Revoking stateless JWTs

"Design token revocation with stateless JWTs."

What it is

A JWT is a signed assertion. A resource server validates it by checking the signature against the issuer's public key and then reading the claims. That is the whole point: no call to the issuer, no shared session store, no coordination.

Which is also the problem. A signed statement that a token is valid until 14:32 is true until 14:32, regardless of what happens in between. Revoking it means introducing something the resource server must consult, and that something is state. You cannot revoke a stateless credential without reintroducing state; the design question is where to put it and how much.

The honest framing to open with: this is not a problem with a clean solution, it is a tradeoff between revocation latency, availability and coupling. Anyone who answers "just keep a blocklist" has not costed it.

It is commonly confused with logout, which is easy (delete the client's copy) and does not revoke anything, and with refresh token rotation, which limits the damage from a stolen refresh token but does nothing about an access token already in flight.

The problem it solves

Four events require revocation, and they have different urgency:

EventRequired latencyFrequency
User logs outBest effortConstant
Password changed after compromiseSeconds to minutesRare
Admin disables an account (departure, fraud)SecondsRare
Permissions reduced (role removed)MinutesOccasional

The urgency differs by two orders of magnitude, which is the key to the design: you do not need one mechanism. Treating logout and account-compromise with the same machinery is what makes people over-engineer this.

Mechanics

The baseline: make the window small

Before adding any state, shrink what you are trying to revoke.

Access token TTL:   5 to 15 minutes
Refresh token TTL:  days to weeks, with rotation

A 15-minute access token means the worst-case exposure after a revocation event is 15 minutes, with no revocation mechanism at all. For a large class of applications that is sufficient, and saying so is a legitimate answer rather than a cop-out.

The refresh boundary is where revocation actually happens: the token endpoint is a call to the issuer, which has state, so the check is free there. Revoke the refresh token and the user is out within one access-token lifetime.

Refresh token rotation with reuse detection

1. Client presents refresh token R1.
2. Server issues new access token + new refresh token R2, and marks R1 used.
3. Next refresh uses R2. R1 is now invalid.

If R1 is ever presented again:
   Either the legitimate client didn't receive R2 (network failure), or
   an attacker stole R1 and is using it. You cannot tell which.
   => Revoke the ENTIRE token family. Both parties re-authenticate.

This is the highest-value mechanism in the whole design and it costs one table. Reuse of a rotated refresh token is a strong signal of theft, and the correct response is to invalidate the family rather than to guess which party is legitimate. The legitimate user re-authenticates, which is a minor annoyance; the attacker is locked out, which is the point.

CREATE TABLE refresh_tokens (
    id         uuid PRIMARY KEY,
    family_id  uuid NOT NULL,        -- all descendants of one login
    user_id    uuid NOT NULL,
    token_hash bytea NOT NULL,       -- store the hash, never the token
    used_at    timestamptz,          -- non-null => already exchanged
    expires_at timestamptz NOT NULL
);
CREATE INDEX ON refresh_tokens (family_id);
def exchange(presented_token):
    row = lookup_by_hash(sha256(presented_token))
    if row is None or row.expires_at < now():
        raise InvalidGrant()

    if row.used_at is not None:
        # Replay. Either theft or a lost response; treat as theft.
        revoke_family(row.family_id)
        audit("refresh_reuse_detected", family=row.family_id, user=row.user_id)
        raise InvalidGrant()

    mark_used(row.id)
    return issue_pair(row.user_id, family_id=row.family_id)

The three revocation mechanisms

1. Denylist by jti. Every token carries a unique id; revoked ids go into a store the resource server checks.

# The TTL is what makes this bounded: an entry only needs to outlive the
# token it revokes, so the store's size is (revocations per token lifetime),
# not (revocations ever). At 15-minute tokens that is a very small number.
def revoke(jti, exp):
    redis.setex(f"revoked:{jti}", ttl=exp - now(), value="1")

def validate(token):
    claims = verify_signature(token)     # local, no network
    if redis.exists(f"revoked:{claims['jti']}"):
        raise TokenRevoked()
    return claims

Cost: a network call on every request, which is the property you gave up statelessness for. Mitigate with a local cache plus a short TTL, and with a Bloom filter in front (a negative answer is definitive and needs no round trip; a positive answer requires confirmation). Availability question that must be answered explicitly: if the denylist is unreachable, do you fail open or closed? Fail open and a revoked token works during the outage; fail closed and your identity store's availability becomes your service's availability.

2. tokens_valid_after per subject. Instead of tracking individual tokens, track a per-user watermark.

# One entry per user rather than one per revoked token, and it revokes
# every outstanding token for that user in a single write. This is the
# right primitive for password change and account disable.
def revoke_all_for_user(user_id):
    redis.set(f"valid_after:{user_id}", now(), ex=MAX_TOKEN_TTL)

def validate(token):
    claims = verify_signature(token)
    watermark = redis.get(f"valid_after:{claims['sub']}")
    if watermark and claims['iat'] < watermark:
        raise TokenRevoked()
    return claims

Smaller, simpler, and it maps exactly onto the events that matter (password change, account disable, global logout). It cannot revoke one session while leaving others alive, which is a real limitation for "log out my other devices" but not for the security cases.

3. Introspection (RFC 7662). Do not use a JWT for the client-facing token at all. Issue an opaque random string; the resource server calls the authorisation server to ask what it means.

POST /introspect
token=mF_9.B5f-4.1JqM&token_type_hint=access_token

{"active": true, "sub": "user-123", "scope": "orders.read", "exp": 1735689600}

Instant revocation by construction, because the authorisation server is consulted every time. Cost: a network call per request and a hard availability dependency. Cache introspection results for a few seconds and you have traded revocation latency for load, which is the same tradeoff in different clothes.

The pattern I would actually deploy

Opaque token to the browser, JWT inside the perimeter.

Browser  --[opaque session cookie, HttpOnly]-->  BFF / gateway
                                                    |
                                          introspect or session lookup
                                                    |
Gateway  --[short-lived JWT, 5 min]-->  internal services (local validation)

The browser holds an opaque cookie, so revocation at the edge is instant: delete the session and the next request fails. Internal services receive a short-lived JWT minted by the gateway per request or per few minutes, so they validate locally with no coordination and no per-request lookup.

You get instant revocation where the risk is (the internet-facing credential) and statelessness where the scale is (service-to-service calls). The cost is a gateway in the path, which most architectures have anyway, and it neatly avoids the question of storing tokens in browser storage, since HttpOnly cookies are not readable by injected script.

Push-based revocation

For federated setups where the token issuer is not you, the emerging standard is the Shared Signals Framework (OpenID Foundation), of which CAEP (Continuous Access Evaluation Profile) is the security-event profile. The identity provider pushes an event (session-revoked, credential-change, assurance-level-change) to subscribed relying parties, which then invalidate locally.

This is the right long-term answer for enterprise SSO, because the alternative is every relying party polling or waiting out the token lifetime. Naming it is a strong currency signal; most candidates stop at "short TTLs and a blocklist".

A worked example

A B2B SaaS product. 200,000 daily active users, 40 internal microservices. An admin disables a departing employee's account and the requirement is that access stops within 60 seconds.

Naive: denylist checked by all 40 services. Every service now calls Redis on every request. At 12,000 requests per second internally that is 12,000 extra Redis operations per second and a hard dependency: if Redis is down, either everyone is locked out or nobody is revoked. Both answers are bad and you have re-created the session store you adopted JWTs to avoid.

Better: watermark, checked at the edge only. The gateway checks valid_after:{user} once per request; internal services validate the JWT signature locally with no lookup.

  • One Redis lookup per external request, not 40.
  • Revocation latency: the next request through the gateway, so effectively instant.
  • Internal services stay stateless.
  • Failure mode is bounded to one component with one decision to make.

Numbers. 200,000 users, one watermark entry each, only for users with a revocation in the last 15 minutes. Realistically tens of entries. Gateway throughput 3,000 requests per second, so 3,000 Redis GETs per second, which is trivial, and a 5-second local cache in the gateway reduces it by another order of magnitude while pushing worst-case revocation latency to 5 seconds, still well inside the 60-second requirement.

The availability decision, stated explicitly. If Redis is unreachable, the gateway fails open and logs loudly, because for this product the risk of every user being locked out exceeds the risk of a revoked user retaining access for the duration of a Redis outage. That is a judgement call that depends on the product, and the important part is that it is a decision with a rationale rather than a default. For a banking product I would fail closed and accept the outage.

What this does not cover, and I would say so: a token already in flight when revocation happens completes. With a 5-minute access token and edge checking, the window is one request, which is acceptable. If it were not, the answer is introspection at the edge with no cache, and the cost is the identity provider's availability becoming the product's.

Production evidence

OAuth 2.0 Token Revocation (RFC 7009) and Token Introspection (RFC 7662) are the standards for the revocation endpoint and the introspection endpoint respectively. Notably, RFC 7009 says a revocation request revokes the refresh token and may revoke associated access tokens, and that the authorisation server is not required to be able to revoke JWTs it does not track, which is the specification acknowledging the problem directly.

The OAuth 2.0 Security Best Current Practice (RFC 9700) requires refresh token rotation with reuse detection for public clients and describes the family-revocation response. Auth0, Okta and every major identity platform implement it, and it is the single most widely deployed piece of this design.

The OpenID Shared Signals Framework and CAEP define the push-based model, with Google, Microsoft and Okta among the implementers. It exists precisely because short TTLs plus polling is an unsatisfying answer for enterprise SSO.

The backend-for-frontend pattern (opaque cookie to the browser, tokens held server-side) is recommended in the IETF's browser-based-apps guidance, and its motivation is both revocation and keeping tokens out of reach of XSS.

The debate

The real alternative is do not use JWTs for the client-facing token. Opaque tokens with a session store give you instant revocation, immediate permission changes, no key-rotation complexity, and no risk of a signed token outliving its authorisation. Session stores are a solved problem and Redis handles the load comfortably.

The case for JWTs is genuine at scale: no lookup per request, no shared store to become a bottleneck or a single point of failure, and validation works across trust boundaries where a shared session store cannot. For service-to-service calls inside a mesh, that is exactly right.

My position: opaque tokens at the edge, JWTs inside. Revocation happens where the credential is exposed to the internet, which is where the risk is; statelessness happens where the request volume is, which is where the cost is. Access tokens of 5 to 15 minutes regardless, refresh rotation with reuse detection and family-wide revocation, and a per-user watermark rather than a per-token denylist because it maps onto the events that actually require revocation.

Pure stateless JWTs everywhere are the wrong choice when permissions change frequently (a token minted with a role the user no longer has is a live authorisation bug), when the compliance requirement is immediate revocation, or when tokens are long-lived. Long-lived JWTs are the specific antipattern: a 24-hour access token with no revocation mechanism is a 24-hour window on every compromise, and no amount of design elsewhere compensates.

Follow-up Q&A

"Design token revocation with stateless JWTs." Start by shrinking the problem: 5 to 15 minute access tokens, so worst-case exposure is bounded without any mechanism. Do the real revocation at the refresh boundary, which is a call to the issuer and therefore already stateful, with rotation and reuse detection that revokes the whole family on replay. For immediate revocation, add a per-user tokens_valid_after watermark checked at the gateway rather than a per-token denylist checked by every service, because it is one entry per user instead of one per token and it maps onto the events that matter. And decide explicitly whether an unreachable revocation store fails open or closed.

"Why a watermark rather than a jti denylist?" Size and semantics. The denylist grows with the number of revoked tokens; the watermark is one entry per user, written only when something happens. And the events you actually need to handle (password change, account disable, global logout) are all "invalidate everything for this user", which is exactly one watermark write. The denylist's advantage is revoking a single session while leaving others alive, which matters for "log out my other devices" and not for the security cases. You can have both: watermark for the security events, denylist for selective session logout.

"What is refresh token rotation with reuse detection, and why revoke the whole family?" Each refresh issues a new refresh token and invalidates the old one. If an already-used token is presented again, either the legitimate client never received its replacement or an attacker has a stolen copy, and you cannot tell which. Revoking the entire family forces both parties to re-authenticate, which locks out the attacker at the cost of one login for the legitimate user. Letting it slide means an attacker with a stolen refresh token retains indefinite access, which is strictly worse.

"What happens if your revocation store is down?" You must decide in advance, because both answers are defensible and the wrong one is a surprise. Fail open and revoked tokens work for the duration of the outage; fail closed and your identity infrastructure's availability becomes your product's. I would fail open for a general SaaS product with loud alerting, and fail closed for anything handling money or regulated data. What matters is that it is a documented decision with a rationale, not the default behaviour of whichever client library you used.

"How do you handle a permission change rather than a revocation?" Same mechanism, different urgency. A role removal means the outstanding token asserts permissions the user no longer has. Options: bump the user's watermark so the token is rejected and a new one minted at refresh, which costs one round trip and is usually right; or do not put fine-grained permissions in the token at all, and have the resource server evaluate authorisation against the current state. The second is better practice for a separate reason: scopes are what the client asked for, not an authorisation decision about a specific resource. Treating scope as an access control list is how broken object-level authorisation gets shipped.

Common misconceptions

The most common is that JWTs can be revoked without state. They cannot. Every mechanism reintroduces state somewhere; the design question is where and how much, and answering "just use a blocklist" without costing the per-request lookup and the availability dependency misses the whole problem.

The second is that logout revokes anything. Deleting the client's copy of a token stops that client from using it and does nothing about a copy an attacker took.

The third is that a long-lived JWT is fine if you have a denylist. The denylist has to be consulted on every request, which means you have a session lookup with extra cryptography, and if it is ever unavailable you are back to a long-lived unrevocable credential.

Interview delivery note

Say this: "You can't revoke a stateless credential without adding state, so the question is where to put it. First I'd shrink the window: 5 to 15 minute access tokens, so worst-case exposure is bounded even with no mechanism at all. Then do the real revocation at the refresh boundary, which already talks to the issuer, with rotation and reuse detection that revokes the whole family on replay. For immediate revocation I'd use a per-user tokens_valid_after watermark checked at the gateway, not a per-token denylist checked by every service: one entry per user instead of one per token, and it maps onto the events that actually matter."

Then the two things that make it a staff answer. The architecture: "in practice I'd give the browser an opaque HttpOnly cookie and mint short JWTs at the gateway for internal calls, so revocation is instant where the credential is exposed and statelessness is preserved where the volume is." And the explicit availability decision: "and I'd decide up front whether an unreachable revocation store fails open or closed, because both are defensible and discovering the default during an outage is not."

Further reading

  • RFC 7009 (Token Revocation) and RFC 7662 (Token Introspection), including RFC 7009's own acknowledgement that JWT revocation is not generally supported.
  • RFC 9700, "Best Current Practice for OAuth 2.0 Security", on refresh token rotation and reuse detection.
  • OpenID Foundation Shared Signals Framework and the CAEP profile, for push-based revocation across federated systems.
  • IETF "OAuth 2.0 for Browser-Based Applications", for the backend-for-frontend pattern and why tokens should not live in browser storage.