JWTINTERMEDIATEAugust 30, 202612 min read

How to Revoke JWT Access Tokens in Multi-Device Applications

D
Deepak Thapa
Share:TwitterLinkedIn

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.

D
Written by Deepak Thapa
Enjoyed this article? Share it with fellow developers!
Share:TwitterLinkedIn