Your JWT Is Stateless… So How Do You Log Out One Device Without Logging Out Every Device?
A production design for single-device JWT logout using short-lived access tokens, rotating refresh tokens, session records, JTI revocation, and Redis.
7 min read
One User, Three Devices, One Logout Button
A user is signed in on:
Laptop
Personal phone
Work tabletThey tap Log out on the work tablet.
If the API uses only a self-contained JWT, there is nothing to delete from the server. The token remains cryptographically valid until it expires.
You could rotate the user’s signing version and invalidate every token—but that logs out all three devices.
The requirement is narrower:
Revoke work tablet
Keep laptop active
Keep personal phone activeStateless authentication and selective revocation pull in opposite directions. The usual solution is to keep access tokens short-lived and make refresh sessions stateful per device.
Separate Access From Session
Use two credentials with different jobs.
| Credential | Lifetime | Stored where | Purpose |
|---|---|---|---|
| Access token | Short | Client memory | Authenticate API requests |
| Refresh token | Longer | Secure HTTP-only cookie | Obtain a new access token |
The access token can contain:
{
"sub": "user_42",
"sid": "session_phone_8d2",
"jti": "access_18f",
"iat": 1789632000,
"exp": 1789632900
}sid identifies the device session. jti uniquely identifies this particular token.
The server stores the refresh session:
CREATE TABLE auth_sessions (
id text PRIMARY KEY,
user_id text NOT NULL,
refresh_token_hash text NOT NULL,
device_name text,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
replaced_by_session_id text
);
CREATE INDEX auth_sessions_user_id_idx
ON auth_sessions (user_id);Store a hash of the refresh token, not the raw credential.
Login Creates a Device Session
Credentials verified
|
v
Create session row for this device
|
+--> issue short-lived access JWT with sid
|
+--> issue random refresh token
|
v
store only token hashA second device gets a different session ID and refresh token. That separation is what makes one-device logout possible.
Logout Revokes One Session
The refresh token identifies the current device session:
app.post("/auth/logout", async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
const tokenHash = hashRefreshToken(refreshToken);
await db.authSession.updateMany({
where: { refreshTokenHash: tokenHash, revokedAt: null },
data: { revokedAt: new Date() },
});
}
res.clearCookie("refreshToken");
res.status(204).end();
});After logout:
- That refresh token cannot create new access tokens.
- Other device sessions remain active.
- The current access token may remain valid until its short expiry.
That final point is a deliberate trade-off, not a bug.
Do You Need Immediate Access-Token Revocation?
For many systems, a 5–15 minute access-token lifetime is enough. Logout deletes the client token and revokes refresh. The maximum remaining server-side validity is short.
Higher-risk systems may require immediate revocation.
Deny List by JTI
On logout, store the access token’s jti in Redis until its natural expiry:
SET revoked:jti:access_18f 1 EX 720Authentication middleware checks Redis:
const payload = verifyAccessToken(token);
const revoked = await redis.exists(`revoked:jti:${payload.jti}`);
if (revoked) throw new UnauthorizedError("Token revoked");This provides immediate revocation, but every authenticated request now depends on Redis. The access token is no longer fully stateless operationally.
Revoke by Session ID
Instead of storing every access-token JTI, store revoked session IDs:
SET revoked:sid:session_phone_8d2 1 EX 900All access tokens associated with that device session become invalid. Keep the key only as long as the longest possible access-token lifetime.
This is usually more compact than revoking individual access tokens.
Refresh-Token Rotation
Each refresh should replace the old refresh token.
refresh token R1
|
v
validate R1 and session
|
v
issue access A2 + refresh R2
|
v
invalidate R1The validation and replacement must be atomic. Otherwise two concurrent uses of R1 can both succeed.
One pattern is compare-and-swap:
UPDATE auth_sessions
SET refresh_token_hash = $1
WHERE id = $2
AND refresh_token_hash = $3
AND revoked_at IS NULL
AND expires_at > now()
RETURNING id;If no row returns, the presented token is old, revoked, expired, or already rotated.
Detect Refresh-Token Reuse
Suppose an attacker steals R1. The legitimate client rotates to R2. Later the attacker presents R1.
That old token should not merely fail. It may indicate theft.
Possible response:
Old refresh token reused
|
v
Revoke the affected session family
|
v
Require login on that device
|
v
Record security eventDo not automatically log out every device unless your threat model requires account-wide containment. Session-family revocation preserves device isolation.
“Log Out Everywhere” Is a Different Operation
One-device logout:
UPDATE auth_sessions
SET revoked_at = now()
WHERE id = $1;Account-wide logout:
UPDATE auth_sessions
SET revoked_at = now()
WHERE user_id = $1
AND revoked_at IS NULL;Keep these endpoints and audit events distinct. Users should understand which action they are taking.
Diagnose Authentication Incidents
Track:
active_sessions_per_user
refresh_success_total
refresh_failure_total by reason
refresh_reuse_total
revoked_access_token_hits_total
session_logout_totalLog session ID, user ID, token family, and device metadata—but never raw tokens.
Common failure signals include:
- Many refreshes for one session in a short window
- Refresh-token reuse after rotation
- Access tokens with missing or unknown
sid - Session lookup latency affecting every API request
- Redis failure causing authentication failure or accidental bypass
Define fail-open versus fail-closed behavior explicitly. For security revocation checks, silently accepting tokens because Redis is down may violate the requirement.
Trade-offs
| Design | Benefit | Cost |
|---|---|---|
| Short access token only | Simple request verification | Logout is not immediately enforced server-side |
| JTI deny list | Precise immediate revocation | Redis lookup and one key per revoked token |
| Session deny list | Revokes one device efficiently | Redis lookup on protected requests |
| Database session lookup | Durable source of truth | More latency and database load |
| Opaque access tokens | Easy revocation | Every request requires authorization-server state |
JWT is a format, not a mandate that every part of authentication remain stateless.
Production Best Practices
- Use a separate refresh session for every device.
- Keep access tokens short-lived.
- Store refresh tokens in secure, HTTP-only cookies where appropriate.
- Store only refresh-token hashes server-side.
- Rotate refresh tokens atomically.
- Detect reuse of replaced tokens.
- Put
sidand uniquejticlaims in access tokens. - Use bounded Redis revocation entries only when immediate logout is required.
- Keep logout-one-device and logout-everywhere separate.
- Never log raw access or refresh tokens.
Conclusion
Selective JWT logout is not achieved by changing JWT cryptography. It comes from modeling each device as its own revocable session.
Short-lived access JWT
+
Per-device refresh session
+
Rotation and reuse detection
+
Optional bounded revocation cacheThat design preserves fast token verification while providing the state required for real logout, device management, and incident response.
This session model belongs inside the broader authentication and authorization contract described in the production-ready REST API guide.
References
Related
Written by
Faisal
Software engineer writing about backend systems, Node.js, system design, scalable applications, and modern web and mobile development.