DevKitLab Logo DevKitLab
JWT / Authentication / Security / Sessions

Short-Lived Tokens, Refresh Rotation, and How to Revoke a JWT

The way JWTs are usually deployed — as self-contained access tokens a server verifies locally, without checking any state — is exactly why you can't easily take one back: it's valid until it expires, whatever happens server-side. This article is about managing that: short access-token lifetimes, refresh tokens and rotation with reuse detection, and the real options for revoking a self-contained token, each with its trade-off against the statelessness you chose it for.

The previous article ended on the arrangement most teams converge toward: a short-lived access token paired with a well-guarded refresh token. This one is about the machinery that makes that arrangement work — and about the awkward property of JWTs that makes the machinery necessary in the first place.

Here is that property, stated plainly. JWT is just a format — nothing about it requires statelessness — but it’s commonly used as a self-contained, signed access token that a resource server verifies locally: the server checks the signature and the claims and reaches a verdict without calling a database or the issuer. That’s what makes the pattern fast and easy to scale across services. It is also exactly why you can’t easily take such a token back. When it carries an exp — a token doesn’t have to, but an access token should — it’s valid until that moment, and nothing you routinely do on the server changes that, because verification never pauses to ask, “is this still okay?” (The one blunt exception is retiring the signing key itself: that invalidates every token the key signed at once, but it’s a global, coarse lever, subject to JWKS and verifier-cache propagation, and it fits a key compromise rather than an ordinary logout.) So the operations every real system needs — log this user out everywhere, this token was stolen, kill it, this account is banned as of now — have no natural home. Every technique in this article exists to manage that one problem: to bound, detect, and undo the validity of a token that is already out in the world.

Lever one: short lifetimes

The simplest control is also the most important. If an access token expires a few minutes after it’s issued, a stolen one is dangerous only for those few minutes. You don’t revoke it — you wait it out, which is cheap and requires no state. A short lifetime isn’t revocation, though: revoking means the server actively invalidating a credential that would otherwise still be valid, and expiry does no such thing. What it does is bound how long a leaked token stays useful, with no server state at all — and that makes it the backbone the rest of the lifecycle is built on. Much of what follows is really about making short lifetimes practical rather than painful.

Because the pain is real: a token that expires every few minutes would, on its own, send users back through the login form constantly. You need a way to hand the browser a fresh access token silently, without a password prompt, every time the current one lapses. That mechanism is the refresh token, and its whole design is a response to this tension.

Lever two: the access/refresh split

A refresh token is a separate, longer-lived credential whose only job is to obtain new access tokens from the auth server. The reason to have two tokens instead of one is that they can then have opposite exposure profiles, and you protect each accordingly:

  • The access token is sent on every API request, so it’s exposed widely — in headers, proxies, logs. You keep its lifetime short precisely because it’s everywhere.
  • The refresh token is sent only to the token endpoint, rarely and to one place, so its exposure is narrow. That lets you guard it heavily — in an httpOnly cookie the browser’s JavaScript can’t read, or held entirely by a backend under the BFF pattern from the previous article.

That division is the entire point. The credential with the long life is the one that is transmitted rarely and kept out of reach; the credential that travels everywhere is the one that expires before it can do much harm. Collapse the two into a single long-lived token sent on every request and you get the worst of both — which is the arrangement short lifetimes exist to avoid.

Lever three: rotation and reuse detection

Refresh tokens introduce a new risk — they’re long-lived, so a stolen one is valuable — and rotation is the answer that turns that risk into something you can detect.

Rotation means that every time a refresh token is used, the server issues a brand-new refresh token and invalidates the old one. A refresh token becomes single-use. On its own that’s a modest improvement, but it enables the part that matters: reuse detection. After a rotation, only one party should legitimately hold the current refresh token. So if a previously-used, rotated-out token is ever presented again, something is wrong — the chain has forked, and two parties are holding descendants of the same login. That is the signature of a theft.

Walk the theft through and the mechanism becomes clear. An attacker steals a refresh token and uses it first: they receive a fresh access/refresh pair, and the stolen token is now spent. When the legitimate client later tries to refresh with the copy it still holds — the now-invalidated original — the server sees a spent token come back to life and recognizes the compromise. A well-built auth server responds by revoking the entire token family: every refresh token descended from that original login is invalidated at once, forcing a clean re-authentication. The attacker’s freshly-minted tokens die alongside the victim’s. Be honest about the timing, though: detection only fires when the legitimate client next tries to refresh and presents the now-spent original. Until that happens, the attacker can keep rotating their own live branch — so for an inactive user, the exposure window stretches to however long it takes the real client to return. Rotation reliably surfaces the theft and shuts it down once the legitimate client comes back; it does not guarantee containment within minutes. This token-family model with automatic reuse detection is the OAuth 2.0 security guidance for public clients — single-page apps and mobile — and it’s the reason rotation is worth the added complexity: it doesn’t prevent the first malicious use, but it bounds the damage and reliably surfaces the intrusion.

One honest caveat: rotation can generate false alarms on unreliable networks. If a refresh response is lost in transit, a client may retry with the old token it never learned had been rotated, which looks exactly like reuse. Implementations soften this — but not for free. A grace window that keeps tolerating the immediately-previous token also hands a stolen old token a usable window, so it has to be very short and tightly scoped; an idempotent refresh — returning the same successor for a retried request instead of rotating again — sidesteps the ambiguity more cleanly. Either way it’s a genuine security trade-off against reuse detection, to design for deliberately rather than discover in production.

Rotation isn’t the only sanctioned answer, either. The same OAuth security guidance gives public clients a parallel option: sender-constrained refresh tokens, bound to a key the client holds through DPoP or mutual-TLS, so a stolen token is useless without the matching private key. The spec frames it as a choice — sender-constrain or rotate. It deserves the same honesty as everything else in this series, though: in a browser, an XSS that can steal a token can usually reach the key material too, so this hardens against network interception and offline replay, not against script already running in your origin.

The hard part: revocation

Short lifetimes and rotation cover most situations, but sometimes you need to invalidate access now — a user clicks “log out of all devices,” an administrator disables an account, a token is known to be compromised. This is where the statelessness of JWTs stops being a feature, because there is no built-in “delete.” The available options are all trade-offs between how immediately you can revoke and how much statelessness you’re willing to give back. Four are worth knowing.

Rely on short TTL, and revoke at the refresh layer. This is the pragmatic default. The client still holds the refresh token, but the authorization server tracks its state — a stored hash, a token-family record — which is what lets it refuse the token later; revoke that record and no new access tokens will be issued for the session. The current access token stays valid until it expires on its own — but that’s only a few minutes if you’ve kept it short. You can’t kill the access token instantly, yet you cap its remaining life to minutes and stop the bleeding at the source. For most applications, a short window of residual validity is an acceptable price for keeping verification stateless.

Keep a denylist. When you revoke a token, record an identifier for it — its jti if the issuer sets one (that’s an optional claim, not a guarantee), otherwise a hash of the token or its session ID — until it would have expired, and have the verifier check that list on every request. This gets you near-instant revocation of specific tokens — at the cost of a lookup on the hot path, which hands back some of the statelessness you chose JWTs for. The cost is bounded, though: the list only ever holds revoked, still-unexpired tokens, so it stays small and its entries expire themselves.

Keep a per-user “valid-after” cutoff. Instead of tracking individual tokens, store one marker per user, meaning “reject anything issued before this point.” On logout-everywhere, password change, or a ban, you bump it; verification rejects any older token. Key it on a trusted iat from your own issuer, or — more robustly — on a per-user session-version claim you increment, since iat is optional and second-granularity timestamps get awkward around same-second issuance and clock drift. It’s one cheap lookup and it cleanly covers the common case of invalidating all of a user’s sessions at once. It’s coarse — all-or-nothing per user, no single-session granularity — but small and effective, and it composes well with the denylist for the cases that need finer control.

Give up statelessness where it genuinely matters. For the highest-stakes access, some systems abandon self-contained tokens for opaque reference tokens that the resource server introspects against the auth server — revocable and stateful, though with a caveat of its own: resource servers often cache introspection results, so how instant revocation really is depends on that cache’s TTL and how quickly a revocation propagates. The honest framing is the useful one: if instant, fine-grained revocation is a hard requirement for a particular token, a self-contained JWT may simply be the wrong tool for it, and a server-side session or an introspected token is the right one.

The through-line across all four is a single trade: revocation immediacy against statelessness. Short TTL buys “good enough” revocation while staying stateless; denylists and introspection buy immediacy by reintroducing state. There’s no universally correct point on that line — you pick it per token, according to what that token can do if it’s abused.

What “logout” actually means

It’s worth saying directly, because it’s a common and consequential misconception: for a JWT, deleting the token on the client is not revocation. If the token leaked before the user logged out, clearing your own copy does nothing to the attacker’s copy — theirs keeps working until it expires. Real logout is a server-side action: clear the client’s credentials and revoke the refresh token so the session can’t be renewed. “Log out everywhere” goes one step further — revoke every refresh-token family so that no session can renew. If you also need the access tokens already in circulation to stop working immediately, rather than at their next expiry, you have to bump the user’s valid-after cutoff (or denylist them) so the resource servers actively reject them. Revoking refresh tokens alone only stops renewal; the outstanding access tokens keep verifying until they expire unless something on the request path checks that cutoff.

Session length: absolute and idle limits

Refresh tokens usually carry two independent limits, and it helps to hold them apart. An absolute maximum caps the total session — say, thirty days from the original login, after which the user must sign in again no matter how active they’ve been. An idle (inactivity) timeout ends the session if it goes unused for some shorter span. That timeout is a server-side policy, not an automatic effect — you record when the refresh token was last used and reject it once the gap exceeds the window (the OAuth security guidance frames it as expiring a refresh token after a period of client inactivity). Rotation is what makes it convenient to enforce: each rotation is a natural moment to stamp a fresh last-used time and idle deadline onto the successor token, so a session that keeps refreshing stays alive and a dormant one lapses. Together the two limits bound both “how long can a session possibly last” and “how long can it sit dormant before we distrust it.”

A small, practical note that ties back to the first article: don’t over-tune lifetimes into fighting the clock. A verifier may be configured with a small clock-skew leeway on exp/nbf — the spec permits it rather than requiring it, and it’s usually kept to at most a couple of minutes — because server clocks drift. An access token so short-lived that it races that skew will produce spurious failures, so short means minutes, not seconds.

Seeing the lifecycle in a real token

Most lifecycle bugs are invisible until you read the actual numbers, and the numbers are just claims. When a token carries iat and exp — the registered claims for when it was issued and when it expires — together they tell you precisely how long it’s meant to live, but they’re stored as bare Unix timestamps that the eye can’t parse. Paste a token into the JWT inspector and those claims render as real dates: you can see at a glance whether you’re holding a short-lived access token or a long-lived one, and confirm your issuer is actually minting the lifetimes you configured. A frequent surprise is discovering that a token you believed expired in fifteen minutes in fact carries hours — a lifecycle bug that’s obvious the moment exp is a date instead of a ten-digit integer, and invisible until then.

The series, in one line each

That closes a four-part arc, and the parts only work together. Read a token — decode it and understand its claims, remembering that decoding is not trusting. Verify it correctly — let your own configuration, not the token’s header, decide what to accept. Store it carefully — keep the long-lived credential out of JavaScript’s reach, and treat stopping XSS as the real defense. And control its lifecycle — short access-token lifetimes, rotating refresh tokens that make theft detectable, and a revocation path for when short lifetimes aren’t enough. Miss any one of them and the others don’t save you: a system that verifies flawlessly but stores the token in localStorage with a 24-hour expiry and no way to revoke it has built a strong lock and taped the key to the door.

The JWT itself is a small, almost elegant idea — signed claims that anyone can decode, but that only a verifier holding the expected key can actually trust. Everything difficult about it is operational: what your verifier chooses to trust, where the browser keeps the token, and how you take it back when you have to. Get those right and the token does its job quietly. Get them wrong and the cryptography was never the part that mattered.