Skip to main content

Reference lesson / tools and protocols

MCP Auth in Production: Issuer-Bound Enrollment and Tokens

By Rohit Ghumare and contributors · MIT licensed · Original lesson· Runnable code

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 9207 iss value 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_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 available: client_id_metadata_document_supported: true (CIMD, preferred), a pre-registered client, or registration_endpoint (deprecated RFC 7591 compatibility).
  • If authorization_response_iss_parameter_supported is true, the client requires the returned RFC 9207 iss and compares it exactly with the issuer recorded before redirecting.
  • 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.

{
  "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 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.

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_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 lesson's mock 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 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 metadata authorization_servers field 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 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 value returned in the actual authorization response (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.
  • 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-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:

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

  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 — 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 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 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 — 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. 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-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" — that 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.
  • 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.

Use 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 -v

The 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.

  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 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.
  4. 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 8707 resource indicator.
  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 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

  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 lesson's /register handler does not validate. Add the validation. (Hint: software_statement and redirect_uris URI scheme.)

  5. 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.

  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. Exercise deprecated DCR with both native and web clients. 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