We have written before about resource indicators and about checking the aud claim on every request. Both assume something earlier in the chain: that the token was really issued by the authorization server you trust, and that your server can still tell that tomorrow, after the authorization server rotates its signing key.
That earlier step is token validation plumbing. It is boring, it is rarely in the demo, and it is the part that takes an MCP server down at 3am when a key rotates and the cache does not. Reviewers ask about it in two forms: "how do you verify the token" and "what happens when the key changes". This is how we answer both.
What a validator has to do, in order
An MCP server receiving a request with Authorization: Bearer <token> has to do six things before any tool handler runs.
- Parse the token and read the protected header. Nothing inside the payload is trusted yet.
- Pick a verification key, using the
kidfrom the header and the algorithm you expect. - Verify the signature.
- Check
issagainst the issuer you configured, not against a value taken from the token. - Check
audagainst your server's resource identifier, and checkexp,nbf, andiatwith a bounded clock skew. - Map the remaining claims (
sub,scope,tidor equivalent,client_id) into the request principal your handlers see.
Only step 6 belongs to your application. Steps 1 through 5 belong to a library. Use the maintained JOSE library for your runtime and keep it pinned; do not assemble signature verification out of primitives. RFC 9068 describes the JWT profile for access tokens and is the shape most enterprise IdPs issue.
Getting the key: discovery, not configuration
Hard-coding a PEM public key in configuration works until the first rotation. Instead, derive the key set from the issuer:
- Fetch
GET {issuer}/.well-known/oauth-authorization-server(RFC 8414) or the OpenID Connect discovery document. - Read
jwks_urifrom it. - Fetch the JWK Set (RFC 7517) from that URI and select the key whose
kidmatches the token header and whoseuseissig.
Treat both documents as configuration you refresh, not as data you fetch per request. A validator that calls the JWKS endpoint on every tool call has handed the authorization server a denial-of-service lever over your product, and has made your p99 latency depend on someone else's CDN.
Fetch over TLS with certificate verification on, a short connect and read timeout, and a response size limit. The JWKS endpoint is a remote input. If your server runs with a restricted egress allowlist, which it should, the issuer's discovery and JWKS hosts must be on that allowlist, and that is a deployment fact worth writing down in the runbook because it breaks quietly when a customer changes IdP tenants.
Caching and rotation, which are the same problem
The rule that survives rotation is simple: cache the key set, and allow exactly one re-fetch when you see an unknown kid, with a cooldown.
A workable policy:
- Cache the JWK Set in memory with a TTL. Ten minutes is a reasonable default; honour a shorter
Cache-Control: max-agefrom the endpoint if it sends one. - On a token whose
kidis not in the cache, re-fetch once, then retry key selection. - Rate-limit that re-fetch: at most one in flight, and no more than one per some interval, perhaps thirty seconds, per issuer. Without this, a flood of tokens with a bogus
kidbecomes a flood of outbound requests. - Keep serving from a stale cache if the re-fetch fails and the cached key still verifies the token. Availability beats freshness here; the signature check is still being done.
- Never fall back to "could not get a key, allow the request". Failure to resolve a key is a 401 with
WWW-Authenticate: Bearer error="invalid_token", never an allow.
Authorization servers publish the new key before they start signing with it, so a cache with a TTL in minutes plus an unknown-kid refresh covers an ordinary rotation without an outage. It does not cover an emergency rotation where the old key is revoked immediately. For that you need a way to flush the cache without a deploy: an admin endpoint, a config signal, or a short enough TTL that you can wait it out. Decide which, and write it in the incident runbook rather than discovering it during the incident.
If your server runs as more than one process, each process holds its own cache. That is fine. A shared cache in Redis adds a dependency, a serialization format, and a new thing to invalidate, for a document that is a few kilobytes.
Algorithms: pin them, do not negotiate them
The header of an attacker-supplied token is attacker-supplied. Two classic failures follow:
alg: none. The library accepts an unsigned token. Any library that still permits this in its default path should not be in your dependency tree.- Algorithm confusion. A token is presented as
HS256and the verifier uses the RSA public key as an HMAC secret. The public key is public, so the attacker can mint tokens.
The fix is to pass an explicit allowlist of algorithms to the verifier, taken from your configuration and not from the token: for example ["RS256"] or ["ES256"]. Cross-check against id_token_signing_alg_values_supported from discovery when you set it up, then fix it. If the IdP adds an algorithm later, that is a change you make deliberately.
Also reject tokens whose typ is not what you expect when the issuer sets one. RFC 9068 specifies at+jwt for access tokens. Checking it stops an ID token being replayed at the resource server, which is a real finding we have written up before in a different form: an ID token is for the client, not for you.
Local validation or introspection
Two designs pass review. Choose on operational grounds, not ideology.
Local JWT validation. The server verifies the signature and claims itself. No network call on the hot path, no dependency on the IdP's availability per request, and it works for high-rate tool calls. The cost is revocation latency: a token stays valid until exp regardless of what happened to the session behind it. That is acceptable when access token lifetimes are short, five to fifteen minutes, and refresh is where the real lifecycle check happens.
Token introspection (RFC 7662). The server posts the token to the authorization server's introspection endpoint with its own client credentials and reads back active, scope, sub, aud, and whatever else the IdP returns. Revocation is immediate. The cost is a synchronous dependency on the IdP for every call, plus the need to cache introspection results anyway, which reintroduces a staleness window you now have to explain.
Most MCP servers we build do local validation with short-lived tokens, and introspect only where the blast radius justifies it: a small set of high-impact tools, or a customer whose requirement is explicitly "disabled means disabled within one minute". If you cache introspection responses, cache positive results for no longer than the revocation window you promised, and do not cache negative results at all beyond a few seconds.
Opaque tokens force introspection; there is no third option. If a customer's IdP issues opaque access tokens to resource servers, that is a design input, not a surprise, and it belongs in the week-one notes.
Several issuers at once
A multi-tenant MCP server usually trusts more than one authorization server: your own, plus a customer's IdP where cross-app access or token exchange is in play. Two rules keep that safe.
First, the issuer set is an allowlist you control. Resolve iss to a configured entry, then use that entry's cached JWKS. Never follow the issuer URL in an unverified token to discover a key set; that lets anyone with a web server mint tokens for your product.
Second, the mapping from issuer to tenant is explicit. Issuer A's sub claim and issuer B's sub claim can collide. The principal your handlers see should be a pair, issuer plus subject, and the tenant should be resolved from the issuer-tenant binding you stored at onboarding, not from a tenant claim the token happens to carry. Cross-tenant findings in our audits come from this far more often than from a broken signature check.
What to log, and what not to
Log the validation outcome as a structured audit event: issuer, kid used, token jti if present, sub, client ID, the failure reason on rejection, and the time. That gives a SIEM enough to correlate and enough to spot the two patterns worth alerting on, which are a spike in unknown-kid rejections and a spike in invalid_token from a single client.
Do not log the token. Not the raw string, not the payload, not "just the first twenty characters" in a debug branch someone left enabled. If you need to correlate specific tokens, log jti, or a truncated hash of the token with a server-side salt.
Testing it
These cases belong in the automated test suite, with fixtures, not in a manual checklist:
- Valid token, expected issuer, expected audience: allowed.
- Expired token, and a token with
nbfin the future: rejected, with skew bounded to a minute or two. - Correct signature, wrong audience: rejected.
- Correct signature, wrong issuer: rejected.
alg: noneand anHS256token signed with the RSA public key: both rejected.- Unknown
kid: one JWKS re-fetch, then rejection if still unknown. - Rotation: sign with a new key, publish it, and assert the server accepts the new token without a restart.
- JWKS endpoint returning 500 or timing out, with a warm cache: existing tokens still verify.
- JWKS endpoint unreachable with a cold cache: 401, not 200.
The rotation and outage cases are the two that find real bugs. They are also the two that a security questionnaire asks about in prose, so having a test name to cite is worth more than a paragraph of assurance.
What this is not
None of this makes a tool safe. A perfectly validated token still calls a tool that may do too much, return too much, or act beyond what the user could do in your product directly. Token validation is the floor. If your server already does all of the above and you are being sent back by reviewers anyway, the finding is almost certainly in tool design or scoping, not here.
If you want this checked rather than described, an MCP security audit reads the validator, the cache policy, and the test fixtures, and tells you in writing which of the cases above your server currently fails. If none of them fail, we say that too.