AuthnGENERALPROTOCOL-SPECIFICFRAMEWORK-SPECIFIC

Token Authentication and the Revocation Problem

A self-contained token removes the lookup by carrying its own claims — and removing the lookup is exactly what makes immediate revocation hard. That is the trade, and it is the whole lesson.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

What do you give up when a token can be verified without asking anyone?

The requirement

Mobile clients and internal services need to authenticate without a shared session store, and a compromised account must be cut off when support presses the button.

The obvious build

Issue a signed token at login with the user id and an expiry, verify the signature on each request, and read the claims. No store, no lookup, no shared state — the appeal is real and the mechanism is sound.

Why it breaks

A user's account is compromised and support disables it. The attacker's token keeps working until it expires, because nothing consults the account on the request path.

How it breaks in production
  • A user's account is compromised and support disables it. The attacker's token keeps working until it expires, because nothing consults the account on the request path.
  • A permission is removed, and the token still asserts it. Authorization decisions made from claims are as stale as the token is (Where the Check Belongs).
  • A token is set to a long expiry to avoid re-authentication friction, which multiplies the length of every one of these windows.
  • Verification accepts the algorithm named inside the token, so a token that declares "none" or names a symmetric algorithm against a public key verifies successfully (JWT Failure Modes in Security Engineering).
  • The signing key is rotated and every existing token is instantly invalid, because nothing was designed to verify against more than one key at a time.
  • The issuer and the verifier disagree about the clock by a minute, and freshly issued tokens are rejected as not-yet-valid.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • A self-contained token carries its claims and a signature over them. Verification is a signature check plus claim validation — no network call, no store, no shared state. That is the entire performance argument.
  • A signature proves integrity and origin, not currency. It says the issuer produced these claims and nobody altered them. It cannot say whether they are still true, because nothing about the token changes when the world does.
  • Expiry is the only revocation a pure self-contained token has. The window between "should be revoked" and "actually stops working" equals the remaining lifetime. Shortening lifetime shortens the window and increases refresh traffic.
  • The refresh pattern splits the difference. A short-lived access token is verified locally; a long-lived refresh token is exchanged at an endpoint that *does* consult state, and that exchange is where revocation is enforced.
  • Rotation with reuse detection makes a stolen refresh token detectable. Each refresh issues a new refresh token and invalidates the old one; a second use of an already-used token means someone has a copy, and the whole family is revoked.
  • Denylists put the lookup back. Checking a token id or a per-subject version on each request restores immediate revocation and reintroduces the dependency you removed. That is a legitimate design — it is just not the stateless one people believe they have.
  • Tokens are bearer credentials. Anyone holding one is the subject, so storage on the client and transport are as important as the cryptography (JWT — What It Is and What It Costs in Security Engineering).

The trade, stated once and plainly

Everything about tokens follows from one fact: verification asks nobody. That is why it is fast, why it scales without a shared dependency, and why nothing you do to an account can affect a token already in the wild.

The diagram is worth drawing because it makes the gap physical. There is no arrow from the account state to the verifier — and adding that arrow is exactly what a denylist or version check does, at exactly the cost you were avoiding.

at loginevery requestcachedno path to the verifierbuys revocation back, costs a lookupIssuer signs claimsPublic key / JWKSAccount disabledOptional: version / denylist checkToken held by clientGap: valid until expiryVerifier: signature + claims
UserLLMAgentToolDataDecisionHumanGuardrail

How much revocation do you actually need?

This is a product question with an engineering answer, and it is almost always skipped. "How fast must disabling an account take effect" has a different answer for a photo-sharing app and for a payments console, and the whole design falls out of it.

Note that the options are not ranked. A fifteen-minute window is negligent in one system and generous in another, and the only way to know is to ask what an attacker can do in that window.

Revocation requirement drives the design

When support disables an account, how soon must the next request fail?

Whenever the token expires

when Low-stakes access, short lifetimes, no meaningful damage in the window.

cost A compromised account keeps working for up to one token lifetime; write that number down.

Within a few minutes

when The usual answer: short access tokens plus a refresh exchange that checks account state.

cost Refresh traffic, client complexity, and a refresh endpoint that is now on the critical path.

Immediately, per subject

when Password change, forced logout, account disable must all take effect now.

cost A per-subject version counter checked on each request — a cached read, so revocation is immediate only to within that cache's TTL.

Immediately, per token

when Individual credentials must be killable — a leaked integration token, a specific device.

cost A denylist keyed by token id, checked per request: you have re-added the lookup that tokens removed (Where Sessions Live).

Reference tokens instead

when You want bearer-header ergonomics with session-grade revocation.

cost A lookup or introspection call per request; the token carries no claims, so it is a session with a different transport.

Refresh rotation, and the race it creates

GENERALThe rotation-plus-grace pattern is independent of token format; it applies equally to opaque refresh tokens and to signed ones, because the refresh store is stateful either way.

Rotating refresh tokens with reuse detection is the strongest commonly used defence against a stolen refresh token, and it introduces a race that will page you if you implement it naively. A client with several requests in flight sees several 401s and starts several refreshes; only one wins, and the others present a token that has just been consumed.

Strict reuse detection reads that as theft and revokes everything, which logs out a legitimate user in a way that is very hard to reproduce. The fix is a client-side single-flight around refresh plus a short server-side grace window where the immediately previous token is still accepted, with the family revoked only outside it.

Rotation with reuse detection and a grace window
1async function refresh(presented: string) {
2 const rec = await store.byHash(hash(presented)) // stored hashed
3 if (!rec) throw new AuthError() // unknown: nothing to do
4
5 if (rec.consumedAt) {
6 // Already used once. Either a concurrent refresh from the same
7 // client, or someone else has a copy.
8 if (Date.now() - rec.consumedAt < GRACE_MS && rec.replacedBy) {
9 return store.byId(rec.replacedBy) // hand back the same new pair
10 }
11 await store.revokeFamily(rec.familyId) // treat as theft
12 throw new AuthError('reuse detected')
13 }
14
15 const next = await store.issue({ familyId: rec.familyId, subject: rec.subject })
16 await store.consume(rec.id, { replacedBy: next.id }) // atomic compare-and-set
17 return next
18}

Two details carry the correctness: consume must be an atomic compare-and-set so two concurrent refreshes cannot both succeed (Atomic Operations), and the grace window must be short enough that a real thief cannot use it and long enough to cover a normal client's in-flight requests.

How to build it

Most important first.

  • Decide the revocation requirement first, in product terms: how quickly must "disable this account" actually take effect? Every other decision here follows from that number.
  • Keep access tokens short-lived and pair them with a longer-lived refresh token exchanged at an endpoint that checks account state, session records and revocation.
  • Rotate refresh tokens on every use, store them hashed, and treat reuse of a consumed one as compromise: revoke the entire family and require re-authentication.
  • Pin the accepted algorithm in the verifier. Never let the token's own header choose it, and validate issuer, audience, expiry, not-before and — where you have one — a key identifier.
  • Support two valid signing keys at once so rotation is a rollout rather than a mass logout, and publish key material through a mechanism the verifier refreshes (Secrets Are Not Configuration).
  • Add a per-subject token version or generation counter, bumped on password change, on forced logout and on permission changes that must take effect now. Checking it is a cheap cached read and it converts "wait for expiry" into "immediate", deliberately.
  • Keep claims minimal and identity-shaped. Roles and permissions in a token are a snapshot; authorize against current state for anything that matters (Where the Check Belongs, Role-Based Access Control).
  • Allow a small, explicit clock-skew tolerance and run NTP everywhere, rather than widening expiry to hide skew.
  • Store tokens on the client with the platform's protection, and understand that in a browser every storage location has a real weakness — which is why cookie-borne sessions remain a reasonable choice there (Session Authentication).

What can go wrong

Failure modes
  • The denylist is added for revocation and cached with a TTL, so revocation is silently delayed by the TTL rather than immediate.
  • Refresh rotation implemented without reuse detection, so a stolen refresh token is a permanent credential that renews itself.
  • A long-lived access token issued "temporarily" for an integration and never revisited, extending the revocation window for exactly the caller with the most access.
  • Key rotation performed with a single active key, invalidating every live token at once.
  • Claims trusted for authorization long after they stopped being true — the most common serious consequence of the revocation gap.
  • Verification code that checks the signature and forgets the expiry, which is a surprisingly common bug because the token still "verifies".
  • The token logged in full by a request-logging middleware, an error reporter, or a URL query parameter that ends up in an access log (Secrets in Logs).
What can race
  • Concurrent refresh with rotation is the classic one: a client fires several requests, several see a 401, several refresh at once. With strict reuse detection, the second refresh looks like a stolen token and revokes the family — logging out a legitimate user. Mitigations are a short grace window in which the previous token is still accepted, or a client-side single-flight lock on refresh.
  • A token issued microseconds before a revocation is valid by every check the verifier makes; the gap between issuance and revocation propagation is not closable, only shortenable.
  • Key rotation racing verification: a token signed with a new key can reach a verifier that has not yet fetched it, which is why key sets are cached with refresh-on-unknown-key-id rather than on a fixed timer.
Security
  • A bearer token is a password with an expiry. Transport it over TLS only, never put it in a URL, and never log it.
  • Pin the algorithm and validate every registered claim you rely on. Algorithm confusion and skipped-claim bugs are the classic JWT failures and are well documented on the attack side (JWT Failure Modes in Security Engineering).
  • Signed is not encrypted. Claims are readable by anyone holding the token, so a token is not a place for anything private.
  • The revocation gap is a security property you are choosing, not an implementation detail. Write down the window and make sure the people who answer support tickets know what it is.
  • A password change must invalidate refresh tokens and existing sessions; without a version counter, it does not invalidate outstanding access tokens at all (Credentials and Password Handling).
  • Consider binding tokens to a sender — a client certificate or a proof-of-possession scheme — where a stolen token is the primary threat; it is significantly more work and it removes pure bearer semantics.
Misreads
  • "Tokens are better than sessions." They are a different trade: no lookup versus no immediate revocation. Which is better depends on your revocation requirement and your appetite for a shared dependency.
  • "JWTs are stateless." The authentication check is stateless. Revocation, key rotation and refresh are all state, and they did not disappear — they moved (Stateless Services).
  • "We added a denylist, so we get both." You get both properties and you also get the per-request dependency back. That is a fine choice, stated honestly.
  • "Short expiry solves revocation." It bounds the window. Whether the bound is acceptable is a product decision about what "disable this account" must mean.
  • "The token is signed, so the claims are true." They were true when issued. Signing says nothing about now.
  • "Put the permissions in the token to avoid a lookup." Then a permission change takes effect when the token expires, which is rarely what anyone intended (Authentication vs Authorization).

Operating it

How you see it in production
  • Verification failures by reason: expired, bad signature, wrong audience, unknown key id, not-yet-valid. A rise in "not-yet-valid" is clock skew and nothing else.
  • Refresh rate and refresh failure rate. A spike usually means an access-token lifetime change; a spike in reuse-detection triggers means either a stolen token or a client with a broken retry.
  • Token age at time of use, as a histogram. It tells you the real distribution of your revocation window rather than the configured one.
  • Count denylist or version-check hits — every hit is a request that a pure self-contained scheme would have allowed.
  • Track which signing key id is in use during rotation, so you know when the old key can be retired.
What changes at 10x and 100x
  • The reason to reach for tokens at scale is that verification is local CPU and does not add load to a shared store on every request (Where Sessions Live).
  • At 10x, that property is worth real money and real tail latency. At 100x across many services, it also removes a single dependency that would otherwise be in every service's critical path.
  • The refresh endpoint concentrates the load you removed: it is stateful, it is called on a schedule set by your access-token lifetime, and it can stampede when many clients' tokens expire together — jitter the lifetimes (Backoff and Jitter, Cache Stampede for the same shape).
What this costs
  • No lookup per request, and no immediate revocation. This is the trade. Everything else in this lesson is a way of buying some revocation back and paying part of the lookup.
  • Short lifetimes shrink the revocation window and increase refresh traffic and client complexity.
  • A denylist or version check restores immediate revocation and reintroduces a per-request dependency — often a cached one, which reintroduces a smaller version of the same delay.
  • Rich claims avoid downstream lookups and make staleness a correctness problem; minimal claims force lookups and keep decisions current.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALThe "verify locally, cannot revoke immediately" trade holds for any self-contained credential, JWT or otherwise — including signed cookies and PASETO.
  • PROTOCOL-SPECIFICJWT specifics — the alg header, registered claims, JWKS key discovery — belong to that format. A reference token (an opaque string resolved at an introspection endpoint) uses the same Authorization: Bearer transport and has the opposite properties: a lookup per request and immediate revocation. Same header, different system.
  • FRAMEWORK-SPECIFICLibrary defaults decide whether the classic failures are possible: some JWT libraries historically honoured the token's own alg header unless you passed an allowlist, while others require the algorithm up front. Check which yours does before assuming the verification is safe.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.