Chapter 266
MCP Auth in Production: Issuer-Bound Enrollment and Tokens
MCP Auth in Production: Issuer-Bound Enrollment and Tokens
Lesson 16 built the OAuth 2.1 state machine. This lesson hardens its production boundaries for MCP 2026-07-28: Client ID Metadata Documents first, deprecated dynamic registration only for compatibility, authorization-response issuer validation, issuer-keyed client credentials, JWKS refresh, and audience-pinned tokens on every stateless request.
Spec note (2026-07-28): Dynamic Client Registration is deprecated in favor of Client ID Metadata Documents. DCR remains a compatibility mechanism. When it is used, the client declares the correct
application_type. A client validates a present RFC 9207issvalue and never reuses credentials across authorization-server issuers.
Type: Build Languages: Python (stdlib) Prerequisites: Phase 13 · 16 (OAuth 2.1 state machine), Phase 13 · 17 (gateways) Time: ~90 minutes
Learning Objectives
- Discover an authorization server through RFC 8414 metadata and verify the contract.
- Enroll through a Client ID Metadata Document and isolate deprecated DCR as a fallback.
- Validate RFC 9207
iss, key registrations by authorization-server issuer, and key resource-bound tokens by issuer plus resource. - Cache and refresh JWKS keys on a schedule so signature verification survives key roll-over.
- Pin tokens to a single MCP resource using RFC 8707 resource indicators and refuse confused-deputy reuse.
- Choose JWT validation or token introspection, define revocation freshness, and fail safely when identity dependencies are unavailable.
- Separate the authorization server, resource server, and client so each enforces only its own checks.
- Audit an authorization server against a deployment checklist and refuse unsafe enrollment or token reuse.
The Problem
The Lesson 16 simulator runs OAuth 2.1 in memory. Production has three operational gaps that a memory-only simulator does not see.
The first gap is enrollment and credential isolation. A real org may run hundreds of MCP servers and thousands of MCP clients. The 2026-07-28 revision prefers a Client ID Metadata Document: the client uses an HTTPS URL with a path that it controls as its identifier, and the authorization server pulls the metadata. RFC 7591 dynamic registration remains only as a deprecated compatibility path. When DCR is unavoidable, the request declares the correct application_type. The client stores registrations under the authorization-server issuer and access tokens under the (issuer, resource) pair. A changed issuer means a new enrollment, and a different resource means a separately audience-bound token.
The second gap is key rotation. JWT validation depends on the authorization server's signing keys, published as a JSON Web Key Set (JWKS). The authorization server rotates these on a schedule (often hourly, sometimes faster under incident response). An MCP server that fetches JWKS once at boot validates fine until the rotation window — then every request fails until restart. Production wires JWKS as a cached value with a refresh job that overwrites the cache before the previous keys expire, plus a fall-back fetch on cache miss for the case where a token signed by a key newer than the cache arrives.
The third gap is audience binding. Lesson 16 introduced RFC 8707 resource indicators. In production, that indicator becomes a hard claim check on every request. The MCP server compares token.aud against its own canonical resource URL and rejects mismatches with HTTP 401. This is the only defense against an upstream MCP server (or a malicious client holding a token meant for one server) replaying that token against another server in the same trust mesh.
This lesson maps each gap onto a concrete piece of the surface. The metadata document is an HTTP endpoint. JWKS cache refresh is a scheduled job plus a key-value cache. JWT validation is a routine the resource server runs before dispatching any tool. Keep the three roles separate and each one enforces only the checks it owns: the authorization server issues and rotates keys, the resource server caches and validates, the client discovers and enrolls.
Scope: Production Enforcement After Lesson 16
Lesson 16: MCP Security with OAuth 2.1 owns the authorization-code state machine, PKCE, protected-resource discovery, resource indicators, and scope decisions. This lesson does not define a second OAuth flow. It starts after those contracts exist and asks how a deployed resource server keeps enforcing them during key rotation, opaque-token validation, revocation, dependency failure, rollout, and incident response.
The production boundary is narrower and more operational:
- A JWT path verifies a pinned issuer, algorithm, signature key, audience, time claims, and scopes on every request while refreshing JWKS safely.
- An opaque-token path calls the issuer's authenticated introspection endpoint and validates the returned active state, audience or resource, expiry, subject, and scopes.
- Revocation policy defines how quickly a credential must stop working and which cache can delay that fact.
- Failure policy decides what happens when discovery, JWKS, introspection, or revocation infrastructure is unavailable.
- Evidence records which issuer metadata, key set or introspection response, token claims, policy version, and refusal reason drove the result without storing the token.
This distinction keeps the lessons composable. Lesson 16 proves the flow. Lesson 18 proves that a token remains trustworthy, or is refused, after it reaches a real MCP request path.
The Concept
RFC 8414 — OAuth Authorization Server Metadata
A document at /.well-known/oauth-authorization-server describes everything a client needs:
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
"client_id_metadata_document_supported": true,
"registration_endpoint": "https://auth.example.com/register",
"authorization_response_iss_parameter_supported": true,
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["mcp:tools.read", "mcp:tools.invoke"],
"token_endpoint_auth_methods_supported": ["none", "private_key_jwt"]
}A client given an MCP resource URL chains discovery: oauth-protected-resource from RFC 9728 (the resource server's document) names the issuer, then oauth-authorization-server (this RFC) names every endpoint. The client never hard-codes an authorization URL.
For a resource identifier with a path, insert the well-known segment before that path. For example, https://mcp.example.com/team/server resolves protected-resource metadata at https://mcp.example.com/.well-known/oauth-protected-resource/team/server. Appending /.well-known/... after the resource path is incorrect.
The contract you verify before trusting an IdP for MCP:
code_challenge_methods_supportedincludesS256(PKCE per RFC 7636). The spec is explicit: if this field is absent, the authorization server does not support PKCE and the client MUST refuse to proceed.grant_types_supportedincludesauthorization_codeand rejectspasswordandimplicit.- At least one enrollment path is available:
client_id_metadata_document_supported: true(CIMD, preferred), a pre-registered client, orregistration_endpoint(deprecated RFC 7591 compatibility). - If
authorization_response_iss_parameter_supportedis true, the client requires the returned RFC 9207issand compares it exactly with the issuer recorded before redirecting. response_types_supportedis exactly["code"]for OAuth 2.1.
If S256 is missing, the MCP server refuses to deploy against this IdP — there is no degraded mode for PKCE. If neither enrollment path is advertised and you have no pre-registered client_id, you also cannot enroll; the deployment manifest is wrong, not the code.
RFC 9728 (recap) — Protected Resource Metadata
Lesson 16 covered RFC 9728. The delta in production: this document is the only place a client looks to find the authorization servers trusted by this MCP server. A single MCP server may accept tokens from multiple IdPs (one for staff, one for partners). RFC 9728 declares that set; RFC 8414 documents what each IdP supports.
{
"resource": "https://notes.example.com",
"authorization_servers": ["https://auth.example.com", "https://partners.example.com"],
"scopes_supported": ["mcp:tools.invoke"],
"bearer_methods_supported": ["header"],
"resource_documentation": "https://notes.example.com/docs"
}Client ID Metadata Documents (the recommended default)
CIMD inverts registration from push to pull. Instead of asking the authorization server to mint a client_id, the client uses an HTTPS URL it controls as its client_id. The URL resolves to a JSON metadata document; the authorization server fetches it on demand during the OAuth flow. Trust is rooted in DNS: if the server operator trusts app.example.com, it trusts the client served from https://app.example.com/client.json. No registration round-trip, no client_id namespace to exhaust, no per-server state to keep in sync.
The metadata document the client hosts:
{
"client_id": "https://app.example.com/oauth/client.json",
"client_name": "Example MCP Client",
"client_uri": "https://app.example.com",
"application_type": "native",
"redirect_uris": ["http://127.0.0.1:7333/callback", "http://localhost:7333/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}The client_id value in the document MUST equal the URL it is served from (the authorization server verifies this; mismatches are rejected). The authorization server advertises support with client_id_metadata_document_supported: true in its RFC 8414 metadata.
For the current CIMD contract, client_id, client_name, and a non-empty redirect_uris array are required. The client identifier is an absolute HTTPS URL with a path. application_type may be included, but it is not a mandatory CIMD field. Do not copy the DCR requirement for application_type into the preferred CIMD path.
Two security facts the spec is blunt about:
- SSRF. The authorization server fetches an attacker-supplied URL. It must defend against server-side request forgery (no fetches to internal/admin endpoints).
- localhost impersonation. CIMD alone cannot stop a local attacker from claiming a legitimate client's metadata URL and binding any
localhostredirect. The authorization server MUST clearly display the redirect URI hostname during consent and SHOULD warn onlocalhost-only redirects.
Because CIMD needs no server-side state, there is no registrar to stand up the way DCR requires. The client side is read-only: serve your metadata document from a static HTTPS endpoint and let the authorization server pull it.
If the authorization server operator has already provisioned a client identifier, use that issuer-scoped registration before trying automatic enrollment. Otherwise prefer CIMD. Use deprecated DCR only when the issuer cannot use either pre-registration or CIMD.
RFC 7591: deprecated compatibility enrollment
DCR is deprecated in the 2026-07-28 revision. Keep it only for authorization servers that cannot consume CIMD and where pre-registration is impractical. A compatibility client posts:
POST /register
Content-Type: application/json
{
"application_type": "native",
"redirect_uris": ["http://127.0.0.1:7333/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"scope": "mcp:tools.invoke",
"client_name": "Cursor",
"software_id": "com.cursor.cursor",
"software_version": "0.42.0"
}The server responds with client_id and a registration_access_token for later updates:
{
"client_id": "c_3e7f1a",
"client_id_issued_at": 1769472000,
"redirect_uris": ["http://127.0.0.1:7333/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"registration_access_token": "regt_b2...",
"registration_client_uri": "https://auth.example.com/register/c_3e7f1a"
}application_type is not decorative. A loopback desktop client declares native; a server-hosted client declares web and uses HTTPS redirect URIs. token_endpoint_auth_method: none is the right default for a public native client. It gets a client_id only, with PKCE providing the proof-of-possession.
Three production pitfalls:
- The registration endpoint must rate-limit by source IP. Without that, a hostile actor scripts millions of fake registrations and exhausts the
client_idnamespace. Run a rate-limit check before the registrar handles the request. software_statement(a signed JWT vouching for the client) is required by some enterprise IdPs. The lesson's mock skips it; production wires a verification step that rejects unsigned registrations from anything other than localhost redirect URIs.- The
registration_access_tokenmust be stored as a hash, not plaintext. Theft of this token means the attacker can rewrite the client's redirect URIs.
RFC 8707 (recap) — Resource Indicators
Lesson 16 established the shape. The production rule: every token request includes resource=<canonical-mcp-url>, and the MCP server verifies token.aud matches its own resource URL on every call. The canonical URI is the most specific identifier for the server: it uses lowercase scheme and host, no fragment, and conventionally no trailing slash. The path component is not stripped by rule — the spec keeps it when it is needed to identify an individual MCP server. https://mcp.example.com, https://mcp.example.com/mcp, https://mcp.example.com:8443, and https://mcp.example.com/server/mcp are all valid canonical URIs. Pick one per server and pin aud to exactly that. (This lesson's mock uses bare-host audiences like https://notes.example.com for brevity; a deployment that co-hosts several MCP servers under one origin distinguishes them by path.)
RFC 7636 (recap) — PKCE
PKCE is mandatory in OAuth 2.1. The lesson's authorization-code flow always carries code_challenge and code_verifier. The server rejects any token request without a verifier or with a verifier that does not hash to the stored challenge.
MCP 2026-07-28 authorization profile
The current MCP revision keeps the OAuth resource-server boundary while making MCP transport stateless. There is no protocol session on which to cache an identity decision. The authorization layer therefore validates each request independently:
- Implement RFC 9728 protected-resource metadata, and provide its location either through the
WWW-Authenticate: Bearer resource_metadata="..."header on a 401 or the well-known URI/.well-known/oauth-protected-resource(SEP-985 made the header optional with a well-known fallback). The metadataauthorization_serversfield MUST name at least one server. - Accept tokens only via
Authorization: Bearer ...on every request — never in a query string, never validated only at session start. - Validate
aud,iss,exp, and required scopes per request. The server MUST validate that the token was issued specifically for it (audience); a missing or mismatchedaudis rejected, never treated as wildcard. - On 401/403, return
WWW-Authenticate: Bearercarryingerror=..., theresource_metadata="<PRM-URL>"parameter (the URL of the metadata document, not the bare resource), andscope="..."oninsufficient_scope(403). Note: the parameter isresource_metadata, a discovery pointer — there is noresourceparameter in the challenge. - Authorization-server discovery accepts either RFC 8414 OAuth metadata or OpenID Connect Discovery 1.0; clients must try both well-known suffixes in priority order.
- The client (not the server) defends against mix-up attacks: it records the expected
issuerbefore redirecting and validates theissvalue returned in the actual authorization response (RFC 9207) before redeeming the code. PKCE alone does not stop mix-up, because the client hands itscode_verifierto whatever token endpoint it was steered to. - A client credential belongs to one authorization-server issuer. If discovery resolves to a different issuer, the client re-enrolls instead of presenting the old
client_id, registration token, or access token. - CIMD is the preferred enrollment mechanism. DCR is deprecated; a compatibility DCR request still declares the correct
application_type.
The OAuth 2.1 draft is the substrate; RFC 8414/7591/8707/9728/9207 + RFC 7636 + CIMD are the surface; the MCP spec is the profile.
Deployment capability checklist
Vendor feature tables become stale quickly. Inspect the metadata returned by the authorization server you will actually deploy instead. The gate is mechanical:
| Check | Required decision |
|---|---|
| Discovered issuer | Exact HTTPS issuer expected by policy |
| PKCE | S256 advertised; otherwise stop |
| Enrollment | CIMD preferred, pre-registration accepted, DCR only as deprecated compatibility |
| Authorization response | Validate RFC 9207 iss when present or advertised |
| Resource binding | Token request carries resource; resource server requires the matching aud |
| Credential storage | Key client IDs and registration credentials by issuer; key access tokens by issuer plus resource |
| DCR compatibility | Declare native or web; reject redirect URIs that do not fit the declared application type |
Do not infer support from a product name or pricing tier. Capture the discovered document in deployment evidence and fail closed when a mandatory field is absent.
JWKS refresh pattern (rotate at the AS, refresh at the resource server)
Keep two verbs separate, because conflating them is a real production bug:
- Rotate is what the authorization server does: mint a new signing key, publish it in the JWKS, retire the old one later. The resource server has no part in this and cannot do it — it does not hold the IdP's private keys.
- Refresh is what the resource server does: re-
GETthe published JWKS into its cache. That is the only JWKS action a resource server ever performs.
The production failure mode is a stale cache. Solve it with a scheduled refresh job plus a key-value cache. The resource server runs a job (cron, timer, whatever your runtime offers) that, on a fixed interval, fetches <issuer>/.well-known/jwks.json and overwrites cache[issuer] = {keys, fetched_at}. The validator reads from that cache. A token whose kid is missing from the cache triggers one synchronous refresh as a fall-back, then re-checks. This handles two cases at once: the scheduled refresh, and key-overlap windows where a token signed by a brand-new key arrives before the next scheduled refresh.
The fall-back must be a re-fetch, never a rotate. If you wire the cache-miss path to a rotate-and-mint, two things break: (1) minting a fresh key produces a kid that still does not match the token, so the lookup fails anyway; and (2) an attacker who sprays tokens with random kid values forces an unbounded series of key creations — a self-inflicted DoS. A re-fetch is idempotent, so a bogus kid costs at most one wasted fetch.
The cache shape:
{
"https://auth.example.com": {
"keys": [
{"kid": "k_2026_03", "kty": "RSA", "n": "...", "e": "AQAB", "alg": "RS256", "use": "sig"},
{"kid": "k_2026_04", "kty": "RSA", "n": "...", "e": "AQAB", "alg": "RS256", "use": "sig"}
],
"fetched_at": 1772668800
}
}Two keys at once is the steady state. Authorization servers rotate by introducing the next key (k_2026_04) before retiring the previous (k_2026_03), so tokens issued under the old key remain valid until they expire. The cache holds the union; the validator picks by kid.
The validation routine
The MCP server runs validation before dispatching any tool. The shape code/main.py uses:
result = server.validate(bearer_token, required_scope="mcp:tools.invoke")
if not result["valid"]:
return {"status": result["status"], "WWW-Authenticate": result["www_authenticate"]}validate decodes the JWT, resolves the signing key from the JWKS cache (refreshing once on a miss), verifies the signature, then checks iss against the allow-list, aud against this server's canonical resource, exp, and the required scope — returning a WWW-Authenticate challenge on the first failure. Keeping it a single routine on the resource server means every entry point (every tool call, every transport) goes through the same checks; there is no path that reaches a tool without validating first.
Opaque tokens use introspection, not guesswork
Not every access token is a JWT. If the issuer documents an opaque token, the resource server cannot decode it into trustworthy claims. It sends the token to the issuer's RFC 7662 introspection endpoint over an authenticated backchannel and requires active: true, the expected issuer context, the exact MCP audience or resource, unexpired time claims, and the scopes required by the concrete tool.
Cache introspection by issuer, a one-way token digest, and MCP resource. Never use the clear token as a log or cache label. Bound a positive cache entry by the earliest of token expiry, issuer cache guidance, and the deployment's revocation freshness objective. Keep negative caching short enough that a newly issued token does not remain falsely inactive. A result for one resource cannot authorize another resource even when the opaque token string is identical.
Do not choose validation mode from attacker-controlled token contents. Pin JWT versus introspection behavior to validated issuer metadata and deployment configuration. On the JWT path, pin accepted algorithms and trusted jwks_uri; never follow a key URL or algorithm selected only by the token header.
Revocation is a freshness contract
RFC 7009 lets a client ask an authorization server to revoke a token. That request does not erase copies already cached by every resource server. Define the maximum acceptable revocation delay and make every cache honor it.
Opaque-token deployments can achieve tighter revocation by introspecting on each high-risk call or using a short positive cache. Self-contained JWT deployments usually combine short access-token lifetimes with refresh-token revocation, key retirement for issuer-wide incidents, and an optional subject, session, or token-id denylist for emergency local refusal. A signed JWT remains cryptographically valid until expiry unless the resource server has current external revocation evidence.
Logout, account disablement, consent withdrawal, and incident response are different triggers but must converge on one measurable statement: after at most the declared revocation window, every replica refuses the credential. Test that statement through the load balancer, not only against one warm process.
Dependency failure needs a declared decision
Never improvise availability policy inside an exception handler.
| Failure | Safe production behavior |
|---|---|
Scheduled JWKS refresh fails, known kid remains in a still-valid bounded cache | Continue only within the declared stale-on-error window and emit degraded health evidence |
Token has an unknown kid and the one allowed refresh fails | Reject; never accept an unverifiable signature |
| Introspection is unavailable | Fail closed for protected calls; do not convert network failure into active: true |
| Protected-resource or issuer metadata changes unexpectedly | Stop new enrollment and token acquisition; keep only explicitly pinned, unexpired configuration under a bounded incident policy |
| Revocation endpoint is unavailable | Report logout or revocation as incomplete, retain the credential locally as unusable when possible, and do not claim global revocation succeeded |
| Clock source or claim type is invalid | Reject rather than widening skew until the token passes |
Classify failures separately from invalid credentials. A dependency outage is an operational error with health and retry policy. A bad signature, issuer, audience, expiry, or scope is an authorization refusal. Neither reaches the tool handler, and neither should leak token contents into audit evidence.
Audience-replay walkthrough (access-token privilege restriction)
Server A (notes.example.com) and Server B (tasks.example.com) both register against the same authorization server. Server A is compromised. The attacker takes a user's notes token and replays it against Server B.
Server B's validator:
- Decode JWT, fetch JWKS by
kid, verify signature. - Check
issagainst its protected-resource metadata'sauthorization_servers. (Pass — same IdP.) - Check
aud == "https://tasks.example.com". (Fail — token'saudishttps://notes.example.com.) - Return 401 with
WWW-Authenticate: Bearer error="invalid_token", error_description="audience mismatch", resource_metadata="https://tasks.example.com/.well-known/oauth-protected-resource".
The audience claim is the only defense against this attack at the protocol layer. Skipping it for performance is the most common production mistake; the validator must run on every request, not just at session start. The spec calls this access-token privilege restriction: an MCP server MUST reject any token that does not name it in the audience.
Naming note. The spec reserves the term confused deputy for a related-but-distinct problem: an MCP server acting as an OAuth proxy to a third-party API, using a static client ID, that forwards a token without obtaining per-client user consent. Audience binding fixes the replay above; the confused-deputy fix is per-client consent plus never passing the inbound token through to upstream APIs (the MCP server
MUSTget its own separate upstream token).
Mix-up attacks (a client-side defense the server cannot provide)
A client talks to many authorization servers over its life. A malicious AS can try to make the client redeem an honest AS's authorization code at the attacker's token endpoint. Audience binding does not help here — the attack happens before any token exists. The defense lives in the client (RFC 9207):
- Before redirecting, the client records the expected
issuerfrom the validated AS metadata. - On the authorization response, the client compares the returned
issparameter against that recorded issuer (simple string comparison, no normalization) before sending the code anywhere. - Mismatch (or
issabsent when the AS advertisedauthorization_response_iss_parameter_supported) → reject, and do not even display theerrorfields.
PKCE alone does not stop mix-up, because the client hands its code_verifier to whatever token endpoint it was steered to. This is why the spec records the issuer per-request alongside the PKCE verifier and state.
Failure modes
- Stale JWKS. The validator rejects valid tokens after the AS rotates a key. The fix is the cron-refresh + cache-miss-refetch pattern above. Never cache JWKS without a refresh job.
- Rotate-as-fall-back. Wiring the cache-miss path to a rotate-and-mint instead of a re-fetch is a real bug: it never produces the missing
kid, and it turns attacker-controlledkidvalues into a key-creation DoS. The fall-back must be the idempotentrefresh-jwks. - Missing
audclaim. Some IdPs default to omittingaudunlessresourceis present in the token request. The validator must reject tokens with missingaud, not treat absence as wildcard. - Mix-up via missing
isscheck. A client that does not validate the RFC 9207issauthorization-response parameter against the issuer it recorded before redirecting can be steered into redeeming an honest AS's code at an attacker's token endpoint. This is a client-side failure; the resource server cannot compensate for it. - Scope upgrade race. Two concurrent step-up flows for the same user can both succeed and produce two access tokens with different scopes. The validator must use the token presented on the request, not look up "the user's current scope" — that creates a TOCTOU window.
- Registration token theft. A leaked
registration_access_tokenlets the attacker rewrite redirect URIs. Hash these at rest; require the client to present the cleartext on every update; rotate on suspicion. issnot pinned. A validator that accepts anyisslets an attacker stand up their own authorization server, register a client for the target audience, and issue tokens. The protected-resource metadata'sauthorization_serverslist is the allow-list; enforce it.- Credential or token cache collision. A client that keys registrations only by resource can present one authorization server's identity to another. A client that keys access tokens only by issuer can replay a token at the wrong audience. Key registrations by validated issuer, key access tokens by
(issuer, resource), and re-enroll whenever the issuer changes.
t3-jwks-rotateUse It
code/main.py walks the full production flow with stdlib Python and three roles: AuthorizationServer, ResourceServer, and Client. The flow:
From the repository root, run:
cd phases/13-tools-and-protocols/18-mcp-auth-production
python3 code/main.py
python3 -m unittest discover -s code/tests -vThe first command prints the issuer-bound enrollment and token-validation transcript. The second reports eighteen passing checks. Neither command opens a network listener or writes credentials.
- Authorization server publishes RFC 8414 metadata at
/.well-known/oauth-authorization-server. - MCP client calls the metadata endpoint and checks its enrollment options (
client_id_metadata_document_supportedfor CIMD,registration_endpointfor DCR) andS256PKCE support. - The client checks for an issuer-scoped pre-registration, otherwise enrolls with its HTTPS Client ID Metadata Document. Deprecated DCR remains a separately testable compatibility method.
- The client records the validated issuer, creates an S256 challenge, receives a one-time authorization code plus
iss, validates that returned issuer, and redeems the code with the original verifier and RFC 8707resourceindicator. - MCP client calls a tool on the MCP server with
Authorization: Bearer .... - MCP server runs
validate, resolving the signing key from the JWKS cache. - The IdP rotates a key; the scheduled refresh re-pulls the JWKS into the cache.
- The next call validates against the refreshed keys without restart, and the previous token still validates during the overlap window.
- An audience-replay attempt against a different MCP resource gets 401 with
audience mismatchand aresource_metadatapointer.
The JWT here uses HS256 with a shared secret (so the lesson runs on stdlib only). Production uses RS256 or EdDSA with the JWKS pattern above; the validation logic is otherwise identical. Because the IdP and resource server live in one process, refresh_jwks reads the authorization server's key list directly; over the wire it is an HTTP GET to jwks_uri.
Ship It
This lesson produces outputs/skill-mcp-auth.md. Given an MCP server config and an IdP capability set, the skill emits the auth surface to stand up — the protected-resource metadata, the enrollment path to use (CIMD, pre-registration, or DCR fallback), the JWKS refresh schedule, the scope mapping, and the refusal rules to apply when the IdP does not support the full RFC profile.
Exercises
-
Run
code/main.py. Trace the flow. Note how the IdP rotates a key in step 6, the scheduledrefresh_jwksre-pulls the published set, and both the old token (overlap window) and a fresh token validate without restart. -
Add a new IdP to the protected-resource metadata's
authorization_serverslist. Issue a token signed by the new IdP and confirm the validator accepts it. Issue a token signed by an unlisted IdP and confirm the validator rejects withWWW-Authenticate: Bearer error="invalid_token", error_description="iss not allowed". -
Add a rate-limit check to
register_clientthat runs before the registrar accepts a request. Use a token-bucket per source IP held in a small dict keyed by IP. -
Read RFC 7591 and identify two fields the lesson's
/registerhandler does not validate. Add the validation. (Hint:software_statementandredirect_urisURI scheme.) -
Add a second authorization server. Confirm the client stores a separate issuer-keyed enrollment and refuses to reuse the first issuer's token or
client_id. -
Prove the DoS fix. Send the validator a token with a random
kidand confirmrefresh_jwksruns at most once and the authorization server's key count does not grow. Then deliberately re-wire the fall-back to a rotate-and-mint and watch the key count climb per bogus token — restore the re-fetch afterward. -
Exercise deprecated DCR with both
nativeandwebclients. Confirm a web client with an HTTP redirect URI and a native client without an exact loopback redirect are rejected.
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| ASM | "OAuth metadata document" | RFC 8414 /.well-known/oauth-authorization-server JSON |
| CIMD | "Client metadata URL" | Client ID Metadata Document: an HTTPS URL used as the client_id; the AS pulls the JSON. Preferred enrollment in MCP 2026-07-28 |
| DCR | "Self-service client registration" | RFC 7591 POST /register; deprecated for current MCP and retained only for compatibility |
| JWKS | "Public keys for JWT validation" | JSON Web Key Set, fetched from jwks_uri, indexed by kid |
| Rotate vs refresh | "Updating the keys" | Rotate = AS mints/retires signing keys; refresh = resource server re-fetches the published set. Resource servers only ever refresh |
| Resource indicator | "Audience parameter" | RFC 8707 resource parameter pinning the token to one server |
aud claim | "Audience" | JWT claim the validator compares against the canonical resource URL |
| Audience replay | "Token replay" | Token issued for Server A presented to Server B; defended by audience validation (spec: access-token privilege restriction) |
| Confused deputy | "Proxy token misuse" | An MCP proxy with a static client ID forwarding a token without per-client consent; distinct from audience replay |
| Mix-up attack | "Wrong token endpoint" | Client steered to redeem an honest AS's code at an attacker's endpoint; defended client-side via RFC 9207 iss |
iss allow-list | "Trusted authorization servers" | The set named in protected-resource metadata's authorization_servers |
resource_metadata | "Where to find the PRM doc" | WWW-Authenticate parameter naming the RFC 9728 metadata URL on a 401/403 |
| Public client | "Native or browser client" | OAuth client with no client_secret; PKCE compensates |
WWW-Authenticate | "401/403 response header" | Carries Bearer error=... directives that drive client recovery |
Further Reading
- MCP authorization specification (2026-07-28) - the current MCP authorization profile
- MCP 2026-07-28 changelog - CIMD, issuer validation, DCR deprecation, and issuer-keyed credential changes
- OAuth Client ID Metadata Document (draft-ietf-oauth-client-id-metadata-document-00) — CIMD
- RFC 8414 — OAuth 2.0 Authorization Server Metadata — discovery contract
- RFC 7591 — OAuth 2.0 Dynamic Client Registration Protocol — DCR (fallback path)
- RFC 7636 — Proof Key for Code Exchange (PKCE) — public-client proof-of-possession
- RFC 8707 — Resource Indicators for OAuth 2.0 — audience pinning
- RFC 9728 — OAuth 2.0 Protected Resource Metadata — resource server discovery
- RFC 9207 — OAuth 2.0 Authorization Server Issuer Identification — the
issparameter that defends against mix-up attacks - RFC 7662: OAuth 2.0 Token Introspection
- RFC 7009: OAuth 2.0 Token Revocation
