An MCP server that does anything useful touches data someone owns, so the first design question for a remote server is not which tools to expose but who is calling and on whose behalf. The protocol answers that question differently depending on the transport and on who, if anyone, is sitting in front of a browser. A local server launched over stdio, a public server used by consumer chat apps, a CI job with no human present and an employee using an IDE at work each need a different model, and choosing the wrong one produces either a security hole or a login prompt nobody can complete.
The OAuth 2.1 flow itself is covered in the MCP authorization architecture article, and the threat model in MCP security in depth. This article compares the models side by side, as of the 2026-07-28 specification (current since July 2026) and the official authorization extensions: what each one proves, when it fits, what the requests look like, and how a single server can accept several of them with one validation path.
Authentication versus authorization in MCP
Authentication establishes who the caller is; authorization decides what that caller may do. MCP folds both into a transport-level contract: for HTTP-based transports, a protected MCP server acts as an OAuth 2.1 resource server and accepts access tokens; the client obtains them from an authorization server. The specification makes authorization optional overall, but says that HTTP implementations that support it should follow it, and that stdio implementations should not, retrieving credentials from the environment instead.
The key idea to hold on to is that every HTTP model ends at the same artefact: a short-lived bearer token, sent in the Authorization header on every request (never in the query string), whose audience is this specific MCP server. The models differ only in how the client earns that token.
Model 1: stdio and environment credentials
A server launched as a subprocess over stdio runs with the user's own privileges on the user's machine. There is no network boundary to defend with OAuth, and the specification says stdio implementations should not use the HTTP authorization flow. Instead the server reads credentials from its environment: an API key for the upstream service in an environment variable, a cloud SDK's default credential chain, or a token file.
This is simple and appropriate for personal tools. Its risks are local ones: secrets pasted into client configuration files end up in backups and dotfile repositories, and any server the user installs can read everything the user can. Prefer the operating system's credential store or a short-lived token helper over literal secrets in configuration, and treat installing a local server as running untrusted code.
Model 2: static API keys, outside the spec
Many remote servers still accept a static key in a header. It works, and every HTTP client can send it, but it is not an MCP authorization model: the specification defines no API-key scheme, so generic clients cannot discover it, cannot obtain it on the user's behalf, and cannot refresh or narrow it. Keys are long-lived, usually not audience-bound, and are rarely scoped per user.
Use static keys only for private integrations you control on both ends, rotate them, and plan to move to one of the OAuth-based models once more than one client or tenant is involved.
Model 3: user-delegated OAuth (the core spec)
When a human uses a client to act on their own data, the core flow applies. The server answers an unauthenticated call with 401 and a WWW-Authenticate header naming its protected resource metadata; the client discovers the authorization server from that document, discovers the server's endpoints through RFC 8414 or OpenID Connect discovery, runs an authorization-code flow with PKCE, and sends the RFC 8707 resource parameter naming the MCP server in both the authorization and token requests.
# 1. Unauthenticated call -> the server points at its protected resource metadata.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.acme.example/.well-known/oauth-protected-resource/mcp",
scope="issues:read"
# 2. GET that URL (RFC 9728). authorization_servers is required.
{
"resource": "https://mcp.acme.example/mcp",
"authorization_servers": ["https://auth.acme.example"],
"scopes_supported": ["issues:read"]
}
# 3. Authorization server metadata (RFC 8414 or OIDC discovery). The client checks:
# code_challenge_methods_supported present (else refuse), and which registration
# options exist: client_id_metadata_document_supported, registration_endpoint.Two details catch implementers. PKCE is mandatory, with S256 where possible, and if the authorization server metadata lacks code_challenge_methods_supported the client must refuse to proceed. And scopes are incremental: a server that needs more rights returns 403 with error="insufficient_scope" and the scopes that operation needs; the client re-authorizes with the union of the scopes it already asked for and the challenged ones, so earlier grants are not lost, and caps its retries. The 2026-07-28 revision also has clients record the authorization server's issuer before redirecting and check the iss parameter on the authorization response (RFC 9207), which defeats mix-up attacks between authorization servers.
Client registration: which identity does the client present?
Before any of this, the authorization server has to know the client. The 2026-07-28 specification lists three mechanisms, and clients that support all of them should try them in this order:
- Pre-registration, when the client already has a client ID for that authorization server, hardcoded or entered by the user.
- Client ID Metadata Documents (CIMD), when the authorization server advertises
client_id_metadata_document_supported. The client ID is an HTTPS URL, and the server fetches a JSON document at that URL whoseclient_idmust match it exactly. - Dynamic Client Registration (RFC 7591), a
POSTto theregistration_endpoint. It is now deprecated and kept only for authorization servers that do not support CIMD. - Prompting the user for client details, as a last resort.
{
"client_id": "https://chat.example.com/oauth/client.json",
"client_name": "Example Chat",
"redirect_uris": ["https://chat.example.com/oauth/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}CIMD fits MCP well because clients and servers usually have no prior relationship, and it avoids every server accumulating thousands of dynamically registered clients. It is also portable: a CIMD client ID works with any authorization server, while pre-registered or dynamically registered credentials are bound to the issuer that created them and must not be reused if the server's metadata points somewhere new. Its known weakness is localhost redirect impersonation: anyone can present a legitimate client's metadata URL and a loopback redirect. The specification tells authorization servers to show the redirect host clearly and warn on localhost-only redirects, and to guard their metadata fetcher against SSRF.
Model 4: client credentials, no human present
A nightly job, a CI pipeline or a backend integration has nobody to click Approve. The OAuth Client Credentials extension (io.modelcontextprotocol/oauth-client-credentials, still a draft in the ext-auth repository) covers this: the client authenticates as itself and receives a token without a browser. It supports a client secret, or the recommended option, a signed JWT assertion (RFC 7523) with iss and sub set to the client ID and aud set to the token endpoint, so no long-lived secret crosses the wire.
The token represents an application, not a person, so authorize accordingly: narrow scopes, per-job client identities rather than one shared robot, and audit logs that record the client ID. The core specification says such clients may attempt step-up or simply fail when they hit insufficient_scope; failing loudly is usually right, because a job should not silently gain rights.
Model 5: enterprise-managed authorization (ID-JAG)
Inside a company, asking every employee to consent to every MCP server individually is both tedious and unmanaged: security teams cannot see or revoke it centrally. The Enterprise-Managed Authorization extension (io.modelcontextprotocol/enterprise-managed-authorization, published as stable) makes the organisation's identity provider the decision-maker. The user signs in to the client through SSO; the client exchanges the resulting ID token at the IdP for an Identity Assertion JWT Authorization Grant (ID-JAG) aimed at the MCP server's authorization server; the IdP applies policy before issuing it; the client then presents the ID-JAG with the JWT bearer grant and receives an ordinary access token. The user is not redirected to the MCP authorization server at all.
# Step 1 -- at the enterprise IdP: exchange the user's ID token for an ID-JAG (RFC 8693).
POST /oauth2/token HTTP/1.1
Host: idp.acme.example
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&requested_token_type=urn:ietf:params:oauth:token-type:id-jag
&audience=https://auth.acme.example
&resource=https://mcp.acme.example/mcp
&scope=issues:read
&subject_token=<ID token from SSO login>
&subject_token_type=urn:ietf:params:oauth:token-type:id_token
&client_id=<client id at the IdP>&client_secret=<secret>
# The IdP evaluates policy (group, app assignment) and returns a JWT with header
# typ "oauth-id-jag+jwt" carrying iss, sub, aud, client_id, resource, scope, jti, exp.
# Step 2 -- at the MCP server's authorization server: present the ID-JAG (RFC 7523).
POST /token HTTP/1.1
Host: auth.acme.example
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&assertion=<ID-JAG>
&client_id=<client id at the MCP authorization server>Revoking an employee's access happens once, at the IdP, and applies across clients. On the server side, validate the ID-JAG's signature against the IdP's keys, check issuer, audience and expiry, and link accounts on the stable sub claim, using email only to match accounts created before enterprise management was switched on.
Downstream calls: never pass the token through
Many MCP servers are wrappers around another API. The token the client sent is audience-bound to the MCP server and must not be forwarded. The specification says so directly: a server must reject tokens not issued for it and must not pass through the token it received. When the server calls upstream, it acts as an OAuth client in its own right and obtains a separate token for that API, through its own user consent flow, a client credentials grant, or a token exchange your identity platform supports. Forwarding the inbound token recreates the confused-deputy problem the audience rules exist to prevent. Gateways that sit in front of many servers face the same rule twice.
Choosing a model
| Situation | Model | Main risk to manage |
|---|---|---|
| Local tool on the user's machine | stdio + environment credentials | Secrets in config files; untrusted local code |
| Private integration, both ends yours | Static key (outside the spec) | Long-lived, unscoped, not discoverable |
| Public server, consumer clients | Auth code + PKCE, CIMD registration | Localhost impersonation; scope creep |
| Jobs and services, no human | Client credentials extension (draft) | Shared robot identities; secret leakage |
| Employees at work | Enterprise-Managed Authorization (stable) | IdP policy gaps; account linking |
Real deployments mix them, and that is fine: the server's job is the same in every case. The validation below accepts a token from any route and checks only what matters: signature, issuer, audience, expiry and scope.
import jwt # PyJWT
from jwt import PyJWKClient
ISSUER = "https://auth.acme.example"
AUDIENCE = "https://mcp.acme.example/mcp" # this server's canonical URI
jwks = PyJWKClient(ISSUER + "/.well-known/jwks.json") # take the real URL from AS metadata
META = "https://mcp.acme.example/.well-known/oauth-protected-resource/mcp"
def authenticate(authorization_header, required_scope):
if not authorization_header or not authorization_header.startswith("Bearer "):
return 401, f'Bearer resource_metadata="{META}", scope="{required_scope}"'
token = authorization_header[len("Bearer "):]
try:
key = jwks.get_signing_key_from_jwt(token).key
claims = jwt.decode(token, key, algorithms=["RS256", "ES256"],
audience=AUDIENCE, issuer=ISSUER) # rejects foreign audiences
except jwt.PyJWTError:
return 401, f'Bearer error="invalid_token", resource_metadata="{META}"'
if required_scope not in claims.get("scope", "").split():
return 403, (f'Bearer error="insufficient_scope", scope="{required_scope}", '
f'resource_metadata="{META}"')
return 200, claims # claims["sub"] is the caller; never forward this token
Worked example: one server, three callers
Acme runs an issue-tracker MCP server at https://mcp.acme.example/mcp. Engineers use it from IDEs; Acme's IdP holds an app assignment for the server, so their clients use enterprise-managed authorization and nobody sees a consent screen. A nightly triage job uses client credentials with a signed assertion and a issues:triage scope only it holds. A partner's customer-facing chat product connects as a public client with a CIMD client ID, and its users log in and consent through the ordinary authorization-code flow with issues:read.
All three present tokens from auth.acme.example with audience set to the server's canonical URI. When a chat user tries to close an issue, the server returns 403 insufficient_scope with scope="issues:write", and the client re-authorizes for issues:read issues:write, the union of old and new. When the server needs build status from the CI system, it uses its own client credentials for that API, never the caller's token. Per-tenant isolation on top of this is covered in multi-tenant MCP servers.
Failure modes
- Audience not checked. A server that accepts any valid token from its issuer will accept tokens minted for other services. Always pass the expected audience to the validator.
- No PKCE check. A client that proceeds when
code_challenge_methods_supportedis missing is exposed to code interception. - Static keys that never retire. A key issued for a pilot is still in a config file two years later with full access.
- Shared robot credentials. One client-credentials identity shared by ten jobs makes audit logs useless and rotation disruptive.
- Token passthrough. Forwarding the inbound token downstream turns the server into a confused deputy.
- Retry storms. A client that loops on
insufficient_scopewithout a limit floods both the user and the authorization server.
What to do next
- Inventory every caller of your MCP servers and place each in one of the five models.
- Serve protected resource metadata and return a
WWW-Authenticateheader withresource_metadataandscopeon 401. - Validate issuer, audience, expiry and scope on every request, with the audience set to your canonical server URI.
- Support CIMD in your authorization server if you run a public server; DCR is deprecated, so keep it only for older clients.
- Move non-interactive callers to the client credentials extension with signed assertions and one identity per job.
- For internal use, talk to your identity team about Enterprise-Managed Authorization and check your clients support it.
- Remove every place where an inbound token is forwarded downstream.