By AndyPublished
JWT Logout and Revocation: How to Invalidate a Token Before It Expires
exp passes, because verification checks only the signature and the claims, not a database. So JWT logout is really two jobs: delete the tokens on the client, and make the server refuse any copy that is still around. The standard answer is to keep access tokens short-lived (5 to 15 minutes), revoke the refresh token on logout, and, where you need a token dead immediately, add a small server-side check: a denylist keyed by jti, or a per-user token version. Each option adds some state back, which is the honest cost of revocation.Why You Can't Simply Invalidate a JWT
A session ID is a pointer: delete the row and the ID means nothing. A JWT is a self-contained claim set signed by the issuer (RFC 7519). The verifier recomputes the signature, checks exp, iss and aud, and never asks anyone whether the token is still wanted. That is the point of JWTs, and it is also why "how do I invalidate a JWT" has no answer that keeps them fully stateless. The JWT vs session tokens comparison covers the design trade-off in more depth.
In practice the question is how long a leaked or logged-out token may stay usable, and what you are willing to pay to shorten that window. The table below sets out the options.
JWT Revocation Strategies Compared
| Strategy | What it revokes | Cost | Best for |
|---|---|---|---|
| Delete token on the client | Nothing server-side; a copied token still works | None | Always do it, never rely on it alone |
| Short access token + revocable refresh token | Future refreshes immediately; access token within minutes | A refresh-token store | The default for most apps |
| jti denylist (Redis) | One specific token, immediately | One cache lookup per request | Logout of a single session, stolen token |
| Token version per user | Every token for a user, immediately | One lookup per request (cacheable) | Password change, "log out everywhere", account lock |
| Rotate the signing key | Every token for every user | Everyone logs in again | Key compromise only |
| Opaque tokens + introspection (RFC 7662) | Any token, immediately | A call to the auth server per request (cacheable) | High-assurance APIs |
Most applications combine the first three: client deletion always, short access tokens with revocable refresh tokens as the baseline, and a denylist or token version for the cases where minutes matter.
JWT Logout Best Practices: Short Access Tokens, Revocable Refresh Tokens
The cheapest form of revocation is a short lifetime. If access tokens live for ten minutes, the worst case after logout is ten minutes of use by someone who copied the token. The user stays signed in through a refresh token, which is long-lived but is stored server-side and checked every time it is used, so it can be revoked like a session. The refresh token pattern guide covers rotation and reuse detection; for logout, the rules are:
- ·Store refresh tokens (or a hash of them) with the user, device and expiry. An opaque random string is fine; it does not need to be a JWT.
- ·On logout, delete or mark revoked the refresh token for that session. The next refresh attempt fails and the user is signed out once the access token expires.
- ·For "log out of all devices", revoke every refresh token belonging to the user.
- ·Keep access-token lifetimes short enough that the remaining window is acceptable. Choosing the value is covered in JWT expiration time.
JWT Revocation List: a jti Denylist in Redis
To kill one specific access token immediately, give every token a unique jti(JWT ID, RFC 7519 §4.1.7) and, on logout, write that ID to a fast shared store. Every request verifies the token as usual and then checks the store. The trick that keeps the list small: set the entry's TTL to the token's remaining lifetime. Once the token would have expired anyway, the entry is no longer needed and Redis deletes it.
// npm install jose redis (Node 18+; run as an ES module)
import { createClient } from 'redis';
import { SignJWT, jwtVerify } from 'jose';
import { randomUUID } from 'node:crypto';
const redis = createClient({ url: process.env.REDIS_URL ?? 'redis://localhost:6379' });
await redis.connect();
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
// Issue a short-lived access token with a unique jti
async function issue(userId) {
return new SignJWT({})
.setProtectedHeader({ alg: 'HS256' })
.setSubject(userId)
.setJti(randomUUID())
.setIssuedAt()
.setExpirationTime('15m')
.sign(secret);
}
// Logout: deny this jti only for as long as the token could still be used
async function revoke(token) {
const { payload } = await jwtVerify(token, secret, { algorithms: ['HS256'] });
const ttl = payload.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) await redis.set(`deny:${payload.jti}`, '1', { EX: ttl });
}
// Every request: verify signature and claims first, then check the denylist
async function authenticate(token) {
const { payload } = await jwtVerify(token, secret, { algorithms: ['HS256'] });
if (await redis.exists(`deny:${payload.jti}`)) throw new Error('token revoked');
return payload;
}
const t = await issue('user-42');
console.log('before logout:', (await authenticate(t)).sub); // user-42
await revoke(t);
await authenticate(t).catch((e) => console.log('after logout:', e.message)); // token revoked
await redis.quit();Two details matter. Verify before checking the denylist, so unauthenticated requests cannot be used to probe or fill the store. And decide what happens when Redis is unavailable: failing closed (reject every request) is safer, failing open keeps the service up but briefly honours revoked tokens. Pick deliberately and document it.
The honest trade-off
A denylist reintroduces a lookup on every request, which is exactly what JWTs were meant to avoid. It is still much cheaper than a full session store: the list only contains tokens revoked in the last access-token lifetime, which is usually a few hundred entries, not one per active user. If every service in a large system would need the lookup, consider whether opaque tokens with introspection (RFC 7662) at the gateway are simpler; see JWT in microservices.
How to Invalidate All of a User's Tokens: Token Versioning
A jti denylist revokes one token. After a password reset or a "log out everywhere" click you want every token the user holds to die, including ones you never saw. The pattern: keep an integer tokenVersion on the user record, copy it into each token as a private claim (for example tv), and reject tokens whose tv is lower than the current value. Incrementing the counter revokes them all at once.
// At sign-in
const token = await new SignJWT({ tv: user.tokenVersion })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(user.id)
.setExpirationTime('15m')
.sign(secret);
// On each request, after jwtVerify()
const current = await getTokenVersion(payload.sub); // cache this for a few seconds
if (payload.tv !== current) throw new Error('token revoked');
// On password change or "log out everywhere"
await db.user.update({ where: { id }, data: { tokenVersion: { increment: 1 } } });A variant uses a timestamp instead of a counter: store tokensValidAfter on the user and reject any token whose iat is earlier. Both are one lookup per user rather than per token, and both cache well because the value changes rarely.
RFC 7009: the OAuth Token Revocation Endpoint
If you use an OAuth 2.0 or OpenID Connect provider, logout should call its revocation endpoint, defined in RFC 7009. The client POSTs the refresh token (and authenticates itself if it is a confidential client); the authorisation server invalidates it and, depending on the server, the access tokens issued from it.
curl -X POST https://auth.example.com/oauth/revoke \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -d "token=$REFRESH_TOKEN" \ -d "token_type_hint=refresh_token"
The server returns HTTP 200 even if the token was already invalid, so the client cannot use the endpoint to test tokens. Note the limit: revoking a refresh token at the provider does nothing to a JWT access token that your API verifies locally. That token remains valid until it expires unless your API also checks a denylist or introspects. The endpoint URL is published as revocation_endpoint in the provider's discovery or metadata document; see JWTs in OAuth 2.0 and OpenID Connect.
JWT Logout Flow, Step by Step
- ·1. Client calls your logout endpoint with the refresh token (usually in an HttpOnly cookie) and, if you use a denylist, the current access token.
- ·2. Server revokes the refresh token in its own store, or via the provider's RFC 7009 endpoint.
- ·3. Server optionally denylists the access token's
jtiwith a TTL equal to its remaining lifetime. - ·4. Server clears cookies by sending
Set-Cookiewith the same name, path and domain andMax-Age=0. A mismatched path or domain leaves the original cookie in place. - ·5. Client drops in-memory tokens and removes anything it put in
localStorageorsessionStorage; see where to store a JWT. - ·6. With an OIDC provider, end the provider session too, otherwise the next login redirect silently signs the user straight back in.
OpenID Connect logout
OpenID Connect defines three logout specifications. RP-Initiated Logout 1.0 redirects the browser to the provider's end_session_endpoint, usually with id_token_hint and post_logout_redirect_uri, to end the provider's session. Back-Channel Logout 1.0 has the provider POST a signed logout token (a JWT with an events claim and a sidor sub) directly to each application's server, so apps can end their own sessions when the user logs out elsewhere. Front-Channel Logout 1.0 does the same via hidden iframes in the browser, which third-party cookie restrictions have made unreliable. For server-side apps, back-channel logout is the robust option.
JWT Logout in Spring Boot, Node.js, Django and C#
The strategy is the same everywhere; only the hook where you add the revocation check differs.
- ·Node.js (Express): a middleware after token verification that checks the denylist, as in the example above. Full setup in JWT authentication in Node.js and Express.
- ·Spring Boot: with the OAuth2 resource server, add a custom
OAuth2TokenValidator<Jwt>to yourJwtDecoderthat checks thejtior token version. See JWT in Spring Boot. - ·Django: djangorestframework-simplejwt ships a
token_blacklistapp for refresh tokens: addrest_framework_simplejwt.token_blacklisttoINSTALLED_APPS, run migrations, and callRefreshToken(token).blacklist()on logout. Access tokens still live until expiry. See JWT in Django. - ·C# / ASP.NET Core: in
JwtBearerOptions.Events.OnTokenValidated, look up thejtiand callcontext.Fail(...)if it is revoked. See JWT in ASP.NET Core.
Testing That Logout Actually Works
Copy the access token from DevTools before logging out, log out, then replay it with curl. If the API still answers 200, your logout only cleared the client. Check exp in the jwtdecode.app decoder (it runs locally in the browser) to see exactly how long that copy stays usable without server-side revocation. Repeat with the refresh token against your refresh endpoint; that one must fail immediately.
Summary
You cannot revoke a stateless JWT; you can only shorten how long it matters or add state. Keep access tokens short, store and revoke refresh tokens on logout, clear cookies with matching attributes, and end the identity provider's session when you use OIDC. When a token must die immediately, deny its jti in Redis for its remaining lifetime, or bump a per-user token version to revoke everything the user holds. Each step adds a lookup, so add only the ones your threat model needs.