Token Revocation
The revocation problem
Refresh tokens are revocable because they live in a server-side store. Delete the record, and the token is gone.
Access tokens are not revocable by default. They are stateless JWTs. The server verifies the signature and checks the expiration — it does not look up anything. With 15-minute tokens (from the previous lesson), the exposure window is small, but sometimes you need to revoke an access token immediately: a user reports their account compromised, an admin bans a user, or a password changes.
The access token deny list
A deny list is a set of token identifiers that the server checks on every request. If the token’s ID is in the deny list, it is rejected even if the signature and expiration are valid.
Add a token ID to your JWTs
First, include a unique ID (jti — JWT ID) in every access token. Update src/jwt.ts:
export async function createToken(payload: {
userId: string;
email: string;
role: string;
}): Promise<string> {
const jti = crypto.randomUUID();
const token = await new SignJWT({ ...payload, jti })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(secret);
return token;
} The jti claim gives each token a unique identifier.
The deny list
Create src/token-deny-list.ts:
// src/token-deny-list.ts
interface DenyEntry {
expiresAt: number;
}
const denyList = new Map<string, DenyEntry>();
export function denyToken(jti: string, expiresAt: number): void {
denyList.set(jti, { expiresAt });
}
export function isDenied(jti: string): boolean {
return denyList.has(jti);
}
// Clean up expired entries every 5 minutes
setInterval(
() => {
const now = Date.now();
for (const [jti, entry] of denyList) {
if (now > entry.expiresAt) {
denyList.delete(jti);
}
}
},
5 * 60 * 1000,
); When we deny a token, we store its jti along with its expiration time. After the token would have expired naturally, we remove it from the deny list (it is no longer dangerous).
Check the deny list during authentication
Update verifyToken in src/jwt.ts:
import { isDenied } from "./token-deny-list.js";
export async function verifyToken(
token: string,
): Promise<{ userId: string; email: string; role: string; jti: string } | null> {
try {
const { payload } = await jwtVerify(token, secret);
if (
typeof payload.userId !== "string" ||
typeof payload.email !== "string" ||
typeof payload.role !== "string" ||
typeof payload.jti !== "string"
) {
return null;
}
// Check the deny list
if (isDenied(payload.jti)) {
return null;
}
return {
userId: payload.userId,
email: payload.email,
role: payload.role,
jti: payload.jti,
};
} catch {
return null;
}
} Now every token verification checks the deny list. If the token’s jti has been denied, authentication fails.
Revoking on password change
When a user changes their password, revoke all their tokens and sessions:
import { denyToken } from "./token-deny-list.js";
import { revokeUserTokens } from "./refresh-tokens.js";
import { deleteUserSessions } from "./sessions.js";
function revokeAllUserAuth(userId: string): void {
// Revoke refresh tokens (server-side, immediate)
revokeUserTokens(userId);
// Delete sessions (server-side, immediate)
deleteUserSessions(userId);
// For access tokens: we cannot enumerate active JWTs
// They expire in 15 minutes. This is the accepted tradeoff.
} Notice we cannot deny all active access tokens for a user because we do not track which tokens belong to which user. The access tokens expire in 15 minutes, which is the accepted tradeoff of short-lived tokens. For truly immediate revocation, you would need to track all issued jti values per user (which adds server-side state).
When to deny individual tokens
Individual token denial is useful when:
- An admin bans a specific user mid-session (deny their current token’s
jti) - A user explicitly logs out of a specific device (the logout route can deny the token)
- Suspicious activity is detected for a specific request
The cost of deny lists
Checking the deny list adds a Map lookup to every authenticated request. This is fast (O(1) for a Map), but it reintroduces server-side state, which was the advantage of stateless JWTs.
In practice, this is an acceptable tradeoff. The deny list is small (only recently revoked tokens), lookups are fast, and the security benefit is significant. Most production systems that use JWTs have some form of deny list.
Exercises
Exercise 1: Add the jti claim to your access tokens. Log in, decode the token, and verify the jti field is present.
Exercise 2: Manually deny a token’s jti and verify the next request with that token returns 401 even though the signature and expiration are still valid.
Exercise 3: What happens to the deny list if the server restarts? (It is lost — all denied tokens become valid again.) For the 15-minute window of access tokens, this is a minor risk. In production, use Redis for the deny list so it survives restarts.
Why do deny list entries include an expiration time?