Internet Protocols · Identity Protocols

Self-Contained Access Tokens and the Cost of Local Validation

What a resource server gains and loses when it validates a signed token locally instead of asking the issuer, and where clock skew and key caching change the answer.

An opaque access token forces a decision on every request: the resource server must ask the authorization server what the token means. A signed token moves that decision into the resource server. The trade is not a free one, and the costs are concentrated in places that are easy to overlook: key distribution, clock agreement, and the impossibility of taking a decision back.

What validation actually consists of

A JWT access token is a JWS compact serialisation: three base64url segments separated by dots, holding the header, the payload, and the signature. Validation is a sequence of checks, and skipping any one of them is a defect that has been exploited in practice.

The signature must be verified against a key the resource server trusts for the issuer. The algorithm named in the header must be one the resource server accepts, and it must not be selected by the attacker. This is the substance of the alg: none and algorithm-confusion failures: a verifier that reads alg from the token and honours whatever it finds will accept an unsigned token, or will accept an RS256-signed token verified as if it were HMAC using the public key as the shared secret.

The claims must then be checked. iss must equal the expected issuer exactly, by string comparison after the token is parsed, not by pattern. aud must contain the resource server’s own identifier. exp must be in the future and nbf in the past, both within a tolerance for clock skew. iat is not a validity bound but is used for policy. Any claim the resource server relies on for authorization must be present and of the expected type.

The tolerance matters because it is a real source of intermittent failure. Two hosts whose clocks differ by more than the tolerance will disagree about whether a token is valid, and the disagreement appears only for tokens issued near their boundary. RFC 7519 does not fix a tolerance; implementations commonly allow a small window, on the order of a minute, and the window is a direct trade: a larger window reduces spurious rejections and extends the life of an expired token by the same amount.

The key distribution problem

Local validation requires the resource server to hold the issuer’s verification key. The issuer publishes these as a JWK Set, defined in RFC 7517, typically at a well-known HTTPS endpoint, and the resource server fetches and caches it.

The cache is the interesting part. A key set that never changes can be cached forever; a key set that rotates requires the resource server to notice new keys and to stop trusting removed ones. The practical arrangement is a cache with a time-to-live and a refresh on an unknown kid. The unknown-key path is necessary because a token signed with a newly published key can arrive before the cache has expired, and rejecting it produces an outage that lasts until the next refresh.

The kid header parameter is what makes key selection possible without trial verification. It is a hint, not a trust anchor: the resource server must still verify the signature against the key it selected, and must reject tokens whose kid is absent when multiple keys are configured, unless it is willing to try each key.

Key rotation has a hidden cost in the overlap window. During rotation the issuer publishes both keys, so tokens signed with either validate. The overlap must be at least as long as the longest access token lifetime plus the longest key cache TTL plus the clock skew tolerance, or a token signed with the retiring key will be presented after the resource server has dropped it. Getting this wrong produces a failure that appears only during rotation, which is why rotation schedules are frequently set to intervals much longer than the arithmetic requires.

Caching the key set also creates a dependency: the resource server cannot validate anything it has not cached, so a cold start requires a fetch. A resource server that cannot reach the issuer at startup has a choice between failing closed, which makes the issuer a availability dependency for every request, and failing open, which is not a choice. The usual compromise is to persist the last known key set to disk so a restart does not require the network.

The revocation problem, stated plainly

The decisive difference between local validation and introspection is revocation. An introspection call, defined in RFC 7662, returns whether the token is currently active, and the authorization server can answer no at any moment. A locally validated token is valid until exp, because nothing in the token can express “this was revoked five minutes ago”.

The consequences are concrete. A user who logs out of a client does not thereby invalidate access tokens already issued to it. A token found in a log cannot be killed before it expires. A compromised client’s tokens remain usable for their remaining lifetime. Replay-detected refresh tokens, described in refresh token rotation, cannot be paired with immediate revocation of the access tokens derived from them.

The standard mitigations are all partial. Shortening the access token lifetime reduces the exposure window and increases the refresh rate, which increases load on the token endpoint and the number of chances for a rotation race. Maintaining a revocation list that resource servers poll introduces the very dependency the design was meant to avoid, and the polling interval becomes the effective revocation latency. Pushing revocation events to resource servers works when the set of resource servers is known and reachable, which is not the general case.

The honest framing is that local validation chooses bounded staleness over per-request correctness. The bound is the access token lifetime, and a deployment that sets that lifetime without understanding the choice has effectively chosen an unbounded window.

Where the CPU goes

Signature verification is not free, though it is cheap enough that the cost is rarely the deciding factor. An RSA-2048 verification is a modular exponentiation with a small public exponent, which is on the order of tens of thousands of cycles on a general-purpose core; signing is far more expensive because it uses a private exponent, which is why issuers concentrate the work and resource servers distribute it.

An ECDSA P-256 verification is comparable in cost to RSA-2048 verification, with much smaller keys and signatures: a P-256 public key is 64 bytes in uncompressed form and a signature is 64 bytes, against 256 bytes and 256 bytes for RSA-2048. Smaller tokens matter for header size on every request and for the size of the cache entries holding parsed claims.

EdDSA over Ed25519 has fast verification and very small keys, 32 bytes, but support across libraries is less uniform than RSA or ECDSA.

The comparison that actually decides the architecture is not signature cost against nothing; it is signature cost against a network round trip plus a cache lookup. A local verification avoids a network hop entirely, which is a millisecond-scale saving in a datacentre and much more across regions. That is the reason self-contained tokens are used for high-volume, low-latency APIs, and the reason introspection is retained where revocation latency matters more than request latency.

Token size is the cost that is easier to underestimate. Claims are base64url-encoded JSON, so a token with a rich claim set can reach a kilobyte or more, and it travels in a header on every request. A service handling many requests per second spends real bandwidth on tokens, and a proxy that logs headers writes them to disk. Opaque tokens are tens of bytes.

What should never go in a JWT

A JWT is signed, not encrypted. Its payload is readable by anyone holding it, which for a bearer token includes anyone who intercepts it. Sensitive values must not be placed in the claims on the assumption that the signature provides confidentiality.

The related error is trusting claims the issuer did not mean to assert. A claim such as a role or a group membership is an assertion by the issuer at issuance time; if the resource server treats it as current truth, a change in the user’s entitlements is invisible until the token expires. This is the same staleness bound as revocation, applied to authorization data rather than to validity.

RFC 8725 collects the specific pitfalls: reject tokens with none as the algorithm, do not let the token choose the key, do not trust the typ or cty headers for security decisions, validate that the token is a JWT and not a JWS with arbitrary content, and compare issuer and audience values exactly.

The choice in practice

Introspection and local validation are not exclusive. A common arrangement validates locally for the common path and consults the issuer only for operations that need current state, such as a high-value transfer or an administrative action. The resource server then carries two code paths, which is a maintenance cost, but the security property is explicit: the cheap path accepts bounded staleness, and the expensive path does not.

Whichever is chosen, the resource server must know which issuer it trusts and for what. A validation routine that accepts any token signed by any key in a shared key set, without checking iss and aud, is a single sign-on system for the entire internet.

The identity layer built on top of these tokens, where the token’s purpose is to assert who the user is rather than what they may do, has a different set of required checks; those are covered in ID token validation.

References

  1. RFC 7519: JSON Web Token (JWT) — IETF
  2. RFC 7515: JSON Web Signature (JWS) — IETF
  3. RFC 7662: OAuth 2.0 Token Introspection — IETF
  4. RFC 7517: JSON Web Key (JWK) — IETF
  5. RFC 8725: JSON Web Token Best Current Practices — IETF