Why a signed JWT cannot be taken back
A JWT cannot be revoked because it is self-contained: the server checks the signature and the expiry from the token itself, with no server-side record to delete. Any correctly signed, unexpired token is valid by definition. Revocation has to be bolted back on afterwards, and every method that does it costs something.
Key Takeaways
- A session id is a pointer to server state; a JWT is the state. Deleting a session row ends the session on the next request. There is no equivalent row for a JWT.
- "Log out", "log out everywhere", "ban this user now" and "this token leaked" are all easy with sessions and all awkward with stateless tokens.
- Short access-token lifetimes plus refresh tokens is the standard answer. It narrows the window; it does not close it.
- A
jtidenylist or a per-usertokensValidAftertimestamp gives you real revocation — by reintroducing exactly the server-side lookup that JWTs were meant to avoid. - Rotating the signing key revokes everything instantly, including every honest user's session. It is incident response, not a feature.
- If instant revocation is a hard requirement rather than a nice-to-have, the stateless JWT is the wrong tool and a server-side session is the right one. That is a trade-off, not a trick.
The mechanism is worth stating plainly, because most of the confusion here comes from skipping it. To validate a JWT, a resource server needs three things: the token, the key, and a clock. It recomputes the signature over the header and payload, compares it, reads exp, and decides. It does not call the authorisation server. It does not query a database. That is the entire point — verification is local, cheap and horizontally scalable, which is why JWTs suit APIs spread across services that share no session store.
That property has an exact dual. If verification never consults shared state, then no change to shared state can affect verification. Revocation is a change to shared state. So revocation cannot reach a token nobody looks up. This is not a bug to patch; it is the defining property of the design, seen from the other side.
The usual instincts therefore do not apply. Deleting the token from localStorage removes it from one browser and does nothing about a copy an attacker already holds. Even an OAuth revocation endpoint, specified in RFC 7009, is honest about this: it invalidates the record the authorisation server keeps, but where access tokens are self-contained and validated locally, resource servers keep accepting them until they expire. It revokes the grant, not the bytes already in the wild.
To see what a verifier actually has to work with, paste a token into the JWT decoder and read the payload: sub, iat, exp, maybe jti, maybe scopes. That is the whole basis for the decision, and none of it can change after issue.
The four requests that expose the problem
Four requests turn up in every real system, and it is worth being precise about which ones actually hurt.
"Log me out." The mildest case. On the device in question you drop the token and the user's intent is satisfied. The residual risk is only that a copy exists elsewhere — and if that is true, you have a bigger problem than logout. Most teams handle this client-side and accept the remaining minutes.
"Log me out everywhere." Now you must invalidate tokens held by devices you cannot reach. There is no message you can send to a browser you have no connection to, so whatever you do has to happen at verification time, on the API side.
"Ban this user, now." An abuse or fraud response. The user is hostile and will not cooperate by discarding anything, so every unexpired token they hold keeps working. With a 24-hour access token you have just told your trust-and-safety team that a banned account has a day of continued access.
"This token leaked." It turned up in a log, a bug report, a screenshot, a URL. The threat model is specific: whoever holds a valid bearer token can replay it against your API from anywhere, with no password, no second factor and no account-takeover flow to trip an alert. Nothing in the token distinguishes them from the legitimate holder — that is what bearer means.
Only the first is solved client-side. The other three are the same shape: a decision made in your database has to change what a stateless verifier does. Everything below is a way of paying for that.
The mitigations, compared honestly
Here are the approaches that actually work, with what each one buys and what it costs. The lifetimes used here are the ones reused throughout this post: access tokens of roughly 5 to 15 minutes, refresh tokens of days to weeks.
| Approach | What it actually achieves | What it costs | Still stateless? |
|---|---|---|---|
| Short access-token lifetime (5-15 min) | Bounds the life of a leaked access token to its remaining minutes | A refresh round trip every few minutes; more load on the auth server | Yes |
jti denylist | Genuine immediate revocation of named tokens | A lookup on every request; a store that must be as available as the API itself | No |
Per-user tokensValidAfter vs iat | "Log out everywhere" and "ban now" take effect on the next request | One lookup per user, cacheable; all-or-nothing per user | No, but cheaply |
| Refresh-token rotation + reuse detection | Detects a stolen refresh token and kills the whole family | Refresh-side state; occasional false logouts on network races | Refresh path only |
| Rotate the signing key | Invalidates every token signed with that key, instantly | Logs out every user at once | Yes |
| Sender-constrained tokens (mTLS, DPoP) | A stolen token is useless without the client's private key | Real client and infrastructure complexity | Yes |
And here is the same set against the scenarios, which is the table most teams actually need:
| Scenario | Short lifetimes | jti denylist | tokensValidAfter | Key rotation |
|---|---|---|---|---|
| Log out on this device | Window only | Yes | Logs out all devices | Overkill |
| Log out everywhere | Window only | Only if you track every jti | Yes | Yes, for everyone |
| Ban a user immediately | Window only | Only if every token is listed | Yes | Yes, for everyone |
| One named token leaked | Window only | Yes | Yes, at the cost of that user's other sessions | Yes, for everyone |
| Signing key compromised | No | No | No | Yes - this is the case for it |
Read the "Still stateless?" column carefully. Two of the six keep the property; the two that give you precise, immediate revocation both give it up. That is not an implementation detail you can engineer around. It is the trade.
Short lifetimes and refresh tokens: narrowing the window
The standard advice is to issue a short-lived access token and a longer-lived refresh token: the access token goes to your APIs on every call, the refresh token goes only to the auth server to mint a new access token when the old one expires.
This is good advice and you should do it. Be clear about what it buys, though. It does not make tokens revocable; it makes the consequence of not being able to revoke them bounded. A leaked 10-minute access token gives an attacker at most 10 minutes; a leaked 24-hour one gives them a day. The mechanism is unchanged — you have shortened the fuse.
It also moves the problem rather than removing it. Revocation now lives on the refresh token, which is usually a server-side record you can delete. Deleting it stops new access tokens being issued, so the user is locked out within one access-token lifetime. That is the number that matters: your worst-case revocation delay is the access-token lifetime, full stop.
Choosing it is a straight trade between exposure and load. Five minutes means an attacker gets five minutes and your auth server handles a refresh per client every five minutes. An hour is far cheaper and gives an attacker an hour. Most teams land in the 5-15 minute range for anything sensitive and stretch it for low-value read APIs.
One thing not to do: extending an access token by reissuing it on every request, sliding-window style. It sounds like a session and is not one — the old tokens stay valid until their own exp, so you have quietly created a growing set of live tokens per user with no way to name them. The sibling post on [exp, iat and nbf](/blog/jwt-expiry-exp-iat-nbf-explained) covers those claims and the clock-skew traps.
Buying revocation back: denylists and tokensValidAfter
If a bounded window is not good enough, you have to make verification consult shared state. There are two sane shapes.
A denylist of `jti` values
Give every token a unique jti at issue. On revocation, write that jti to a shared store. On every request, after signature and expiry checks pass, look it up; if present, reject.
This works exactly as advertised, at exactly the cost you would expect. A network lookup joins your request path, so that store now needs the availability and latency budget of your API — if it is down you either fail open (no revocation) or fail closed (an outage). Pick that policy deliberately and write it down, because the default is whatever your first try/catch happened to do.
The mitigating detail: entries only need to live until the token would have expired anyway. Set the store's TTL to the token's remaining lifetime and the denylist stays small — with 15-minute access tokens it holds at most 15 minutes' worth of revocations, comfortably an in-memory cache.
A per-user `tokensValidAfter` timestamp
Store one timestamp per user. On "log out everywhere" or a ban, set it to now. On every request, reject the token if its iat is earlier than that user's tokensValidAfter.
This is cheaper and, for the two scenarios people actually ask about, better. One row per user rather than per token, easily cached, and it covers tokens you never enumerated — including ones issued before you knew there was a problem. It is still a lookup, but one you can cache aggressively and invalidate on write.
Two things to get right. iat is seconds-resolution in practice, so a token minted in the same second as the cut-off can survive; set tokensValidAfter to now plus one second, or compare with >=. And apply the same check on the refresh path, or the client simply refreshes into a new token whose iat is after the cut-off.
The honest summary of both: you have rebuilt a session lookup and kept the JWT as its transport. Whether that is worth it depends on what local signature checks buy you elsewhere — which the JWT versus session cookies comparison goes into properly.
Refresh-token rotation and reuse detection
Refresh tokens are the higher-value target: they are long-lived, and possession of one means an attacker can mint access tokens indefinitely. Rotation plus reuse detection is the standard mitigation, and unlike most of this article it is genuinely effective.
Rotation means every refresh call returns a new refresh token and invalidates the one presented. Each token is single-use. Reuse detection is the corollary: if a refresh token that has already been used is presented again, treat it as compromise and invalidate the entire family of tokens descended from that original grant.
The logic is worth spelling out. After a theft the token exists in two places, so whoever refreshes second presents a token the server has already seen consumed. The server cannot tell which party is the attacker — and does not need to. It invalidates the family, both parties are logged out, and the legitimate user re-authenticates with credentials the attacker does not have. One annoying re-login buys a closed door.
Expect the failure mode: a client that fires two refreshes concurrently, or retries after a response is lost in flight, presents a used token innocently and gets its family killed. The mitigations are a short grace window in which the immediately-previous token is still accepted, and single-flight refresh logic so only one request is ever airborne. Build both before you turn detection on.
The OWASP Cheat Sheet Series covers the storage and transport side that pairs with this — where refresh tokens should live in a browser, and why a token in localStorage is reachable by any script you load. Our own API security best practices post covers the surrounding controls.
Rotating the signing key: the nuclear option
Signature verification depends on a key. Change the key and every token signed with the old one fails verification immediately, everywhere, with no lookup. It is the only mitigation on the list that is both instant and stateless.
It is also indiscriminate. Every user is logged out, every integration breaks until it refreshes, and any background job holding a token fails mid-flight. That makes it an incident-response tool for one situation: your signing key is compromised, or you have reason to believe it might be.
Two things make it survivable when you need it. Sign tokens with a kid header identifying the key, and have verifiers select the key by kid rather than assuming a single global secret — this is standard in RFC 7519 deployments and costs nothing up front. And publish keys through a rotating key set so verifiers pick up new keys without a deploy. With both in place, rotation is a config change. Without them, it is an outage with a deploy in the middle of it.
Rehearse it. A revocation mechanism nobody has ever executed is a plan, not a capability.
Deciding what your system actually needs
The useful question is not "how do I revoke a JWT" but "how much revocation delay can this system tolerate, and what am I willing to pay for less". Work through it in this order.
- Ask what the actual requirement is. "Instant" is often "within a few minutes" once someone states it out loud. If a 10-minute worst case is acceptable, short access tokens plus deletable refresh tokens is the whole solution and you can stop here.
- If it is genuinely instant, and it is per-user - bans, "log out everywhere", account suspension - use
tokensValidAfter. It is one cached lookup and it covers tokens you never enumerated. - If it is genuinely instant, and it is per-token - a specific token leaked, one device revoked - you need a
jtidenylist. Decide fail-open versus fail-closed before you ship it, not during the incident. - If nearly every request needs a state lookup anyway, stop. You are running sessions with extra signature maths and a larger
Authorizationheader. Use a server-side session, get revocation for free, and spend the saved complexity elsewhere. This is the right answer more often than the ecosystem admits. - Keep key rotation ready regardless.
kidheaders and a published key set, rehearsed once, so the nuclear option is available on the day you need it.
The failure worth avoiding is the middle: a system with 24-hour access tokens, no denylist, no tokensValidAfter, and a "log out everywhere" button in the settings screen that clears local storage and returns 200. It does nothing, and everyone who reads the code assumes someone else checked. If you cannot revoke, say so in the code and in the runbook, and set the token lifetime to a number you would be comfortable reading aloud during an incident.
Frequently Asked Questions
Can you revoke a JWT?
Not on its own. A JWT is self-contained, so a resource server validates it from the signature and the exp claim without consulting any shared state - which means no change to shared state can invalidate it. You can only add revocation back on top: a jti denylist, a per-user tokensValidAfter timestamp checked against iat, deleting the refresh token, or rotating the signing key. Each of those reintroduces a lookup, logs everyone out, or only shortens the window.
How do I implement "log out everywhere" with JWTs?
Store one tokensValidAfter timestamp per user and set it to the current time when the user asks. On every request, reject any token whose iat is earlier than that timestamp. It is a single cached lookup per user, it covers tokens you never recorded, and it works for account bans too. Apply the same check on the refresh endpoint, otherwise the client refreshes straight into a valid new token.
How short should access tokens be?
The access-token lifetime is your worst-case revocation delay, so choose it as an exposure budget. Roughly 5 to 15 minutes is common for anything sensitive; longer is defensible for low-value read APIs where the refresh load matters more. Pair it with a longer refresh token - days to weeks - that you can delete server-side. Do not extend access tokens by reissuing on every request: the old ones stay valid until their own exp.
Does a denylist defeat the point of using JWTs?
Partly, and you should be candid about it. Once every request hits a shared store, you have the availability and latency profile of a session lookup with signature verification added on top. It is still worth it if most of your requests do not need the check, or if the store is an in-memory cache with a TTL matching the token lifetime. If nearly every request needs it, server-side sessions are simpler and give you revocation for nothing.
What happens when a refresh token is stolen?
Without rotation, the attacker mints access tokens for as long as the refresh token lives, silently. With rotation and reuse detection, each refresh token is single-use, so whichever party refreshes second presents an already-consumed token - the server invalidates the whole token family and both parties are logged out. The legitimate user re-authenticates; the attacker cannot. Build a short grace window and single-flight refresh in the client first, or ordinary network retries will trigger false logouts.
Should I just use sessions instead?
If instant revocation is a hard requirement, yes. Sessions give you revocation as a property rather than a bolt-on: delete the record and the next request fails. JWTs earn their keep when verification really is local and frequent - many services, no shared session store, high request volume - and when a bounded window of a few minutes is acceptable. Choosing sessions because you need revocation is not a step backwards; it is picking the tool that matches the requirement.
Sources and references
RFC 7009 (datatracker.ietf.org) · OWASP Cheat Sheet Series (cheatsheetseries.owasp.org) · RFC 7519 (datatracker.ietf.org). Content was reviewed against these sources as of the last-updated date above; external figures and rules may change after publication.

