The Real Question Underneath This Problem
Everyone who reaches for JWT eventually hits the same wall: the token is stateless by design, but revocation is inherently a state problem. You can't invalidate something you never stored a record of. The usual advice is 'just query a denylist table on every request', and that works, but it quietly turns your stateless token into a database-bound session cookie with extra JSON parsing overhead.
The question isn't really 'how do I avoid a database lookup entirely.' It's 'how do I move that lookup somewhere cheap enough that it doesn't matter, and how do I shrink how often it needs to happen at all.' Those are two different levers, and mixing them up is why most teams either over-engineer this or ship something that falls over at scale.
Decision Criteria, Ranked
Before picking a strategy, answer these in order. Each one eliminates options.
- How fast does revocation need to take effect? Sign-out on request vs. 'within 60 seconds' vs. 'within 15 minutes' changes everything downstream.
- What's your request volume per second on protected endpoints? A hundred requests a second tolerates a Postgres join. Ten thousand does not.
- Do you need per-device revocation, or is 'kick out everywhere' good enough? This decides between a denylist and a simple version counter.
- What's your existing infra? If you already run Redis for caching or distributed rate limiting, you have zero extra ops cost to reuse it here.
Once you've answered those, the right combination usually falls out on its own.
Lever One: Shrink the Blast Radius With Short-Lived Access Tokens
Short expiry alone isn't revocation, and it's worth being upfront about that. A 15-minute access token that's been stolen is still valid for 15 minutes, full stop. What short expiry actually buys you is a smaller window where the 'no revocation' gap matters, which changes the cost-benefit of everything else you build.
The pattern that makes this work is pairing a short-lived, unrevocable access token (5 to 15 minutes) with a longer-lived, revocable refresh token. The access token is checked with signature verification only, no database, no cache, nothing. It's pure math. The refresh token is the only place you pay a lookup cost, and refresh requests happen far less often than API calls, often one refresh per fifteen minutes per user instead of one check per request.
This is the single biggest lever most teams skip. If your API gets called 50 times per session but refreshed once, you've cut your stateful lookups by 98% just from this split. If you haven't built the refresh side of this yet, revoking a JWT before it expires is the natural next piece to implement, and refresh token theft detection specifically is covered in reuse detection without storing every token.
Lever Two: Move the Lookup Off the Database, Onto Redis With a Matching TTL
If you still need per-token revocation (single device sign-out, stolen token response), a denylist is the simplest correct answer. The mistake is putting that denylist in your primary relational database.
Redis is the right home for this, for one specific reason: you can set the TTL on the denylist entry to match the token's remaining expiry exactly. A token that expires in 15 minutes gets a denylist key with a 15-minute TTL. After that, Redis garbage collects it for you. You never accumulate rows, you never run a cleanup cron job, and you never scan a growing table.
SET revoked:jti:8f3a2b1c 1 EX 900
Checking it is a single EXISTS call, sub-millisecond on a warm Redis instance, versus a network round trip to Postgres plus index lookup plus connection pool contention under load. This is the practical version of the denylist strategy: same concept, radically different latency and storage profile because you stopped treating it like permanent data.
The original design intent of a JTI denylist still holds here: you're storing an opaque identifier, not the token itself, and definitely not anything an attacker could replay. Keep that discipline even in Redis.
Lever Three: Token Epoch for 'Kick Out Everywhere' Without Per-Token Records
Sometimes you don't need surgical, single-device revocation. You need 'this user changed their password, invalidate every session they have, right now.' A denylist is the wrong tool here because you'd need to enumerate every outstanding token, which you often can't do without storing all of them.
The fix is a single integer on the user record: token_epoch. Every access token carries the epoch value that was current when it was issued, as a custom claim. On each request, you compare the token's epoch against the user's current epoch. Mismatch means reject.
Signing out everywhere is now one write: increment the user's epoch by one. Every previously issued token instantly fails validation, no matter how many there were or which device they came from. No enumeration, no cleanup job, no growing table.
The catch: this needs a lookup too, so it's not free of state. But it's the cheapest possible lookup, a single indexed integer column keyed by user ID, and it's a value you can cache aggressively in Redis with a short TTL (say, 30-60 seconds) since epoch changes are rare events, not routine ones. Most requests hit the cache; only password changes or forced logouts cause a cache miss and a real database read.
Combining the Levers: What Actually Ends Up in Production
The realistic setup that handles both single-device sign-out and force-logout-everywhere, without a database hit on every request, looks like this:
- Access tokens: short-lived (5-15 min), signature-only validation, carry a
token_epochclaim. - Epoch check: cached in Redis with a short TTL, cache miss falls back to a fast indexed database read.
- Refresh tokens: longer-lived, checked against a Redis-backed denylist or a user-attached 'current refresh jti' column, TTL matched to refresh token expiry.
- Single-device sign-out: denylist the specific refresh token's JTI in Redis.
- Sign-out everywhere: increment the user's epoch, no denylist entry needed.
Notice what's not happening: no request hits your primary relational database directly for access token validation. The database only gets touched on epoch cache misses and refresh events, both of which are orders of magnitude rarer than API calls.
Common Mistakes When Building This
A few things trip people up every time.
Storing the raw token string as the denylist key or value. Always extract the JTI or a hash, never persist anything that could be replayed if your cache got dumped or leaked.
Setting Redis TTLs longer than the token's actual expiry. If the token would have expired naturally in 10 minutes but your denylist entry sits there for 24 hours, you're wasting memory for zero benefit; the token was already dead.
Forgetting that Redis is not guaranteed durable by default. If your denylist check is your only line of defense against a serious account compromise, run Redis with persistence enabled (AOF or RDB snapshotting) or accept the small risk window on restart.
Mixing epoch checks and denylist checks inconsistently across services, so one microservice honors the epoch bump and another one, still caching the old value, doesn't. If you run multiple services validating the same tokens, the epoch cache TTL needs to be short enough that this drift window is acceptable for your threat model.
For the broader picture — comparing TTL configuration, blacklists, and refresh rotation without the performance angle — see JWT Invalidation Strategies: TTL, Blacklists, and When Each One Actually Makes Sense.