How to Revoke JWT Access Tokens Before They Expire
JWT authentication is easy to implement.
You generate an access token, generate a refresh token, send them to the client, and verify the access token on every protected API request.
Something like:
Login
↓
Access Token
↓
Refresh Token
It works perfectly until you implement a real logout flow.
A user logs in on their laptop, phone, and tablet. Then they log out from the phone.
The problem is:
The phone's access token may still be valid for another 10 minutes.
The JWT hasn't expired. Its signature is valid. Nothing about the token itself tells your server that the user has logged out.
So how do you revoke it?
The answer is:
Don't try to make JWT itself stateful. Instead, associate the JWT with a server-side session and revoke the session.
This article explains how to build that architecture for a multi-device application using Node.js, Express, MongoDB, and Redis.
The Problem With Stateless JWTs
Let's say we issue an access token that expires in 15 minutes.
At 10:00:
User logs in
↓
Access token issued
↓
Expires at 10:15
At 10:02:
User clicks Logout
But the JWT is still valid until 10:15.
If your middleware only does this:
const payload = jwt.verify(
token,
process.env.ACCESS_TOKEN_SECRET
);
the request will succeed.
The server knows:
Signature: valid
Expiration: valid
But the server does not know:
User logged out: yes
That information exists outside the JWT.
This is the fundamental limitation of purely stateless JWT authentication.
Why Deleting the Refresh Token Isn't Enough
A common implementation is:
Logout
↓
Delete refresh token
This does stop the client from obtaining a new access token.
But it does not invalidate the access token that has already been issued.
For example:
Access Token
expires in 15 minutes
Refresh Token
expires in 30 days
After logout:
Refresh Token → revoked
Access Token → still valid
The refresh token controls the ability to obtain new credentials.
The access token controls access to the API.
They solve different problems.
The Solution: Introduce Sessions
Instead of thinking about authentication as:
User
├── Access Token
└── Refresh Token
model it as:
User
├── Session A
│ ├── Access Token
│ └── Refresh Token
│
├── Session B
│ ├── Access Token
│ └── Refresh Token
│
└── Session C
├── Access Token
└── Refresh Token
Each login creates a new session.
For example:
Session A → Chrome / Windows
Session B → Safari / iPhone
Session C → Chrome / MacBook
Now logout becomes a session operation.
Logout from the iPhone:
Session A → active
Session B → revoked
Session C → active
Logout from all devices:
Session A → revoked
Session B → revoked
Session C → revoked
This is much easier to reason about.
Put a Session ID Inside the JWT
The access token should reference the session that created it.
For example:
{
"sub": "user_123",
"sessionId": "sess_9c8b2a",
"jti": "token_4e18",
"iat": 1788154200,
"exp": 1788155100
}
The most important claim is:
sessionId
Now the relationship looks like:
Access JWT
│
└── sessionId
│
▼
Server Session
│
active / revoked
The JWT remains stateless from a cryptographic perspective.
The session provides the state required for revocation.
Session Data Model
A MongoDB session document could look like this:
{
_id: ObjectId("..."),
sessionId: "sess_9c8b2a",
userId: ObjectId("..."),
refreshTokenHash: "...",
status: "active",
createdAt: ISODate("2026-08-31T10:00:00Z"),
expiresAt: ISODate("2026-09-30T10:00:00Z"),
lastActivityAt: ISODate("2026-08-31T12:30:00Z"),
device: {
name: "Chrome on Windows",
browser: "Chrome",
operatingSystem: "Windows"
}
}
You can also store:
ipAddress
userAgent
location
lastActivityAt
deviceId
revokedAt
Be careful about retaining IP/location data longer than necessary. Store only what your application actually needs.
Login Flow
Let's walk through the entire lifecycle.
Step 1: Authenticate the user
const user = await User.findOne({ email });
Verify the password:
const valid = await bcrypt.compare(
password,
user.password
);
Step 2: Create a session
Generate a unique session ID:
import crypto from "crypto";
const sessionId = crypto.randomUUID();
Create the refresh token:
const refreshToken = crypto.randomBytes(64).toString("hex");
Store only its hash:
const refreshTokenHash = hashToken(refreshToken);
Create the session:
await Session.create({
sessionId,
userId: user._id,
refreshTokenHash,
status: "active",
createdAt: new Date(),
expiresAt: addDays(new Date(), 30)
});
Step 3: Generate the Access Token
Create a short-lived access token:
const accessToken = jwt.sign(
{
sub: user._id.toString(),
sessionId,
jti: crypto.randomUUID()
},
process.env.ACCESS_TOKEN_SECRET,
{
expiresIn: "15m"
}
);
Return:
res.json({
accessToken,
refreshToken
});
Now both tokens belong to the same session.
Request Authentication Flow
Every protected API request now follows two steps.
Step 1: Verify the JWT
const payload = jwt.verify(
token,
process.env.ACCESS_TOKEN_SECRET
);
This validates:
Signature
Expiration
JWT structure
Step 2: Check the session
Extract:
payload.sessionId
Then query the session:
const session = await Session.findOne({
sessionId: payload.sessionId,
status: "active"
});
If the session does not exist:
return res.status(401).json({
message: "Session revoked"
});
This is the missing piece in a purely stateless JWT system.
Express Authentication Middleware
A simplified middleware looks like this:
async function authenticate(req, res, next) {
try {
const token = extractAccessToken(req);
if (!token) {
return res.status(401).json({
message: "Access token required"
});
}
const payload = jwt.verify(
token,
process.env.ACCESS_TOKEN_SECRET
);
const session = await Session.findOne({
sessionId: payload.sessionId,
status: "active"
});
if (!session) {
return res.status(401).json({
message: "Session expired or revoked"
});
}
req.auth = {
userId: payload.sub,
sessionId: payload.sessionId,
jti: payload.jti
};
next();
} catch (error) {
return res.status(401).json({
message: "Unauthorized"
});
}
}
Now an access token can be:
Not expired
+
Valid signature
+
Session revoked
=
401 Unauthorized
That is exactly what we wanted.
Logout From One Device
Suppose the user has:
Session A → Laptop
Session B → iPhone
Session C → Tablet
The phone sends:
POST /auth/logout
The API knows the current session:
req.auth.sessionId
Revoke it:
await Session.updateOne(
{
sessionId: req.auth.sessionId
},
{
$set: {
status: "revoked",
revokedAt: new Date()
}
}
);
Now:
Laptop → active
iPhone → revoked
Tablet → active
The next request from the phone will fail.
The access token does not need to be modified.
Logout From All Devices
For a global logout:
POST /auth/logout-all
Revoke every session:
await Session.updateMany(
{
userId: req.auth.userId,
status: "active"
},
{
$set: {
status: "revoked",
revokedAt: new Date()
}
}
);
Now every access token associated with those sessions becomes unusable.
Why Redis Can Make This Faster
Checking MongoDB on every authenticated request works, but authentication sits directly on your request hot path.
If your application receives thousands of requests per second, constantly querying MongoDB for session state isn't ideal.
Redis is a good fit for this kind of short-lived authorization state.
For example:
session:sess_9c8b2a = active
The request flow becomes:
Request
↓
JWT verification
↓
Redis session lookup
↓
Active?
├── YES → continue
└── NO → 401
The database can remain the durable source of truth while Redis acts as the fast authorization layer.
Redis Session Example
When the user logs in:
await redis.set(
`session:${sessionId}`,
"active",
{
EX: 60 * 60 * 24 * 30
}
);
When the user logs out:
await redis.del(
`session:${sessionId}`
);
Then middleware:
const session = await redis.get(
`session:${payload.sessionId}`
);
if (!session) {
return res.status(401).json({
message: "Session revoked"
});
}
A revoked session now becomes invisible to the authorization layer almost immediately.
MongoDB + Redis: Who Is the Source of Truth?
A practical architecture is:
MongoDB
↓
Persistent session state
Redis
↓
Fast authorization state
For example:
MongoDB
sessionId = sess_123
status = active
Redis:
session:sess_123 = active
On logout:
MongoDB → status = revoked
Redis → key deleted
This gives you persistence plus speed.
Refresh Token Rotation
Access-token revocation isn't enough.
Refresh tokens are usually much longer-lived, so you should rotate them.
Instead of keeping:
Refresh Token A
Refresh Token A
Refresh Token A
indefinitely, rotate:
Refresh Token A
↓
refresh
↓
Refresh Token B
↓
refresh
↓
Refresh Token C
When A is used:
A → invalid
B → active
This limits replay opportunities.
Store Refresh Tokens as Hashes
Never store the raw refresh token when you can avoid it.
Instead:
const refreshTokenHash = createHash(
refreshToken
);
Store:
refreshTokenHash
When the client sends a refresh token:
const hash = createHash(refreshToken);
Then compare against the stored value.
This means the database does not contain a directly usable refresh credential.
Refresh Endpoint
A simplified refresh endpoint might look like:
async function refresh(req, res) {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({
message: "Refresh token required"
});
}
const tokenHash = hashToken(refreshToken);
const session = await Session.findOne({
refreshTokenHash: tokenHash,
status: "active"
});
if (!session) {
return res.status(401).json({
message: "Invalid refresh token"
});
}
const newRefreshToken =
crypto.randomBytes(64).toString("hex");
const newRefreshTokenHash =
hashToken(newRefreshToken);
await Session.updateOne(
{ _id: session._id },
{
$set: {
refreshTokenHash: newRefreshTokenHash
}
}
);
const accessToken = jwt.sign(
{
sub: session.userId.toString(),
sessionId: session.sessionId,
jti: crypto.randomUUID()
},
process.env.ACCESS_TOKEN_SECRET,
{
expiresIn: "15m"
}
);
return res.json({
accessToken,
refreshToken: newRefreshToken
});
}
In production, make the rotation transaction-safe and handle concurrent refresh attempts carefully.
Refresh Token Replay Detection
Consider this flow:
Token A
↓
refresh
↓
Token B
Token A should now be invalid.
If Token A appears again:
Token A
↓
already used
↓
possible replay
A strong implementation can revoke the entire session:
await Session.updateOne(
{
sessionId
},
{
$set: {
status: "revoked",
revokedAt: new Date()
}
}
);
This protects the session if a stolen refresh token is reused.
What About jti?
JWT supports a unique token identifier:
{
"jti": "token_123"
}
You can maintain a blacklist:
revoked:token_123
This gives you token-level revocation.
For example:
Session A
├── Access Token 1
├── Access Token 2
└── Access Token 3
You could revoke only:
Access Token 2
using its jti.
But this adds complexity.
For most applications, session-level revocation is enough:
sessionId → primary revocation mechanism
jti → optional fine-grained control
Session Revocation vs JWT Blacklisting
There are two common strategies.
JWT Blacklist
JWT
↓
jti
↓
Redis blacklist
Good when:
- individual token revocation is important
- tokens have longer lifetimes
- you need token-specific controls
Downside:
- every token needs revocation state
- blacklist cleanup becomes important
- more state to manage
Session Revocation
JWT
↓
sessionId
↓
Session
↓
active / revoked
Good when:
- users can have multiple devices
- you need "logout this device"
- you need "logout everywhere"
- refresh tokens are tied to sessions
For most multi-device applications, this is the cleaner abstraction.
Token Versioning for Global Logout
Another approach is to store:
user.tokenVersion = 5
Include it in the JWT:
{
"sub": "user_123",
"tokenVersion": 5
}
When the user logs out everywhere:
await User.updateOne(
{ _id: userId },
{
$inc: {
tokenVersion: 1
}
}
);
Now the database contains:
tokenVersion = 6
All tokens containing version 5 are rejected.
This is excellent as a global invalidation mechanism.
However, it doesn't naturally distinguish devices.
That's why a useful architecture can be:
sessionId
+
optional tokenVersion
A Production Architecture
For a Node.js application, the overall system can look like this:
┌──────────────────┐
│ Client │
└────────┬─────────┘
│
Access Token
│
▼
┌──────────────────┐
│ Express API │
└────────┬─────────┘
│
Verify JWT
│
▼
┌──────────────────┐
│ Redis │
│ Session Lookup │
└────────┬─────────┘
│
Session active?
/ \
YES NO
│ │
▼ ▼
Continue 401
│
▼
Application
Behind Redis:
MongoDB
│
├── users
│
└── sessions
Recommended Token Lifetimes
A practical starting point is:
Access Token
5–15 minutes
Refresh Token
7–30 days
The exact values depend on your threat model and product requirements.
The principle is:
Access Token
→ short-lived
Refresh Token
→ longer-lived but revocable
Session
→ server-controlled
Short-lived access tokens reduce the impact of token theft.
Server-side session revocation provides immediate logout.
Refresh-token rotation protects the long-lived credential.
Together they provide much stronger control than any one mechanism alone.
Complete Flow
Login
User
↓
Validate credentials
↓
Create session
↓
Create refresh token
↓
Hash refresh token
↓
Store session
↓
Create access token
↓
Add sessionId to JWT
↓
Return tokens
API Request
Access Token
↓
Verify JWT
↓
Extract sessionId
↓
Check Redis
↓
Session active?
├── Yes → continue
└── No → 401
Logout One Device
sessionId
↓
Revoke session
↓
Delete refresh token / invalidate token
↓
Access token becomes unusable
Logout All Devices
userId
↓
Revoke all sessions
↓
Invalidate all refresh tokens
↓
All existing access tokens fail
Refresh
Refresh Token
↓
Validate
↓
Find session
↓
Rotate refresh token
↓
Generate new access token
↓
Return new credentials
Common Mistakes
1. Only deleting the refresh token
This does not invalidate existing access tokens.
Refresh Token → revoked
Access Token → still valid
2. Making access tokens extremely long-lived
For example:
Access Token = 30 days
If the token is compromised, you now have a very large attack window.
Use short-lived access tokens.
3. Storing raw refresh tokens
If your database leaks, attackers may immediately gain usable credentials.
Store hashes instead.
4. Using only a user-level logout flag
A single:
user.isLoggedOut = true
doesn't work well with multiple devices.
You lose the ability to distinguish:
Logout iPhone
from:
Logout everywhere
A session model solves this cleanly.
5. Treating JWT as the entire authentication system
JWT is only the credential format.
Your authentication system also needs:
Sessions
Refresh token lifecycle
Revocation
Rotation
Expiration
Credential storage
Device management
The JWT is one part of the system, not the whole system.
Final Architecture
For most modern multi-device applications, a robust setup looks like this:
Authentication
│
┌──────────────┴──────────────┐
│ │
Access Token Refresh Token
JWT Rotating
│ │
│ │
5–15 minutes 7–30 days
│ │
└──────────────┬────────────────┘
│
Session
│
sessionId
│
Redis / DB
│
active / revoked
The main idea is simple:
JWT expiration tells you when a token naturally becomes invalid. Session revocation tells you when a valid token should stop working now.
Once you separate those two concepts, multi-device authentication becomes much easier to design.
You can support:
✓ Multiple devices
✓ Logout one device
✓ Logout all devices
✓ Immediate invalidation
✓ Short-lived access tokens
✓ Rotating refresh tokens
✓ Refresh-token replay detection
✓ Device/session management
✓ Fast revocation checks
And the most important rule to remember is:
Do not try to revoke a JWT by changing the JWT.
Revoke the session that the JWT belongs to.
That is the architectural pattern that makes stateless JWT credentials work well with stateful logout requirements.