TAI BUI
Back to curriculum
BuildPython

MCP Auth in Production — Enrollment, JWKS Refresh, Audience-Pinned Tokens

Lesson 16 stood up the OAuth 2.1 state machine in memory. By 2026, every MCP server you ship to a real org sits behind production auth: client enrollment that scales to an unbounded client population (Client ID Metadata Documents first, dynamic client registration as a backwards-compatible fallback), authorization-server metadata discovery (RFC 8414 *or* OpenID Connect Discovery), JWKS cache refresh that does not break a 3 a.m. token validation, and audience-pinned tokens that refuse cross-resource replay. This lesson models the full surface with three roles (an authorization server, a resource server that is the MCP server, and a client) so you can trace each hop from discovery to a validated tool call. **Spec note (2025-11-25):** the November 2025 MCP authorization spec demoted Dynamic Client Registration from `SHOULD` to `MAY` and made **Client ID Metadata Documents (CIMD)** the recommended default enrollment mechanism. This lesson teaches both, in the spec's priority order, and the code keeps DCR for the walk-through because it is fully self-contained in one process.

TIME: ~90 minutesPREREQUISITES: Phase 13 · 16 (OAuth 2.1 state machine), Phase 13 · 17 (gateways)

Learning Objectives

  • Discover an authorization server through RFC 8414 metadata, and refuse to deploy when the IdP cannot satisfy MCP's auth profile.
  • Enroll MCP clients with CIMD first and RFC 7591 dynamic client registration as the fallback, without admin intervention.
  • 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.
  • Separate the three roles cleanly (authorization server, resource server, client) so each enforces only the checks that belong to it.

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. A real org runs hundreds of MCP servers and thousands of MCP clients. Operators do not hand-register every Cursor user as an OAuth client. The 2025-11-25 spec gives clients a priority order for solving this: use a pre-registered client_id if you have one, else use a Client ID Metadata Document (the client identifies itself with an HTTPS URL it controls and the authorization server pulls the metadata), else fall back to RFC 7591 dynamic client registration (the client pushes a POST /register and receives a client_id on the spot), else prompt the user. CIMD is the recommended default because it removes per-server registration entirely while keeping a DNS-rooted trust model; DCR is retained for backwards compatibility. Both discover their entry points from the authorization server's metadata: client_id_metadata_document_supported for CIMD, registration_endpoint for DCR.

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.

Warm-up0/3
1. In the OAuth 2.1 authorization code flow, what does PKCE protect against?Warm-up
A client and an authorization server with three arrows: an authorize request carrying a code_challenge, a redirect back carrying a code, and a token request carrying the code and a code_verifier.
Typed schematic of the flow, not captured.
2. What does the aud claim in an access token identify?Warm-up
A token drawn as three boxes, header, payload and signature, and below them seven chips naming the payload claims: iss, sub, aud, azp, scope, iat and exp, with no values.
Claim names only.
3. A service signs tokens with a private key. How can other services check a token without being able to forge one?Warm-up
A token signer that holds a private key sends a token to three services that each check incoming tokens; a dashed box asks what the services can verify with.
Typed schematic.
Check: The Problem0/2
1. What is the priority order for MCP client enrollment in the 2025-11-25 spec?Explain
Four cards in a two by two grid, Client ID Metadata Document, prompt the user, RFC 7591 dynamic registration and a pre-registered client_id, headed by a note that they are listed in no particular order.
Typed schematic.
2. An org runs hundreds of MCP servers and thousands of desktop clients, and no admin can hand-register each user's client. Which enrollment design fits?Design
Three desktop clients on the left and three MCP servers on the right, with ellipses for the rest and the text thousands of desktop clients and hundreds of MCP servers, and a dashed box asking which enrollment design fits.
Typed schematic; counts from the stem.

The Concept

RFC 8414: OAuth Authorization Server Metadata

A document at /.well-known/oauth-authorization-server describes everything a client needs:

json
{
  "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",
  "registration_endpoint": "https://auth.example.com/register",
  "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.

The contract you verify before trusting an IdP for MCP:

  • code_challenge_methods_supported includes S256 (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_supported includes authorization_code and rejects password and implicit.
  • At least one enrollment path is advertised: client_id_metadata_document_supported: true (CIMD, preferred) or registration_endpoint (RFC 7591 DCR, fallback). Either satisfies the contract; you no longer hard-require DCR.
  • response_types_supported is 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.

json
{
  "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:

json
{
  "client_id": "https://app.example.com/oauth/client.json",
  "client_name": "Example MCP Client",
  "client_uri": "https://app.example.com",
  "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.

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 localhost redirect. The authorization server MUST clearly display the redirect URI hostname during consent and SHOULD warn on localhost-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.

RFC 7591: Dynamic Client Registration (fallback / backwards compatibility)

DCR is now a MAY, kept for backwards compatibility with pre-2025-11-25 deployments and IdPs that do not yet support CIMD. Without it (and without CIMD or pre-registration), every MCP client (Cursor, Claude Desktop, a custom agent) needs an out-of-band exchange with the IdP admin. With DCR, the client posts:

json
POST /register
Content-Type: application/json
 
{
  "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:

json
{
  "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"
}

token_endpoint_auth_method: none is the right default for MCP clients that run on the user's device. They get a client_id only, with no client_secret to exfiltrate. PKCE provides the proof-of-possession that public clients need.

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_id namespace. 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 mock here skips it; production wires a verification step that rejects unsigned registrations from anything other than localhost redirect URIs.
  • The registration_access_token must 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 Spec 2025-11-25 Auth Profile

The MCP spec (2025-11-25) is precise about what an MCP server's authorization layer must do:

  • 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 metadata authorization_servers field MUST name at least one server.
  • Accept tokens only via Authorization: Bearer ... on every request, not in a query string and not 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 mismatched aud is rejected, never treated as wildcard.
  • On 401/403, return WWW-Authenticate: Bearer carrying error=..., the resource_metadata="<PRM-URL>" parameter (the URL of the metadata document, not the bare resource), and scope="..." on insufficient_scope (403). Note: the parameter is resource_metadata, a discovery pointer. There is no resource parameter 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 issuer before redirecting and validates the iss authorization-response parameter (RFC 9207) before redeeming the code. PKCE alone does not stop mix-up, because the client hands its code_verifier to whatever token endpoint it was steered to.

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.

IdP capability matrix

Not every IdP supports the full MCP profile. The matrix below documents factual capability statements as of the 2025-11-25 spec. It is a deployment gate, not a recommendation.

CIMD shipped in the 2025-11-25 spec and the underlying OAuth draft was adopted only in October 2025, so vendor support is still arriving. Treat "CIMD" below as "where it stands today, verify in your tenant," not a permanent statement.

IdP categoryAS metadata (8414/OIDC)CIMDRFC 7591 DCRRFC 8707 resourceRFC 7636 S256 PKCENotes
Self-hosted (Keycloak)yesemergingyesyes (since 24.x)yesReference IdP for the MCP profile in this lesson; full DCR path end-to-end, CIMD tracking the new spec.
Enterprise SSO (Microsoft Entra ID)yesemergingyes (premium tiers)yesyesDCR availability differs by tenant tier; verify in target tenant before deploying.
Enterprise SSO (Okta)yesemergingyes (Okta CIC / Auth0)yesyesDCR available on Auth0 (now Okta CIC); classic Okta orgs require admin pre-registration.
Social login IdPs (generic)variesnorarelyrarelyyesMost social IdPs treat clients as static partners; no self-service enrollment. Use as identity source only, layer your own MCP-aware authorization server on top.
Custom / homegrowndependsdependsdependsdependsdependsIf you ship your own, ship the full profile and prefer CIMD. Skipping PKCE or audience binding breaks the MCP auth contract.

Refusal rule for the deployment manifest: if the chosen IdP does not list S256 in code_challenge_methods_supported, the MCP server refuses to start, because PKCE has no degraded mode. Enrollment is a softer gate: you need one working path (a pre-registered client_id, client_id_metadata_document_supported: true, or a registration_endpoint). DCR's absence alone is no longer a refusal trigger, because CIMD or pre-registration can cover it.

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, since it does not hold the IdP's private keys.
  • Refresh is what the resource server does: re-GET the 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:

json
{
  "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:

python
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; in this code no path reaches a tool without validating first.

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:

  1. Decode JWT, fetch JWKS by kid, verify signature.
  2. Check iss against its protected-resource metadata's authorization_servers. (Pass, same IdP.)
  3. Check aud == "https://tasks.example.com". (Fail, the token's aud is https://notes.example.com.)
  4. 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 has to run on each 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 MUST get 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 because the attack happens before any token exists. The defense lives in the client (RFC 9207):

  1. Before redirecting, the client records the expected issuer from the validated AS metadata.
  2. On the authorization response, the client compares the returned iss parameter against that recorded issuer (simple string comparison, no normalization) before sending the code anywhere.
  3. Mismatch (or iss absent when the AS advertised authorization_response_iss_parameter_supported) → reject, and do not even display the error fields.

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. Don't 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-controlled kid values into a key-creation DoS. The fall-back must be the idempotent refresh-jwks.
  • Missing aud claim. Some IdPs default to omitting aud unless resource is present in the token request. The validator must reject tokens with missing aud, not treat absence as wildcard.
  • Mix-up via missing iss check. A client that does not validate the RFC 9207 iss authorization-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", which creates a TOCTOU window.
  • Registration token theft. A leaked registration_access_token lets the attacker rewrite redirect URIs. Hash these at rest; require the client to present the cleartext on every update; rotate on suspicion.
  • iss not pinned. A validator that accepts any iss lets an attacker stand up their own authorization server, register a client for the target audience, and issue tokens. The protected-resource metadata's authorization_servers list is the allow-list; enforce it.
Check: The Concept0/36
1. What is an authorization server metadata document (RFC 8414)?Define
A terminal pane showing a 200 JSON response whose body has issuer, authorization_endpoint, token_endpoint, jwks_uri, registration_endpoint, response and grant types, PKCE methods, scopes and token endpoint auth methods.
Real output of AuthorizationServer.metadata() from main.py.
2. What is a Client ID Metadata Document (CIMD)?Define
A JSON document named client.json with client_name, client_uri, two redirect URIs, grant types, response types and a token endpoint auth method of none, above a client app and an authorization server joined by an authorize arrow. The client_id field is not shown.
The lesson's example document without its client_id field.
3. What is Dynamic Client Registration (DCR) in the 2025-11-25 MCP spec?Define
A terminal pane with a POST /register request body naming redirect URIs, grant types, response types, scope and client name, followed by a 201 response with a client_id, redirect URIs, grant types and a registration access token.
Real run of main.py; the secret token is cut short.
4. What is a JWKS?Define
A terminal pane with a 200 JSON response holding a keys list of two entries, each with a kid, a key type and a use of sig.
Captured from main.py; key material and alg omitted.
5. What is the difference between "rotate" and "refresh" in the JWKS discussion?Define
An authorization server box and a resource server box above two stacked cards reading rotate and refresh, with a question about which role does which verb.
Typed schematic.
6. What does the RFC 8707 resource parameter do in a token request?Define
A typed token request to auth.example.com/token with a resource parameter set to https://notes.example.com between ellipses, and a dashed box asking about the effect on the issued token.
Typed; other parameters left out.
7. What does a resource server do with the aud claim on each request?Define
A request with an Authorization Bearer header arrives at notes.example.com, whose canonical URL is shown, and a dashed box asks about the check on the aud claim.
Typed schematic.
8. What is audience replay?Define
An authorization server at auth.example.com joined by dashed lines to two MCP servers, notes.example.com and tasks.example.com.
Typed schematic.
9. What is the confused-deputy scenario, as distinct from audience replay?Define
Two cards, audience replay marked as the walkthrough case and confused deputy marked as the reserved term, with a dashed box asking which scenario the second one names.
Typed schematic.
10. What is a mix-up attack?Define
A client box joined by dashed lines to two authorization servers, one at https://auth.example.com and one at https://evil.example.com.
Typed schematic.
11. What is the iss allow-list on an MCP server?Define
A token claim iss with the value https://auth.example.com goes to the MCP server notes.example.com, with a dashed box asking where the iss allow-list comes from.
Typed schematic.
12. In a WWW-Authenticate challenge, what does the resource_metadata parameter carry?Define
A terminal pane with a 401 response whose WWW-Authenticate header reads Bearer followed by an ellipsis and a parameter resource_metadata equal to a question mark.
Real response with other directives left out and one value hidden.
13. What is a public client in the MCP setting?Define
A client app named Cursor that runs on the user's device, with an arrow to the authorization server auth.example.com and a dashed box asking what kind of OAuth client it is.
Typed schematic.
14. What is the job of the WWW-Authenticate header on a 401 or 403 from an MCP server?Define
A terminal pane calling call_tool on notes.list with the bearer value not-a-token, showing status 401 and a WWW-Authenticate line that is hidden behind a question mark, and a dashed box asking about the job of the header.
Real call with the header value hidden.
15. In CIMD, what must the client_id value in the metadata document equal?Explain
The lesson's example client.json with the client_id value replaced by a question mark, and the question of which value client_id must hold.
Typed from the lesson's example.
16. Why does CIMD remove the need for per-server client registration?Explain
One MCP client with arrows to three MCP servers and a note that there are hundreds of servers and thousands of clients, with the question of how each server comes to know the client.
Typed schematic.
17. What is the deployment refusal rule for code_challenge_methods_supported?Explain
A terminal pane with an excerpt of authorization server metadata: issuer, authorization_endpoint, token_endpoint, an ellipsis, and code_challenge_methods_supported with its value replaced by a question mark.
Excerpt of real metadata with one value hidden.
18. How do RFC 9728 and RFC 8414 relate in production discovery?Explain
Two cards for the RFC 9728 document at /.well-known/oauth-protected-resource and the RFC 8414 document at /.well-known/oauth-authorization-server, with a dashed box asking how they relate.
Typed schematic.
19. Which statement about the IdP capability matrix and deployment refusal rule is correct?Explain
A table of five IdP categories, Keycloak, Entra ID, Okta, generic social login and custom, with columns CIMD, DCR, 8707 resource and S256 PKCE showing yes, emerging, rarely, no or depends.
Trimmed copy of the lesson's table.
20. Why is wiring a JWKS cache-miss fallback to a rotate-and-mint operation a bug?Explain
A token whose kid is not in the cache, an arrow to a cache miss fall-back path, and an arrow from it to rotate_key, which mints a key.
Typed schematic.
21. Why does an authorization server keep two overlapping signing keys in steady state?Explain
A box for the published JWKS holding two keys with kids k_2026_03 and k_2026_04, labelled two keys at once.
Typed schematic.
22. What does the canonical MCP resource URI do with the path component?Explain
The URI https://mcp.example.com/server/mcp split into three boxes labelled scheme, host and path, with a dashed box asking what happens to the path.
One of the four example URIs listed in the lesson.
23. Why does PKCE alone not stop a mix-up attack?Explain
A client that holds a code_verifier and sent a code_challenge, joined by dashed lines to an honest authorization server and a malicious one.
Typed schematic.
24. What defends against a mix-up attack, and where does the defense live?Explain
Three role cards, authorization server, resource server and client, with a dashed box asking where the mix-up defense lives.
Typed schematic.
25. Which pairing of role and duty matches the lesson's separation of roles?Explain
Three cards for the roles AuthorizationServer, ResourceServer and Client from main.py, with a line that each role enforces only its own checks and no duties listed.
Typed schematic.
26. Which RFC 7591 production pitfall does the DCR section actually describe?Explain
A POST /register box with an arrow to a box naming client_id and registration_access_token, and a dashed box asking for the production pitfall the DCR section describes.
Typed schematic.
27. What is the scope upgrade race?Explain
One step-up flow card for alice@example.com with an arrow to a dashed box asking about the scope upgrade race.
Typed schematic.
28. What happens if a validator does not pin iss against an allow-list?Explain
auth.example.com sends tokens to an MCP server whose box says it checks signature, aud, exp and scope, with a dashed box asking for the consequence.
Typed schematic.
29. In the audience-replay walkthrough, why does tasks.example.com reject a token stolen from notes.example.com?Apply
notes.example.com marked as compromised sends a stolen token to tasks.example.com, below which is the 401 response from tasks with error invalid_token and the error_description value hidden.
Real response with one value hidden.
30. An IdP omits aud unless resource appears in the token request. What should the validator do with a token that has no aud?Analyze
A token payload with iss, sub, azp, scope, iat and exp but no aud, above a dashed box asking for the validator's decision.
Typed from the stem; no aud claim.
31. A server fetches JWKS once at boot and validates fine for about an hour, then every request returns 401 until restart. The IdP rotates hourly. What is missing?Analyze
A timeline: the server fetches the JWKS once at boot, requests work for about an hour, then every request returns 401 until restart; a dashed box asks for the missing piece.
Typed timeline.
32. An authorization server supports CIMD. A client submits a client_id URL that points at an internal admin endpoint, and the server fetches it. Which defense was missing?Analyze
A client sends a client_id URL to an authorization server, which fetches from an admin endpoint inside an internal network box; a dashed box asks for the missing defense.
Typed schematic.
33. Your MCP server trusts one IdP. Logs show 401s with kid not found, from a client sending random kid values, and a teammate proposes calling rotate_key on each miss. What do you do?Analyze
A log pane with three lines reading 401 invalid_token kid not found and a note that each kid is different, above a card quoting a teammate who proposes calling rotate_key on each miss.
Illustrative log typed from the stem.
34. An MCP server proxies a third-party API with a static client ID and forwards the caller's inbound token upstream. Which change does the spec call for?Analyze
A client calls an MCP server with a static client ID at a third party, which forwards the inbound token to a third-party API; a dashed box asks for the change the spec calls for.
Typed schematic.
35. You co-host two MCP servers under one origin, at https://mcp.example.com/notes and https://mcp.example.com/tasks. How do you set the audience?Design
Two server cards under one origin, a notes server at https://mcp.example.com/notes and a tasks server at https://mcp.example.com/tasks, and a dashed box asking how the audience is set.
Typed schematic; URLs from the stem.
36. Your only available IdP is a generic social login provider, which treats clients as static partners and has no self-service enrollment. What do you do?Design
A generic social login provider that treats clients as static partners with no self-service enrollment, with an arrow to MCP clients and servers and a dashed box asking what to do.
Typed schematic.

Use It

code/main.py walks the full production flow with stdlib Python and three roles: AuthorizationServer, ResourceServer, and Client. The flow:

  1. Authorization server publishes RFC 8414 metadata at /.well-known/oauth-authorization-server.
  2. MCP client calls the metadata endpoint and checks its enrollment options (client_id_metadata_document_supported for CIMD, registration_endpoint for DCR) and S256 PKCE support.
  3. The walk-through takes the DCR fallback path: the client posts to /register (RFC 7591) and receives a client_id. (A CIMD client would instead present its own HTTPS client_id URL and skip this step.)
  4. MCP client runs PKCE-protected authorization code flow (RFC 7636) with resource indicator (RFC 8707).
  5. MCP client calls a tool on the MCP server with Authorization: Bearer ....
  6. MCP server runs validate, resolving the signing key from the JWKS cache.
  7. The IdP rotates a key; the scheduled refresh re-pulls the JWKS into the cache.
  8. The next call validates against the refreshed keys without restart, and the previous token still validates during the overlap window.
  9. An audience-replay attempt against a different MCP resource gets 401 with audience mismatch and a resource_metadata pointer.

The JWT here uses HS256 with a shared secret (so the code 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.

Check: Use It0/5
1. In main.py, a valid, unexpired token with only mcp:tools.invoke and the right audience is sent to call_tool("notes.delete", ...). What comes back?Apply
A terminal pane showing the decoded claims of a token with aud https://notes.example.com and scope mcp:tools.invoke, then a call_tool for notes.delete whose result is a question mark.
Real token claims; call result hidden.
2. In main.py, a new AuthorizationServer calls rotate_key() twice, then once more. Which kids does jwks() publish?Apply
A terminal pane with a new AuthorizationServer followed by three rotate_key calls and a line that lists the kids from jwks, ending with a comment question mark.
Typed from the stem.
3. In main.py, a token for notes.example.com is correctly signed and already expired. It is sent to the tasks server. Which error_description does validate return?Apply
A terminal pane describing a correctly signed expired token with aud https://notes.example.com, validated by a ResourceServer for https://tasks.example.com, with the error_description shown as a question mark.
Typed from the stem; result hidden.
4. In main.py, Client.discover() reads metadata that lists S256 but has neither client_id_metadata_document_supported nor registration_endpoint. What happens?Apply
A terminal pane showing authorization server metadata with S256 in code_challenge_methods_supported and with no client_id_metadata_document_supported and no registration_endpoint, followed by a call to Client discover with its result hidden.
Real metadata with two keys removed; result hidden.
5. In main.py, validate() gets a token whose iss is https://evil.example.com. What does it return, and does it touch the JWKS?Apply
A terminal pane with allowed_issuers set to https://auth.example.com, a token whose iss is https://evil.example.com, a call to validate, and two result lines hidden behind question marks.
Typed from the stem; results hidden.

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.

Author's Note

I'd run main.py and read the output top to bottom before the prose. It asserts a 200 on the first call, again after the key rotation, and again on the fresh token, then asserts the 401 with audience mismatch when the same bearer hits the tasks server. If any of those flips, the printed step tells you which role broke.

The habit I'd take from it is keeping rotate_key and refresh_jwks far apart in your head. Rotation lives on AuthorizationServer, and the resource server only re-pulls what the IdP published. validate calls refresh_jwks on an unknown kid, so a junk kid costs one re-fetch and then a 401. The failure to avoid is wiring that path to a rotate.

The code is also a toy in a few specific ways. Tokens are HS256 with a shared secret so it runs on stdlib, and authorize computes a PKCE challenge it never checks. Enrollment goes through DCR because it fits in one process, and register_client skips rate limiting and software_statement. For a real deployment I'd start from CIMD, sign with RS256 or EdDSA, and treat those gaps as the work.

Exercises

  1. Run code/main.py. Trace the flow. Note how the IdP rotates a key in step 6, the scheduled refresh_jwks re-pulls the published set, and both the old token (overlap window) and a fresh token validate without restart.

  2. Add a new IdP to the protected-resource metadata's authorization_servers list. 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 with WWW-Authenticate: Bearer error="invalid_token", error_description="iss not allowed".

  3. Add a rate-limit check to register_client that runs before the registrar accepts a request. Use a token-bucket per source IP held in a small dict keyed by IP.

  4. Read RFC 7591 and identify two fields the /register handler here does not validate. Add the validation. (Hint: software_statement and redirect_uris URI scheme.)

  5. Add a Client ID Metadata Document path. Serve a client.json whose client_id equals its own URL, and have the authorization server fetch and verify it (reject if client_id ≠ URL). Confirm a CIMD client enrolls with no register_client call.

  6. Prove the DoS fix. Send the validator a token with a random kid and confirm refresh_jwks runs 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.

  7. Implement the client-side RFC 9207 iss check from the mix-up section: record the expected issuer before the authorization request, then reject an authorization response whose iss does not match.

Key Terms

TermWhat people sayWhat 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. Recommended default since 2025-11-25
DCR"Self-service client registration"RFC 7591 POST /register flow; demoted to a MAY fallback in 2025-11-25
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

Code

Ships With