security
Revoking a token that's stateless by design
Revoking a token that's stateless by design
The appeal of a JWT is that validating it needs no database hit — the signature is the proof. The catch is the same sentence: once I've signed one, I can't take it back. If someone changes their password, gets locked out, or an admin force-logs-them-out, a plain JWT setup does exactly nothing. The token stays cryptographically valid until it expires on its own.
I found this the unglamorous way. An account that should have been logged out kept working. I only noticed because I put the frontend's belief about the auth state next to the backend's request logs and saw them disagree — the app thought the account was logged out, the backend was still honouring the token, because nothing anywhere was checking whether the token should still be trusted, only whether the signature was valid.
The options I weighed
- A Redis blacklist of revoked token IDs. Works, but it grows with every logout and lockout, and now I'm managing TTLs so it doesn't either grow forever or expire early.
- Short-lived access tokens with refresh rotation. Good hygiene, doesn't solve instant revocation — a still-valid access token is usable for its whole lifetime.
- A version counter on the user row. What I went with.
// on login / token issue
const token = await signJwt({ sub: user.id, tv: user.token_version });
// on every request, in the auth guard
const payload = await verifyJwt(token);
const currentUser = await db.users.findUnique({ where: { id: payload.sub } });
if (currentUser.token_version !== payload.tv) {
throw new UnauthorizedException("Session no longer valid");
}
To kill every session a user holds — anywhere, all at once — I increment one integer:
await db.users.update({
where: { id: userId },
data: { token_version: { increment: 1 } },
});
No unbounded growth, no TTL bookkeeping, no separate revocation store to keep in sync. The price is a database read per authenticated request — or a cached read through Redis, invalidated when the counter bumps — which is a bounded, predictable cost instead of a growing list.
The lesson that stuck
Decide the "how do we force-logout someone right now" story before writing the login endpoint, not after finding the gap in testing. It belongs on day one of any auth system, same as password reset or rate limiting. By the time you notice it's missing, it's usually because something already walked through it.