JWT Revocation Strategies: Stateless vs Stateful Trade-offs
Last updated:
JWTs are stateless by design — every server with the verification key accepts a valid signature until the exp claim passes. That makes them fast and horizontally scalable, but it also means there is no built-in way to revoke a token before it expires. To kill a token early — for logout, password change, stolen device, or admin-forced sign-out — you have to put some form of state back into the verification path. The practical patterns are short access tokens with stateful refresh tokens, Redis denylists keyed by jti, allowlist sessions tables, per-user tokenVersion counters, and JWE encrypted tokens with rotating keys. Each option trades the original stateless benefit for different revocation guarantees, and production systems usually combine two or three of them. This guide walks through every pattern with the trade-offs and a working code example.
Debugging a JWT revocation flow? Paste the token into Jsonic's JWT Decoder to inspect the jti, exp, and custom claims like tokenVersion without exposing secrets — everything decodes locally in your browser.
Why JWT revocation is hard (and the stateless contradiction)
The whole point of a JWT is that it is self-contained. The server signs a small JSON payload (HS256 secret or RS256/ES256 private key), the client stores the token, and every subsequent request is verified by signature alone. No database lookup, no session store, no coordination between API instances. That is what makes JWTs scale.
The cost is that there is nothing to delete. The token sits in the client's storage; the server keeps no record of it. When you decide a token should stop working — the user clicked Logout, the password was reset, an admin banned the account, a device was reported stolen — the server cannot reach into the client, and the verification logic has no list of revoked tokens to consult. Until the exp claim passes, every API server keeps accepting the token.
The practical answer: pure stateless JWTs do not support revocation, so any revocation strategy adds state back into the path. The questions become where to put that state, how big it gets, and how often it has to be consulted. Every pattern below answers those three questions differently. The right combination depends on your latency budget, your scale, and how quickly a revoked token must stop working.
Short expiry + refresh token pattern (15min/30day)
The baseline pattern for production JWT systems pairs two tokens with very different lifetimes. The access token is a short-lived JWT (15 minutes is standard) that authenticates API requests. The refresh token is a long-lived opaque token (30 days, sometimes 90) used only to mint new access tokens. The access token is stateless; the refresh token is stateful and server-stored. See the JWT refresh token pattern for a deeper walkthrough.
The split solves three problems at once. Short access tokens cap the damage of any token leak — a stolen token works for at most 15 minutes regardless of revocation infrastructure. The refresh token lives in server state (DB row or Redis entry), so deleting it instantly cuts off new access tokens. And the refresh request is the natural place to check whether the account is still in good standing — password change, suspension, role change — without paying that cost on every API call.
// Issue both tokens on login
import jwt from 'jsonwebtoken'
import { randomUUID } from 'crypto'
async function login(userId: string) {
const accessToken = jwt.sign(
{ sub: userId, jti: randomUUID() },
process.env.JWT_SECRET!,
{ expiresIn: '15m' }
)
const refreshToken = randomUUID()
await db.refreshTokens.create({
token: refreshToken,
userId,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
})
return { accessToken, refreshToken }
}The trade-off: there is still a window between logout and access token expiry where the access token is usable. For most apps that 15-minute window is acceptable. When it is not, layer on one of the access-token revocation patterns below — a Redis blacklist or a tokenVersion check.
Token blacklist (denylist) with Redis
The blacklist pattern adds a single Redis check to verify middleware. On logout (or any revocation event), the server writes the token's jti to Redis with a TTL equal to the remaining token lifetime. On every API request, middleware reads Redis to check whether the jti is present; if so, return 401. The TTL ensures the blacklist self-cleans — entries vanish when the token would have expired naturally.
The jticlaim (RFC 7519, Section 4.1.7) is the JWT's unique identifier — a string the issuer guarantees is unique across every token it issues. Use a UUID v4 or a 128-bit random value. jti is the handle every revocation strategy needs.
// Express middleware checking Redis blacklist
import jwt from 'jsonwebtoken'
import { redis } from './redis'
export async function verifyJwt(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '')
if (!token) return res.status(401).json({ error: 'missing token' })
let payload
try {
payload = jwt.verify(token, process.env.JWT_SECRET!) as {
sub: string
jti: string
exp: number
}
} catch {
return res.status(401).json({ error: 'invalid token' })
}
// Check denylist
const revoked = await redis.get(`jwt:blacklist:${payload.jti}`)
if (revoked) return res.status(401).json({ error: 'token revoked' })
req.user = { id: payload.sub }
next()
}Sizing: every entry is a ~36-byte jti plus tiny value with a TTL of at most the access token expiry (15 minutes). The blacklist cannot grow past (logouts per 15 minutes) keys at steady state — 10,000 logouts/hour with a 15-min token caps at ~2,500 keys, well under 1 MB of Redis. When scale pushes past comfortable Redis memory, switch to a Bloom filter for probabilistic membership with a controllable false-positive rate.
Token allowlist (sessions table) — opaque token alternative
An allowlist inverts the blacklist model: instead of recording revoked tokens, you record valid ones. Every active session is a row in a sessions table (or Redis hash). Verify middleware looks up the session on each request and rejects on miss. Revocation becomes a single delete.
Allowlists give you two things blacklists do not. First, instant revocation by deletion — no TTL math, no waiting. Second, rich session metadata: device fingerprint, IP, last-active timestamp, location. The sessions table becomes the source of truth for who is logged in where — required for security dashboards, "active sessions" UIs, and forensic investigation.
// Sessions table schema (Postgres / Prisma)
model Session {
id String @id @default(uuid()) // this is the jti
userId String
ipAddress String
userAgent String
createdAt DateTime @default(now())
lastUsedAt DateTime @default(now())
expiresAt DateTime
user User @relation(fields: [userId], references: [id])
@@index([userId])
@@index([expiresAt])
}
// Verify middleware
async function verifyAllowlist(req, res, next) {
const payload = jwt.verify(token, secret) as { jti: string }
const session = await db.session.findUnique({ where: { id: payload.jti } })
if (!session || session.expiresAt < new Date()) {
return res.status(401).json({ error: 'session invalid' })
}
await db.session.update({
where: { id: payload.jti },
data: { lastUsedAt: new Date() },
})
next()
}The cost is one DB read (and one write) per API request. At that point you are already paying the price of a stateful session lookup — the JWT signature adds cryptographic integrity but little else. Many teams reach this realization and ask: would an opaque session token be simpler? See JWT vs sessions for the comparison, and Decode JWT tokens to inspect what a JWT session actually carries.
Token versioning: bumping tokenVersion in user record
Token versioning gives per-user revocation without per-token state. Store an integer counter on the user record — tokenVersion— and embed it as a custom claim in every JWT. Verify middleware loads the user, compares the claim to the stored value, and rejects on mismatch. To revoke every token for a user (password change, "log out everywhere", admin action), increment user.tokenVersion by one — every previously issued token becomes invalid on its next request.
// User model
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
tokenVersion Int @default(0)
}
// Issue token with tokenVersion claim
function issueAccessToken(user: User) {
return jwt.sign(
{
sub: user.id,
tokenVersion: user.tokenVersion,
jti: randomUUID(),
},
process.env.JWT_SECRET!,
{ expiresIn: '15m' }
)
}
// Verify middleware
async function verifyTokenVersion(req, res, next) {
const payload = jwt.verify(token, secret) as {
sub: string
tokenVersion: number
}
const user = await db.user.findUnique({
where: { id: payload.sub },
select: { tokenVersion: true },
})
if (!user || user.tokenVersion !== payload.tokenVersion) {
return res.status(401).json({ error: 'token version mismatch' })
}
next()
}
// Force-logout everywhere
async function revokeAllUserTokens(userId: string) {
await db.user.update({
where: { id: userId },
data: { tokenVersion: { increment: 1 } },
})
}The cost is one user lookup per request. Cache the user record (in-memory LRU or Redis with short TTL) to amortize that cost across requests from the same user. The pattern composes well: keep short expiry as the baseline, add tokenVersionfor "log out everywhere", and add a Redis blacklist only when you need per-token (not per-user) revocation.
JWE encrypted JWT with rotating key invalidation
JWE (JSON Web Encryption, RFC 7516) wraps the JWT payload in an encryption layer on top of the signature. The client cannot read the claims; only servers with the decryption key can. That property gives a coarse-grained revocation tool: rotate the decryption key and every token encrypted with the old key becomes undecipherable, invalidating an entire generation at once.
This pattern is rare for general user sessions because the granularity is too coarse — you cannot revoke one user without forcing every user to re-authenticate. It shines for narrower cases: service-to-service tokens, tokens scoped to a feature flag, or per-tenant tokens where rotating one tenant's key invalidates only that tenant's sessions. Pair with a KMS (AWS KMS, GCP KMS, HashiCorp Vault) that handles versioning. See the JWT signing algorithms guide for how encrypted variants compose with signatures.
// JWE with key versioning using jose
import { CompactEncrypt, compactDecrypt } from 'jose'
// Key store keyed by version id (kid)
const keys: Record<string, Uint8Array> = {
'v1': await getKeyFromKms('v1'),
'v2': await getKeyFromKms('v2'), // current
}
const CURRENT_KID = 'v2'
async function issueEncryptedToken(payload: object) {
const jwt = await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('15m')
.sign(SIGNING_KEY)
return new CompactEncrypt(new TextEncoder().encode(jwt))
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM', kid: CURRENT_KID })
.encrypt(keys[CURRENT_KID])
}
// Rotating to v3 retires v1/v2 — all tokens
// encrypted with those keys stop decrypting.Logout flow: revoking access + refresh tokens
A correct logout flow revokes both tokens in the right order and clears them from the client. Order matters: invalidate refresh first so an in-flight refresh cannot mint a new access token between the two steps, then revoke the access token, then clear client storage.
// POST /api/auth/logout
import { redis } from './redis'
export async function logout(req, res) {
const { jti, exp, sub } = req.user // populated by verify middleware
const { refreshToken } = req.cookies
// 1. Invalidate the refresh token first
if (refreshToken) {
await db.refreshToken.delete({
where: { token: refreshToken },
}).catch(() => {})
}
// 2. Blacklist the access token's jti until exp
const ttl = Math.max(exp - Math.floor(Date.now() / 1000), 0)
if (ttl > 0) {
await redis.setex(`jwt:blacklist:${jti}`, ttl, sub)
}
// 3. Clear client cookies
res.clearCookie('access_token', { httpOnly: true, secure: true, sameSite: 'strict' })
res.clearCookie('refresh_token', { httpOnly: true, secure: true, sameSite: 'strict' })
return res.status(204).end()
}For "log out everywhere" across every device, skip the per-token blacklist and bump tokenVersion on the user record. That single increment invalidates every previously issued access token, and deleting all refresh_tokens rows for that user finishes the job.
Storage matters as much as flow. Store both tokens in httpOnly cookies with Secure and SameSite=Strict. httpOnly blocks JS access (defeats XSS exfiltration), Secure requires HTTPS, SameSite=Strict blocks cross-site requests. Together they defang the most common JWT theft vectors before revocation even has to fire.
Choosing a strategy: scale, security, latency trade-offs
No single pattern dominates — production systems combine two or three depending on the threat model and traffic profile. Use this table to map requirements to strategies.
| Strategy | Per-request cost | Granularity | Storage | Best for |
|---|---|---|---|---|
| Short expiry only | Zero | None until exp | None | Low-risk apps with 15-min tolerance |
| Refresh token revocation | Zero on access calls; 1 DB read on refresh | Per session, delayed by exp | DB row per session | Most production apps |
| Redis blacklist (jti) | 1 Redis GET | Per token, instant | 1 Redis key per revoked token | Instant logout with bounded growth |
| Allowlist sessions table | 1 DB read + 1 write | Per session, instant | 1 DB row per active session | Need session metadata, dashboards |
| tokenVersion | 1 DB read (cacheable) | Per user, instant | 1 int column on User | "Log out everywhere", password change |
| JWE rotation | Decryption + signature verify | Per key generation | KMS-managed keys | Service tokens, per-tenant keys |
A reasonable default stack for a SaaS app: 15-minute access JWT + 30-day refresh token (rotated on use with reuse detection) + tokenVersion for "log out everywhere" + Redis blacklist for the small set of revoke-now cases. That gives instant revocation when needed, low per-request cost in the common path, and a clean recovery story for stolen tokens. For deeper background, see JWT best practices and the JWT blacklist deep dive.
// Bloom filter blacklist for very high scale
import { BloomFilter } from 'bloom-filters'
// Sized for 1M revoked tokens at 0.1% false-positive rate
const blacklist = new BloomFilter(1_000_000, 4)
function revoke(jti: string) {
blacklist.add(jti)
}
function isRevoked(jti: string): boolean {
// false-positive possible: re-auth a small % of clean tokens
// false-negative impossible: revoked tokens always rejected
return blacklist.has(jti)
}The Bloom filter trades memory for a small false-positive rate — revoked tokens are always rejected (no false negatives), but a small fraction of clean tokens hash to a revoked slot and re-authenticate. For most apps that cost is cheaper than the Redis memory the equivalent exact blacklist would consume.
Key terms
- jti (JWT ID)
- RFC 7519 Section 4.1.7 — a unique identifier for the JWT, set by the issuer at signing time. The stable handle every revocation strategy uses. Conventionally a UUID v4 or 128-bit random value.
- blacklist (denylist)
- A list of revoked token identifiers. Verify middleware rejects on hit. Small (only revoked tokens), TTL-bounded, requires every active token to be self-validating via signature.
- allowlist (sessions table)
- A list of valid sessions. Verify middleware rejects on miss. Larger (every active session), instant revocation by delete, supports rich session metadata like device and IP.
- tokenVersion
- An integer counter on the user record, embedded as a JWT claim. Incrementing the user-level counter invalidates every token issued before that moment without touching token-level state.
- refresh token rotation
- Issuing a new refresh token on every use and invalidating the old one. Reuse of an invalidated refresh token signals theft and triggers family-wide invalidation.
- token family
- The chain of refresh tokens issued from one initial login. If any token in the chain is reused after rotation, the entire family is invalidated — both attacker and victim are logged out.
- JWE (JSON Web Encryption)
- RFC 7516 — encryption layer around a JWT payload. The client cannot read claims; rotating the decryption key invalidates whole batches of tokens at once.
- Bloom filter
- A probabilistic set membership structure. Constant memory regardless of size, with a tunable false-positive rate but zero false negatives — well suited to very large blacklists.
Frequently asked questions
Why can't I just revoke a JWT?
JWTs are designed to be stateless — the server verifies them by checking the signature with a shared secret or public key, not by looking them up in a database. That means once a JWT is issued, every server with the verification key will accept it until the exp claim passes. There is no central record to delete. To actually revoke a token before it expires, you have to add some form of state back into the verification path: a denylist of revoked token IDs, an allowlist of valid sessions, a per-user version counter the token embeds, or a key rotation that invalidates whole batches at once. Each option trades the original stateless benefit for the ability to kill a token early. The right answer is usually a hybrid: keep access tokens stateless and short-lived (15 minutes), and put the revocation logic on the longer-lived refresh tokens that you control issuance for.
What's the standard way to logout a JWT user?
For most apps the standard logout flow is: the client deletes its stored access token and refresh token, the server invalidates the refresh token in its database or Redis, and the next API call from that browser fails because the refresh request returns 401. If access tokens are short (15 minutes is typical), the small window where the access token still works is acceptable for most threat models — the user has explicitly logged out and is no longer making requests with it. When you need instant logout (the user reports a stolen device, an admin force-logs-out an account), pair this with one of the revocation strategies on the access token itself: a Redis blacklist keyed by jti, or bumping a tokenVersion claim that the verify middleware checks against the user record. The pure-stateless flow (just delete the client token) only works if you accept the window between logout and exp.
How big can a Redis blacklist get?
Smaller than you would think, because every entry has a TTL that matches the token expiry. If access tokens expire in 15 minutes, every blacklist entry self-deletes after 15 minutes — the blacklist can never grow beyond roughly (logouts per 15 minutes) keys. For a service with 10,000 logouts per hour and a 15-minute access token, that is ~2,500 keys at steady state. Each key is a jti (UUID, ~36 bytes) and a small value, so the whole blacklist fits in well under 1 MB of Redis memory. Refresh token blacklists are larger because TTLs are longer (30 days is typical), but they are still bounded by the logout rate over that window. If your blacklist grows past comfortable Redis size, switch to a Bloom filter for memory-efficient probabilistic membership checks, accepting a small false-positive rate that forces re-authentication for some unaffected tokens.
What is token versioning?
Token versioning stores a counter on the user record — tokenVersion — and embeds the same number as a custom claim in every JWT issued for that user. The verify middleware does two things: it validates the signature, then it loads the user from the database and compares the JWT tokenVersion against the user.tokenVersion. If they differ, the token is rejected. To revoke all tokens for a user (password change, account compromise, forced logout from every device), the server increments user.tokenVersion by one. Every JWT issued before that increment now mismatches and gets rejected on its next request, regardless of expiry. The cost is one database read per request, which negates much of the stateless benefit — but it gives you instant, user-scoped revocation with no Redis dependency. Caching the user record (with short TTL) brings the cost back down.
Is short expiry alone enough for security?
Short expiry is a partial mitigation, not a complete one. With a 15-minute access token, a stolen token is usable for at most 15 minutes — which limits damage but does not eliminate it. An attacker who exfiltrates a token in the first minute has 14 minutes of valid use, and 14 minutes is plenty of time to drain an account, read messages, or pivot deeper into the system. Short expiry is the right baseline because it lowers the impact of every other failure (logging exposure, XSS data leak, log retention) by capping the validity window, but the strongest revocation story pairs short access tokens with a refresh token rotation pattern that detects reuse. When a refresh token is used twice, the entire token family is invalidated and the user is forced to re-authenticate from scratch. That catches stolen tokens in flight, not just at expiry.
How do I revoke all tokens for a user instantly?
The cleanest pattern is tokenVersion: store a counter on the user record, include it as a custom claim in every JWT, and increment the counter to invalidate every token issued before that moment. The verify middleware compares the JWT claim to the current user.tokenVersion on each request — a mismatch means reject. Use this for password changes, "log out everywhere" buttons, and admin-forced logouts. The alternative is to invalidate all refresh tokens for the user in your sessions table or Redis, then wait out the access token expiry (15 minutes typical) — fine if your threat model tolerates the window. A third option is to rotate the signing key for that tenant, invalidating every token signed with the old key; this works for whole-tenant revocation but is overkill for a single user. Most production systems use tokenVersion plus refresh token invalidation in combination.
What's the difference between a blacklist and an allowlist?
A blacklist (denylist) records tokens that should be rejected — you assume any token is valid unless it appears on the list. The verify middleware checks the list and rejects on hit. Blacklists are small (only revoked tokens are stored) and TTL-bounded, but they require every issued token to be self-validating via signature — they extend stateless JWTs with a thin revocation layer. An allowlist records tokens that are valid — you assume any token is invalid unless it appears on the list. The verify middleware checks the list and rejects on miss. Allowlists are larger (every active session is stored) but give you instant revocation by deletion and let you store rich session metadata (device, IP, last activity). Allowlists effectively turn JWTs into opaque session IDs with extra cryptographic verification; if you are going to use one, consider whether opaque tokens with a sessions table would be simpler.
Can I use httpOnly cookies to make revocation easier?
httpOnly cookies do not change revocation itself, but they shrink the attack surface that creates the need for revocation. An httpOnly cookie cannot be read by JavaScript, so an XSS vulnerability that would otherwise exfiltrate a localStorage-stored JWT cannot reach the cookie value. Pair httpOnly with Secure (cookie only sent over HTTPS) and SameSite=Strict (cookie only sent on same-site requests) and you have a hardened token transport that resists the most common JWT theft vectors. You still need a revocation strategy for the cases httpOnly does not cover — compromised devices, malicious admins, password resets, account closures — but you will reach for the revoke button less often. The other benefit: cookies are sent automatically with every request, so your client code is simpler than the Authorization header pattern that requires manual injection on every fetch call.
Further reading and primary sources
- RFC 7519 — JSON Web Token (JWT) — Authoritative spec for JWT claims including the jti identifier used by every revocation strategy
- RFC 7516 — JSON Web Encryption (JWE) — Encryption layer for JWT payloads — the basis for key-rotation revocation patterns
- OWASP — JSON Web Token for Java Cheat Sheet — Token revocation, storage, and validation guidance from the OWASP security community
- Auth0 — Refresh Token Rotation — Reference implementation of refresh token rotation with automatic reuse detection
- IETF Draft — OAuth 2.0 Token Revocation — RFC 7009 — standard endpoint for revoking OAuth refresh and access tokens