Why You Can't Revoke a JWT, and the One Setup Where You Can

Why You Can't Revoke a JWT, and the One Setup Where You Can

How to revoke a JWT before it expires, why a jti blacklist in Redis is the answer everyone reaches for, and how Versola edge does it with Postgres LISTEN/NOTIFY instead: real HTTP examples.

Search for how to revoke a JWT and the first answer you get is that you can’t. JWTs are stateless, the reasoning goes, so a token stays valid until exp no matter what happens in between: user logs out, admin blocks the account, security confirms the session was stolen. The usual advice follows immediately. Keep access tokens short, revoke the refresh token instead, accept the window.

We believed that too, until a bank’s fraud team asked us to kill a compromised session in under a second and “the access token is technically still valid for another five minutes” turned out not to be an answer anyone would accept.

The premise is half right. A signature proves what was true when the token was minted, and it cannot tell you whether that has been undone since. What follows from it is where the reasoning quietly breaks: “so you cannot revoke a JWT” is not a fact about signatures. It is a fact about an assumption almost nobody states out loud, that whatever verifies the token has to reach a verdict using nothing but the token.

Amazon documents the actual problem better than anyone

Cognito has a revocation endpoint and a GlobalSignOut API, so revoking an access token there is a solved problem. Read what the docs say actually happens:

Revoked tokens can’t be used with any Amazon Cognito API calls that require a token. However, revoked tokens will still be valid if they are verified using any JWT library that verifies the signature and expiration of the token. AWS Cognito documentation, token revocation

And, from the same vendor’s knowledge base, more bluntly:

Note that only Amazon Cognito is informed of the token revocation. Your application might continue to accept the tokens until they expire. AWS re:Post, Cognito logout endpoint and GlobalSignOut

The revocation happened. It is recorded, durably, on the provider’s side. Your API keeps honoring the token anyway, because your API verifies the JWT with a library, locally, from the token alone. Nothing in that code path has any way to learn what Cognito knows.

That is not a JWT limitation. That is a topology limitation, described by the vendor in their own documentation, and it is the same shape in every provider that hands you a JWT and lets each of your services verify it independently.

The short TTL answer, and the arithmetic it skips

The standard mitigation is to shrink the exposure window instead of closing it. Drop the access token TTL from an hour to five minutes and a stolen token dies in at most five minutes on its own.

It also means every client refreshes twelve times more often: twelve times the load on the token endpoint, twelve times the refresh round trips, to buy a window that is still five minutes wide. Five minutes is a long time when a fraud team is watching an attacker move money right now.

Short TTLs do not close the gap. They rent it on a shorter, more expensive lease.

The blacklist answer, and what it really costs

The other standard answer is a denylist. Put a jti in every token, and on revocation write that jti to Redis with a TTL matching the token’s remaining lifetime. Every request checks the blocklist before honoring the token. Variants show up under different names: a token version counter bumped per user, a not-before timestamp per subject that invalidates everything minted earlier, a full blacklist keyed by token.

These all work. They are also where the objection lands that gets repeated as gospel: now you are doing a lookup on every request, so the token is not stateless anymore, so why use a JWT at all. That objection is usually presented as the end of the discussion. It is worth taking one step further and asking what exactly the lookup costs, because the answer depends entirely on who is doing the looking.

If every backend service verifies tokens for itself, then every backend service needs that revocation state. Not one cache: N caches, N subscriptions to whatever pushes updates, N reconnect and catch-up paths for when that subscription drops, N places where a clock skew bug can quietly let a revoked token through. In however many languages and deployment shapes those N services happen to be written in.

We built exactly one copy of that machinery for Versola edge: a Postgres LISTEN/NOTIFY feed into an in-memory map, a resume cursor so a replica that misses a notification catches up instead of guessing, a fail closed startup gate so a replica that cannot read the list refuses to serve traffic at all. The rest of this post walks through why we put that on Postgres instead of standing up a new store for it.

Once is a project. N times, per service, is why almost nobody does it, and why the short TTL advice wins by default.

Four places the revocation state can live

Where the state livesCostWho ships this
Nowhere, short TTL onlyexposure window equals the TTL, roughly 12x the auth load at 5 minutes instead of 60the standard advice, and most providers’ default posture
Every service, independentlyN caches, N update subscriptions, N catch up paths, N chances to get it subtly wrongjti blacklists wired into each service’s JWT middleware
The auth server, checked synchronously per requesta network round trip added to every single API callany design where verification calls out on the hot path
One proxy in front of everythingone cache, one cursor, a hashmap lookup with no network callVersola edge

Row two is the honest version of the Redis blacklist advice, and its cost is maintenance surface rather than compute. Row three trades that for latency on every request, which is a real option and a different article’s worth of detail. Row four is the same machinery as row two, built once, because exactly one process family needs to hold it.

flowchart LR
    subgraph N["Every service validates independently"]
        direction TB
        SA["Service A"] -->|own LISTEN, own cache, own cursor| DBA[("Postgres")]
        SB["Service B"] -->|own LISTEN, own cache, own cursor| DBA
        SC["Service C"] -->|own LISTEN, own cache, own cursor| DBA
    end

    subgraph P["One proxy validates for everything"]
        direction TB
        Client(["Client request"]) --> Edge["edge replica"]
        Edge -->|one LISTEN, one cache, one cursor| DBP[("Postgres")]
        Edge --> SA2["Service A"]
        Edge --> SB2["Service B"]
        Edge --> SC2["Service C"]
    end

Why row four is a precondition, not a technique

Here is the part that would be easy to oversell. Row four is not a trick any architecture can adopt by trying harder. It is available exactly when a proxy already terminates every request before it reaches a backend, which is what edge does in Versola’s architecture for reasons that have nothing to do with revocation: routing, authn/authz enforcement, rate limiting, the usual.

If your services verify JWTs independently, adding a shared cache does not move you from row two to row four. It adds a fifth thing to synchronize between N services, which is row two with extra steps. Row four requires giving up independent verification first, and that is an architectural commitment, not a library choice.

So the claim is not that our cache is fast. A hashmap lookup being fast is not news. The claim is that the fast lookup is only reachable from one starting point, and if you are not standing there, no amount of engineering gets you the property. That is worth saying plainly, because the alternative is a team building a worse version of row two while believing they built row four.

Why Postgres, and not a new store

Here is what row four actually had to satisfy: zero database calls on the request path, revocation visible everywhere in about a second instead of at the next poll, no replica ever serving traffic without having loaded the list first, and enough headroom to hold a worst-case mass revocation of a million entries in memory. The first and third pull against each other. “No database call” and “never wrongly accept a revoked token” only coexist if the thing answering the question already has the answer cached before the request arrives.

The obvious move is to reach for Redis: a jti blacklist with a TTL is the answer every search result gives, and it would have worked here too. We didn’t, because edge already runs a Postgres per deployment as the source of truth for everything else it holds, clients, sessions, the works, and every replica already shares a connection pool into it as a basic fact of being edge. Standing up Redis alongside that for one feature means a second store to provision, back up, and reason about failure modes for, to hold state that Postgres could carry for free. LISTEN/NOTIFY gets us the one thing Redis would have added: push instead of poll. A revocation written by any replica becomes a row every other replica already has a connection to, and Postgres tells them about it instead of making them ask.

That is the whole architectural bet, and it only pays off because a proxy sits in front of everything: a handful of processes can afford to hold a full in-memory copy of the revocation list and keep it warm, in a way that would be absurd to ask of every backend service independently.

sequenceDiagram
    autonumber
    participant Ops as Fraud team
    participant Auth as auth (OP)
    participant E1 as edge replica 1
    participant PG as edge's Postgres
    participant E2 as edge replica 2

    Ops->>Auth: DELETE /users/sessions  {userId}
    Auth->>Auth: End every session for userId, group clients by back_channel_logout_uri
    Auth-->>Ops: 200 OK

    Auth->>E1: POST /logout/backchannel  logout_token=(sub, toe, events)
    E1->>E1: Verify signature against JWKS, check iss/aud/events
    E1->>PG: INSERT INTO revocations (revoked_key='sub:user-42', issued_before=toe, ...)
    PG-->>PG: AFTER INSERT trigger -> NOTIFY 'revocation'

    par Every replica listens on its own connection
        PG-->>E1: NOTIFY revocation payload
        E1->>E1: entries.merge(key, revocation, widest)
    and
        PG-->>E2: NOTIFY revocation payload
        E2->>E2: entries.merge(key, revocation, widest)
    end

    note over E2: Next request carrying a token issued before toe
    E2->>E2: isRevoked(keys, iat) - ConcurrentHashMap.get, no query
    E2-->>E2: 401 Unauthorized

The fraud team from the intro doesn’t call /revoke with a token in hand, they don’t have one. They call an internal endpoint with a user ID:

DELETE /users/sessions HTTP/1.1
Host: auth.internal.example.com
Authorization: Bearer <admin-token>

{ "userId": "user-42" }

auth ends every session that user has and sends one back-channel logout token per client, not one per session, so a user with five sessions across two clients costs one or two deliveries, not five. No sid in that token, on purpose: OIDC back-channel logout treats a bare sub as “end all of this user’s sessions.” edge verifies the signature against auth’s JWKS and writes one row that outlives the individual sessions:

INSERT INTO revocations (revoked_key, revoked_at, expires_at, issued_before)
VALUES ('sub:user-42', now(), '2025-01-01 14:00:00+00', '2026-08-25T14:00:00Z')

An AFTER INSERT trigger calls pg_notify('revocation', ...), every replica’s listener picks it up, and about a second after the fraud team’s call, every access token that account had issued stops working, the ones in the attacker’s hands and the ones in the owner’s other tabs alike. That’s the cost of a sub-wide kill: nothing at the JWT layer can tell attacker from owner. The owner logs in again and gets a token stamped after the cutoff, which is what issued_before is for.

The same key column carries two narrower granularities through a prefix instead of a separate mechanism each:

PrefixKillsTriggered by
sub:every token a user holds, everywhereadmin or security ending someone’s access
jti:one access tokena client’s own /revoke call (RFC 7009)
sid:every token in one SSO sessionordinary logout

sub: needed the most thought precisely because it’s the broadest and the most time-sensitive: it has to coexist with the same user logging back in seconds later, which is why it carries a moving cutoff instead of a ban somebody has to remember to lift.

NOTIFY has no redelivery. Drop the connection and everything published in the gap is gone, silently, which is the actual argument for keeping this on a database instead of a fire-and-forget pub/sub. Three things close that gap. At startup, a replica reads the full revocation list before serving anything and refuses to start if it can’t, because an empty cache isn’t a stale answer, it’s a wrong one. On reconnect, it resumes from a cursor instead of reloading everything, applying a handful of already-applied revocations twice rather than risk missing one. Every ten minutes regardless, a full resync runs as backstop, and nothing in normal operation depends on it firing.

What this doesn’t fix

It needs the proxy to already be on the hot path. If your deployment does not route every request through one, this design is unavailable rather than merely harder. Short TTL with an accepted exposure window is the honest fallback for that architecture, not row four assembled piecemeal.

The state has a size assumption, not a cap. The cache holds every unexpired revocation in memory with no eviction, because capacity is not the safety property here, the expiry timestamp on each entry is. Around a million entries works out to roughly 250MB per replica. That figure is arithmetic, not a load test we have run at that scale.

Losing the database degrades the answer, it does not stop traffic. Fail-closed applies at startup only: a replica that cannot read the list refuses to come up, which keeps it out of the load balancer. One that is already serving and then loses Postgres keeps proxying from the last list it loaded, logging a warning and reporting how stale it is. That is deliberate, and it is a tradeoff rather than a free choice: the alternative, rejecting every request, converts a revocation outage into a total outage. It does mean revocations written during that window reach nobody until the connection returns.

The window is not zero. Between the write landing and the notification reaching every replica, some request somewhere is served against the old cache. Sub-second in practice, not a guarantee.

We are describing our own architecture, which is a reason to be stricter about these boundaries rather than looser. Row four is real, and it exists only because the proxy was already there.

Common questions

Does the OAuth revocation endpoint (RFC 7009) revoke access tokens? Not necessarily. The spec lets a server support only refresh tokens and return unsupported_token_type, and it explicitly notes that immediate access token revocation needs some interaction between the authorization server and the resource server that the spec does not standardize. What that interaction looks like is the entire subject of this post.

Is a Redis jti blacklist wrong? No. It is row two, and it works. Its cost is that every service verifying tokens needs its own copy of the checking logic and its own path to stay current.

How do you log a user out of all devices? Revoke by subject rather than by token id, so one entry covers every token that user holds anywhere. Pair it with a cutoff timestamp so the user logging back in immediately is unaffected, otherwise you have banned the account rather than ended its sessions.

Is token versioning enough? It gives you user wide revocation cheaply, and it cannot express “kill this one session” or “kill this one token”. Which granularities you need is a product question, and the storage cost difference between them is small.

← All articles