Secure MCP authentication
MCP's authorization story is OAuth 2.1, and most of the ways to get it wrong are the ways people have been getting OAuth wrong for a decade. Two of them are specific to MCP, and both are subtle.
Where MCP landed
The 2025 specification revisions gave MCP a formal authorization framework based
on OAuth 2.1, aimed at remote servers. Authorization is optional in the
protocol, a local stdio server started by your editor does not need it, but as
soon as a server is reachable over the network, it does.
The two documents to read:
Both are short. Both contain requirements that are easy to skip and expensive to skip.
HTTPS, first
Before any of the OAuth detail: remote MCP connections must use TLS.
This is not the usual "encrypt data in transit" argument, or not only. An attacker on the path to an MCP server does not merely read the traffic. They can rewrite tool results. Tool results go into a model's context and influence what it does next, so a plaintext MCP connection is a prompt-injection channel that requires no cleverness at all.
http://localhost in development is fine. Anything else is not.
GATE003.
The OAuth shape
At a high level:
- The MCP client discovers the server's authorization requirements.
- The user authorises through their browser, at an authorization server.
- The client receives an access token scoped to this MCP server.
- The client presents it as
Authorization: Bearer …on each request. - The MCP server validates it, including the audience.
The important structural point is that there are three parties, not two: the client, the MCP server, and whatever upstream API the MCP server talks to. Most MCP authentication bugs are confusions between the second and the third.
PKCE
Use PKCE. It is mandatory in OAuth 2.1 for all clients, and MCP clients are exactly the case it was designed for: public clients that cannot keep a secret, completing a redirect on a machine where other software is running.
Without it, an authorization code intercepted on the redirect can be exchanged by whoever intercepted it. With it, the code is useless without the verifier that only the initiating client has.
This is not optional and not a nice-to-have. If an MCP client library offers it as a setting, the setting has one correct value.
Audience validation
An MCP server must reject tokens that were not issued for it.
This is the requirement most likely to be skipped, because everything appears to work without it. A server that validates the signature and expiry of a token, and nothing else, accepts any valid token from that issuer, including one a user granted to a completely different application.
The attack is straightforward. An attacker runs an unrelated service, gets a user to authorise it, receives a valid token from the shared identity provider, and replays it at your MCP server. Your server checks the signature, sees a valid token, and acts.
Two halves to getting this right:
Clients must use the resource parameter from
RFC 8707 to say which resource
the token is for.
Servers must check the aud claim (or the equivalent for their token
format) and reject anything that does not name them.
Neither half works alone.
Token passthrough
An MCP server must not forward the token it received to an upstream API.
This is the second MCP-specific mistake, and it is tempting because it is the path of least resistance. Your MCP server receives a token; it needs to call GitHub; it has a token; it sends it.
What that destroys:
- The audience restriction you just implemented. The token was minted for your server. Sending it onward makes it a general-purpose credential again.
- Attributable logs. The upstream sees the user, not your server, so the audit trail cannot distinguish a human action from an agent action.
- Scope boundaries. Your server now grants everything the token grants, not everything your server intended to expose.
- Revocation. Revoking your server's access does not revoke the copies it handed onward.
The correct pattern is token exchange: present the incoming token to the authorization server and receive a different token, scoped to the upstream resource. Or use the server's own credential and carry the user identity separately.
The confused deputy
The classic OAuth failure, with a specifically MCP-flavoured version.
An MCP server acting as a proxy to a third-party API, using a static client ID with that API, can be induced to obtain authorization codes without proper user consent: the interaction of static client registration, dynamic client registration, and consent cookies lets an attacker have a code redirected to them and exchanged for a token the user never meant to grant.
The mitigation, per the specification: an MCP proxy server must obtain per-client consent rather than relying on a previously-remembered authorization. If your server sits between a user and someone else's API, this applies to you.
The general shape applies well beyond OAuth. A confused deputy is any component with more authority than its caller that can be talked into using it. An MCP server is a deputy by construction. So, for that matter, is an agent, which makes prompt injection and confused-deputy problems the same problem viewed from two angles.
Storing credentials
Wherever a token ends up at rest:
- OS keychain or a secret manager, for anything long-lived.
- Environment variables injected at runtime, as the workable compromise.
- Never a file in the repository. GATE001.
- Never a URL. Query strings reach access logs, proxy logs,
Refererheaders and browser history. GATE004. - Never argv. Readable by every process on the machine. GATE002.
- Never a log line, including truncated and including in error messages.
For configuration you publish, always show the reference form:
{
"mcpServers": {
"vendor": {
"type": "http",
"url": "https://mcp.vendor.example/v1",
"headers": { "Authorization": "Bearer ${env:VENDOR_TOKEN}" }
}
}
}
People copy examples verbatim. Your README is where their .mcp.json comes
from.
Short-lived and scoped
RFC 9700, the OAuth security best current practice, is the reference here, and the two properties that matter most are the boring ones.
Short-lived. Issue access tokens that expire in minutes, not months. A leaked token with a deadline is a contained incident. The MCP specification recommends this explicitly, and notes that the protocol does not itself mandate lifecycle management, which means it is on you.
Scoped. Narrow scope bounds every tool on the server at once, including the ones added next month. It is the highest-leverage single change available: one credential edit, whole-server effect.
Rotation only happens if it is easy, so make it possible to rotate without a redeploy.
What to check
For a server you are building:
- TLS required for all remote connections
- OAuth 2.1 with PKCE
-
audclaim validated; tokens for other resources rejected - No incoming token is ever forwarded upstream
- Per-client consent if acting as a proxy
- Short-lived access tokens, refresh where appropriate
- Scopes are the narrowest that make the server useful
- Tokens never logged, never in URLs, never in argv
For a server you are installing:
npx @usegate/cli scan
Gate will tell you if the URL is plaintext (GATE003), if credentials are embedded in it (GATE004), if there is no visible authentication at all (GATE017), and if credentials are reaching the server through a channel that exposes them (GATE002).
It cannot tell you whether the server validates the audience. Nothing outside that server can. Ask.