Revocation, Introspection and Opaque Tokens
Getting a working logout out of a stateless design — denylists, short lifetimes and token introspection.
A signed token is valid until it expires. There is no message you can send that unsigns it. So "log out", "ban this user" and "revoke this key" need a deliberate design.
Option 1 — short lifetimes (the default answer)
Make access tokens live 5–15 minutes and revoke the refresh token instead. Your worst-case exposure is one access-token lifetime. No extra infrastructure, and it is what most systems ship.
Option 2 — a denylist
Store revoked jti values in Redis with a TTL equal to the token's remaining life, and check on each request. Precise, and small: entries expire on their own, so the list stays bounded. The cost is a lookup per request, which is the very thing stateless tokens were avoiding.
Option 3 — a global version counter
Keep tokenVersion on the user row and embed it in the token. Increment it to invalidate everything that user holds. Cheaper than a per-token denylist and it is exactly what you want on password change — though it is all-or-nothing.
Option 4 — opaque tokens + introspection
Issue a random string instead of a JWT and have the API look it up (locally, or via an OAuth 2.0 introspection endpoint, RFC 7662). You are back to stateful sessions, with all their revocation power, wrapped in bearer-token ergonomics. Many providers do exactly this and cache the introspection result for a few seconds.
What to revoke, when
| Event | Action |
|---|---|
| Logout | revoke this refresh family |
| Logout everywhere | revoke every family for the user |
| Password change | revoke everything + bump tokenVersion |
| Account suspended | bump tokenVersion; deny at the gateway |
| Token reuse detected | revoke the whole family |
| Key compromise | rotate the signing key — invalidates everything at once |
Be honest in the UI
If "sign out everywhere" leaves access tokens working for another ten minutes, do not claim instant effect. Either say so, or add the denylist that makes the claim true.
Example
// Denylist a specific token until it would have expired anyway
async function revokeToken(claims) {
const ttl = claims.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) await redis.set(`revoked:${claims.jti}`, '1', 'EX', ttl);
}
// Check on the way in
async function isRevoked(claims) {
return claims.jti ? (await redis.exists(`revoked:${claims.jti}`)) === 1 : false;
}
// The list can never grow without bound: every entry deletes itself when the
// token it refers to would have expired regardless.When to use it
- A fraud team suspends an account and the tokenVersion bump locks the user out of every device on their next request instead of within ten minutes.
- A gateway caches introspection results for five seconds, giving near-instant revocation without a lookup on literally every request.
- A leaked signing key is rotated during an incident, invalidating every outstanding token in one action at the cost of a global re-login.
More examples
Token version: one integer, total revocation
A five-second cache on the version keeps the lookup off the hot path while capping the revocation delay at five seconds — a much better number than ten minutes.
// users table: token_version INTEGER NOT NULL DEFAULT 0
function issueAccessToken(user) {
return jwt.sign(
{ sub: String(user.id), ver: user.tokenVersion, scope: user.scopes.join(' ') },
PRIVATE_KEY,
{ algorithm: 'RS256', expiresIn: '10m', keyid: ACTIVE_KID,
issuer: ISSUER, audience: AUDIENCE, jwtid: crypto.randomUUID() },
);
}
export async function bearerAuth(req, res, next) {
const claims = await verify(readBearer(req)); // signature, iss, aud, exp
// One cached lookup — far cheaper than a per-token denylist, and it is the
// check that makes password-change and suspension take effect immediately.
const version = await cache.userTokenVersion(claims.sub); // 5s TTL
if (claims.ver !== version) {
return res.status(401).json({ error: 'token_revoked' });
}
req.user = { id: claims.sub, scopes: String(claims.scope).split(' ') };
next();
}
// Everything below invalidates every outstanding token for that user:
await db.users.increment(userId, 'tokenVersion'); // password change
await db.users.increment(userId, 'tokenVersion'); // suspension
await db.users.increment(userId, 'tokenVersion'); // "sign out everywhere"Opaque tokens and RFC 7662 introspection
The 'active' field is the whole contract: a resource server never parses an opaque token, it only ever asks whether it is still good.
# The token is a random string. It says nothing; the issuer knows everything.
Authorization: Bearer 9f2c1d4a7e3b8c5d0a6f1e4b7c2d9a3f
# The resource server asks the authorization server about it
curl -X POST https://auth.dfg.com/oauth/introspect \
-u "$RS_CLIENT_ID:$RS_CLIENT_SECRET" \
-d "token=9f2c1d4a7e3b8c5d0a6f1e4b7c2d9a3f"
{
"active": true,
"sub": "42",
"scope": "orders:read",
"client_id": "partner-app",
"exp": 1754313000
}
# Revoked one second ago? Then:
{ "active": false }
# Trade-off: a network hop per request (cache it for a few seconds), in exchange
# for revocation that is genuinely immediate.
Discussion