# How to secure MCP servers

Canonical: https://www.swfte.com/how-to-secure-mcp-servers
Last verified: 2026-10-06
Difficulty: Advanced
Time: About half a day to inventory and apply client-side controls; one to three days to harden a server you build
Cost: No licence cost for the controls described. You pay for your own identity provider, logging and engineering time.

## Short answer

Treat every MCP server as code you are choosing to run and every tool result as untrusted input. Require OAuth with audience-checked tokens on remote servers, refuse token passthrough, grant the smallest scopes and raise them only on demand, sandbox local servers behind explicit consent, keep an allow-list of approved servers, put a person in front of sensitive tools, and log each call.

## Who this is for

- Platform and security engineers rolling out MCP servers to developers or to production agents.
- Teams that build their own MCP server and need to know what the specification requires of it.
- Anyone approving which MCP servers a company may connect to.

Not for:
- People who only want to defend against prompt injection in general: start with [how to stop prompt injection](https://www.swfte.com/how-to-stop-prompt-injection).
- Anyone looking for a product comparison of MCP gateways: see [the MCP gateway page](https://www.swfte.com/mcp-gateway).

## Prerequisites

- A list of the MCP servers in use today (local and remote), and who owns each.
- For servers you build: an OAuth 2.1 capable authorisation server, or a plan to use your existing identity provider.
- Access to your MCP client configuration. The examples use Claude Code because its documentation was readable in full; the controls apply to any client.
- Agreement on which tools are read-only and which change something outside the model (send, write, delete, pay, deploy).

## Which transport, and which controls apply

The controls differ by transport. Pick the row that matches each server before you start, so you do not apply OAuth thinking to a stdio server or sandbox thinking to a remote one.

|  | Local (stdio) | Remote (HTTP) |
| --- | --- | --- |
| Who can reach it | Only the client process, if you use stdio | Anything that can reach the URL and present a token |
| Main risk | Code runs with your privileges | Stolen or misdirected tokens, SSRF, confused deputy |
| Credentials | Taken from the environment (the specification says not to run the OAuth flow) | OAuth 2.1 with audience-checked tokens |
| Key controls | Consent for the command, sandbox, pinned package | Audience validation, minimal scopes, no passthrough, egress limits |

## Steps

### Step 1: Inventory every server and read what it can do

Outcome: A table of servers with owner, transport, data reached, and read versus write tools.

List every MCP server your people and agents connect to. Include servers that arrive through a project file checked into a repository, because anyone who clones that repository inherits the entry. For each one record the owner, whether it runs locally or remotely, the systems it can reach and the tools it exposes.

Split the tools into two groups: ones that only read, and ones that change something outside the model. The second group is where a mistake costs money or data, so it gets the strictest controls in the later steps. The specification itself says clients must treat tool annotations as untrusted unless they come from trusted servers, so do not accept a server's own claim that a tool is read-only. Check by reading the tool code or by testing against a copy.

Add a column for "can fetch external content". A server that reads web pages, tickets or email brings text you do not control into the model, which is the route for indirect prompt injection. Claude Code's documentation says the same thing in its warning: servers that fetch external content can expose you to prompt injection risk.

**Inventory columns worth recording**

| Column | Why it matters |
| --- | --- |
| Owner and contact | Someone must answer when a server misbehaves or its maintainer disappears. |
| Transport (stdio or HTTP) | Decides which controls apply: local sandboxing versus OAuth and network policy. |
| Systems and data reached | Sets the blast radius if a token or the server is compromised. |
| Write-capable tools | These get approval gates and narrower scopes. |
| Fetches external content | Marks the servers that can carry injected instructions into the model. |

### Step 2: Require OAuth with audience-checked tokens on remote servers

Outcome: Remote servers reject any token that was not issued for them.

For HTTP transports the specification (revision 2026-07-28, the latest when we read it) builds on OAuth 2.1. The MCP server acts as an OAuth resource server and must implement OAuth 2.0 Protected Resource Metadata (RFC 9728). Clients use that metadata to find the authorisation server. Stdio servers are the exception: the specification says they should not follow this flow and should take credentials from the environment instead.

Two rules do most of the work. First, the client must send a resource parameter (RFC 8707) naming the MCP server in both the authorisation request and the token request. Second, the server must check that the token was issued specifically for it, and the specification states that servers "MUST NOT accept or transit any other tokens". Tokens go in the Authorization header on every request and never in the query string. Expired or invalid tokens get an HTTP 401.

If you build the server, validate the audience claim on every request before any tool code runs. If you only consume servers, ask the vendor how they validate audience, and prefer servers that publish the metadata document. Whichever side you are on, test the failure cases in the next step: the controls only count if a wrong token is actually refused.

What a correct 401 challenge looks like (from the specification):

```text
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
                         scope="files:read"
```

What the client appends to authorisation and token requests:

```text
&resource=https%3A%2F%2Fmcp.example.com
```

> NOTE: The specification marks Dynamic Client Registration as deprecated and recommends Client ID Metadata Documents. If your authorisation server supports neither yet, check with its vendor before building new flows on registration.

### Step 3: Refuse token passthrough and add per-client consent to proxies

Outcome: No token crosses from one service to another unchecked, and proxies ask the user before forwarding.

Token passthrough is the shortcut where an MCP server accepts a token from the client and forwards it to a downstream API unchanged. The security best-practices page forbids it. It breaks audit trails (the downstream API cannot tell which client acted), skips controls that rely on the token audience, and lets a stolen token use your server as a proxy. The fix is plain: the server accepts only tokens issued for itself and, if it needs downstream access, obtains its own token for that API.

If you run an MCP proxy server that sits in front of a third-party API using one static OAuth client ID, you also face the confused deputy problem. The page describes the conditions: a static client ID, dynamic client registration by MCP clients, a consent cookie on the third-party authorisation server, and no per-client consent in the proxy. The attacker registers a client with a malicious redirect URI, sends the victim a crafted link, and the existing consent cookie lets the code flow to the attacker without a consent screen.

The required protections are specific. Keep a registry of approved client IDs per user and check it before forwarding to the third party. Show a consent page that names the client, lists the scopes and shows the registered redirect URI, with CSRF protection and anti-framing headers. Match redirect URIs by exact string, not by pattern. Generate a random single-use `state` value, and set the state cookie only after the user approves. Use the `__Host-` prefix and the `Secure`, `HttpOnly` and `SameSite=Lax` attributes on any consent cookie.

**Negative tests to run against a server you build (each should fail)**

| Test | Expected result |
| --- | --- |
| Call a tool with a valid token issued for a different resource | 401, tool code never runs |
| Call with an expired token | 401 |
| Put the token in the query string | Rejected: tokens must not be in the URI |
| Register a client whose redirect URI differs by one character from the approved one | Request rejected (exact match) |
| Replay an authorisation callback with an old or missing `state` | Callback rejected |

### Step 4: Grant the smallest scopes and raise them on demand

Outcome: Tokens carry only what the current task needs, and every elevation is logged.

Broad scopes turn a leaked token into a skeleton key. The specification's guidance is progressive least privilege: start with a minimal set (it gives `mcp:tools-basic` as an example) covering low-risk discovery and read operations, and ask for more only when a privileged operation is first attempted.

On the server, use the `WWW-Authenticate` header to say what is needed for the operation in hand. A 403 with `error="insufficient_scope"` and the missing scope tells a compliant client to run a step-up authorisation. Include everything the operation needs in one challenge, so the user is not sent round the loop several times. Do not publish every scope you have in `scopes_supported`, do not use wildcard scopes such as `*` or `all`, and do not trust the scopes claimed in a token as a substitute for your own authorisation check on the resource.

Log each elevation with the scope requested, the subset granted and a correlation ID. Over a month those logs show which clients ask for more than they use, and which scopes users decline.

Insufficient scope response (from the specification):

```text
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
                         scope="files:write",
                         resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
                         error_description="File write permission required for this operation"
```

### Step 5: Put local servers behind consent and a sandbox

Outcome: A local server cannot start without a person seeing the exact command, and it runs with least privilege.

A local MCP server is a program that runs with your privileges. The specification lists how it goes wrong: a malicious startup command in a shared configuration, a malicious payload inside the server, or an insecure server left listening on localhost and reached through DNS rebinding. Its examples include a startup command that posts a private SSH key to a remote host.

The client-side rules are clear. If a client offers one-click setup it must show the exact command, untruncated, mark it as code execution, and require approval. Clients should flag patterns such as `sudo`, `rm -rf`, network calls and access outside expected directories, and run servers in a sandbox with minimal default privileges. As the person configuring clients, choose clients that do this, and keep project-level server files under code review like any other file that executes code.

If you write a local server, use the stdio transport so only the client can reach it. If you must use HTTP locally, require an authorisation token or use a Unix domain socket with restricted access. Do not leave an unauthenticated server on a localhost port.

Claude Code adds one control worth turning on knowingly: it asks for approval in interactive sessions before using project-scoped servers from a `.mcp.json` file. You can reset those approval choices with a command, which is useful after a repository changes hands.

Reset project-scoped server approvals in Claude Code:

```bash
claude mcp reset-project-choices
```

> WARNING: The specification also says clients must not open authorisation URLs through a shell and must reject `javascript:`, `data:` and `file:` schemes. If you build a client, add tests for these. If you buy one, ask.

### Step 6: Keep an allow-list of approved servers

Outcome: Only servers you have reviewed can be connected, and the rule is enforced by configuration, not by memory.

Review each server once against the inventory from step 1, then enforce the result. In Claude Code, organisations can set `allowedMcpServers` and `deniedMcpServers`, by server name or by URL pattern, in managed configuration. Other clients have equivalents or can be fronted by a gateway that only forwards to approved servers.

Pin what you can. Record the exact package or image you approved, and re-review when it changes, because a server that changes its tool descriptions after approval changes what the model reads. The protocol lets servers announce changes to their tool list, so clients and gateways should surface a changed list for review instead of silently accepting it.

Prefix tool names with a server identifier where several servers are aggregated. The specification notes that two servers may both expose a tool called `search`, and recommends a disambiguation strategy such as prefixing. Without it, a hostile server can shadow a trusted tool.

Allow and deny lists by name (Claude Code managed configuration):

```json
{
  "allowedMcpServers": ["github", "slack", "notion"],
  "deniedMcpServers": ["internal-api"]
}
```

Or by URL pattern:

```json
{
  "allowedMcpServers": [
    {"serverUrl": "https://mcp.*.example.com/.*"}
  ],
  "deniedMcpServers": [
    {"serverUrl": "https://untrusted.com/.*"}
  ]
}
```

### Step 7: Gate sensitive tools with a person and validate inputs

Outcome: Write-capable tools need explicit approval, and the server rejects malformed or hostile arguments.

The tools page says there should always be a human in the loop with the ability to deny tool invocations, and that clients should show tool inputs to the user before calling the server to avoid accidental or malicious data exfiltration. Apply that to the write-capable list from step 1: send, delete, pay, deploy, change permissions. Read-only tools on trusted servers can run without a prompt, but keep the log.

On the server, the specification says you must validate all tool inputs, implement access controls, rate-limit tool invocations and sanitise tool outputs. Check against the schema, not just the type: lengths, allowed values, path prefixes. A tool that takes a file path should resolve it and refuse anything outside its allowed directory. A tool that runs a query should use the caller's permissions, not the server's.

Clients should also validate results before passing them to the model and set timeouts on tool calls. A tool result is data from outside your trust boundary. Treat any instruction inside it as text to report, not an order to follow. The same applies to resource and prompt content that servers provide.

### Step 8: Restrict outbound requests from clients and servers

Outcome: A hostile server or metadata URL cannot make your client call internal addresses.

During OAuth discovery a client fetches several URLs that a malicious MCP server controls. The security page describes the result: a server can point those fetches at a cloud metadata address, an internal admin service or a local database, and the client acts as a proxy past your firewall.

Mitigations from the page: require HTTPS for OAuth URLs outside local development; block private, loopback and link-local ranges (it lists `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `169.254.0.0/16` and the IPv6 equivalents); validate redirect targets and consider disabling automatic redirects; and for server-side clients, route requests through an egress proxy. It names Smokescreen as an example. It also warns against writing your own IP validation, because encoding tricks defeat custom parsers, and notes DNS rebinding can change an address between check and use.

Apply the same egress thinking to the MCP server process. A server that only needs to reach one API should be allowed to reach one API, whether you enforce that with network policy, a proxy or a container profile.

### Step 9: Log every call and review on a schedule

Outcome: You can answer who called which tool, with what arguments, under which token, and what came back.

The tools page asks clients to log tool usage for audit purposes, and the best-practices page asks servers to log scope elevations. Put both in one place. For each call record the time, the user or agent identity, the server, the tool, a hash or redacted copy of the arguments, the scopes on the token, the outcome and whether a person approved it.

Keep arguments out of logs where they may hold personal data or secrets, or store them with the same controls as the source system. Decide retention before you need it. A log nobody reads protects nobody, so set a review: weekly for new servers and write-capable tools, monthly for the rest. Look for tools called at odd hours, scopes requested but never used, and servers whose tool lists changed.

When you find a compromise, you need two things fast: a way to revoke the server's tokens and a way to disable it in the allow-list. Practise both once on a test server, so the first time is not during an incident.

## About tool description poisoning

A tool's name and description are text the model reads, so a malicious or compromised server can write instructions into them. The specification does not give this attack its own section. What it does say bears directly on it: tool annotations are untrusted unless they come from trusted servers, tool lists can change over time, and clients should validate results and keep a human in the loop. The practical answer is the combination in steps 6 and 7: an allow-list so only reviewed servers load, review when a tool list changes, and approval gates on tools that can act. We have not found an official control that removes the risk, and the OWASP guidance on prompt injection says plainly that it is unclear whether fool-proof prevention exists.

## Troubleshooting

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| A client gets 401 from your MCP server after a successful login | The token audience does not match the server, usually because the client omitted the `resource` parameter or sent a different canonical URI. | Send `resource` with the canonical server URI in both the authorisation and token requests, and check the token's audience claim against the same string. |
| Repeated authorisation prompts for the same operation | The server returns one missing scope per challenge, or the client replaces its scope set instead of taking the union. | Return all scopes the operation needs in a single `insufficient_scope` challenge. In the client, combine previously requested scopes with the new ones, and cap retries. |
| A proxy lets a registered client skip the consent screen | A consent cookie on the third-party authorisation server is trusted for a static client ID, with no per-client consent in the proxy. | Add per-client consent in the proxy before forwarding, store approvals by client ID, and set the state cookie only after approval. |
| A local server starts without any prompt | The client config was shared through a repository and approvals are disabled, or the client does not show the startup command. | Reset approvals, review the committed config as code, and choose a client that shows the full command before it runs. |
| Two servers expose a tool with the same name and the wrong one runs | Tool names are only unique within one server; an aggregator did not prefix them. | Prefix tool names with a server identifier at the gateway or client, and deny servers that shadow names of approved tools. |
| The audit log shows calls but no identity | Calls go through a shared service credential, or tokens were passed through to downstream APIs. | Issue per-user or per-agent tokens and log the identity from the verified token, not from a request field. |

## Verify it worked

- [ ] Every MCP server in use appears in the inventory with an owner, transport and list of write-capable tools.
- [ ] A token issued for another resource is refused by every remote server you operate, and the refusal is tested, not assumed.
- [ ] No server forwards a client token to a downstream API; downstream access uses the server's own credentials.
- [ ] The default scopes cover read and discovery only, and an elevation produces a log entry with the granted subset.
- [ ] Connecting a server that is not on the allow-list fails, and the failure is visible to the person trying.
- [ ] A write-capable tool call needs a person's approval, and the approval is in the audit log.
- [ ] You have revoked a test server's tokens and removed it from the allow-list within a few minutes.

## Next steps

- [How to stop prompt injection](https://www.swfte.com/how-to-stop-prompt-injection): the model-side layers that sit alongside these server controls
- [How to set up human approval for AI agents](https://www.swfte.com/how-to-set-up-human-approval-for-ai-agents): design the approval gates from step 7 properly
- [MCP security best practices](https://www.swfte.com/mcp-security-best-practices): Swfte's longer reference on the same topic
- [MCP and tool security](https://www.swfte.com/secops/mcp-and-tool-security): how this fits the wider runtime-security picture
- [How to red team an LLM](https://www.swfte.com/how-to-red-team-an-llm): test the finished setup with hostile inputs

## FAQ

### Is MCP secure by default?

No. The protocol defines authorisation for HTTP transports and publishes security guidance, but it is optional and most protections depend on how servers and clients are built and configured. Local servers run with your privileges, and tool results are untrusted input to the model.

### What is token passthrough in MCP and why is it forbidden?

It is when an MCP server accepts a client token and forwards it unchanged to a downstream API. The specification forbids it because it bypasses audience checks and controls, weakens audit trails and lets a stolen token use the server as a proxy. Servers must accept only tokens issued for them.

### Do stdio MCP servers need OAuth?

No. The authorisation specification says stdio implementations should not follow the OAuth flow and should take credentials from the environment. Their risk is different: they execute code locally, so use consent for the startup command, a sandbox and pinned, reviewed packages.

### What is the confused deputy problem in MCP?

It affects MCP proxy servers that use one static client ID with a third-party authorisation server while letting clients register dynamically. A consent cookie from an earlier approval can let an attacker's client obtain a code without a consent screen. Per-client consent in the proxy prevents it.

### How do I restrict which MCP servers my team can use?

Review servers against an inventory, then enforce an allow-list in the client or a gateway. In Claude Code, managed configuration accepts `allowedMcpServers` and `deniedMcpServers` by name or URL pattern. Re-review a server when its package or tool list changes.

### Can I stop tool poisoning completely?

We found no official control that removes it. Reduce it by loading only reviewed servers, reviewing changed tool lists, treating annotations as untrusted, validating results and requiring human approval for tools that act. OWASP says it is unclear whether fool-proof prevention of prompt injection exists.

## How Swfte can help

Everything above works with open standards and your own identity provider. If you want one place to apply the allow-list, audit and policy for agent tool calls, Swfte describes an MCP gateway designed for that.

- [MCP gateway](https://www.swfte.com/mcp-gateway): central auth, audit and policy on tool calls, as Swfte describes it
- [Agent runtime security](https://www.swfte.com/secops/agent-runtime-security): permissions and containment for agents that call tools
- [AI governance platform](https://www.swfte.com/platform/governance): policy and evidence across models, agents and tools

Whether a given control is available in your Swfte deployment depends on your configuration: <MCP gateway availability and deployment options - founder to fill>. You can complete every step in this guide without Swfte.

## Sources

- [MCP specification: Security Best Practices (latest)](https://modelcontextprotocol.io/specification/latest/basic/security_best_practices): Confused deputy, token passthrough, SSRF, state handles, local server compromise, authorisation URL validation, scope minimisation, as read on 2026-10-06 (links resolve to revision 2026-07-28).
- [MCP specification: Authorization (latest)](https://modelcontextprotocol.io/specification/latest/basic/authorization): OAuth 2.1 basis, Protected Resource Metadata, resource parameter, audience validation, scope challenges, 401 and 403 handling, stdio exception.
- [MCP specification: Tools (latest)](https://modelcontextprotocol.io/specification/latest/server/tools): Human in the loop wording, untrusted annotations, tool name collisions, server and client security considerations, audit logging.
- [Claude Code documentation: Connect to tools via MCP](https://code.claude.com/docs/en/mcp): Third-party server warning, .mcp.json approval prompt, reset-project-choices command, allowedMcpServers and deniedMcpServers examples.
- [OWASP GenAI: LLM01 Prompt Injection](https://genai.owasp.org/llmrisk/llm01-prompt-injection/): Statement that fool-proof prevention is unclear, and the list of mitigations including least privilege and human approval.

Last verified against these sources on 2026-10-06.
