Back to Blog Security

A Hacker Steals Your JWT — You Log Out, But They're Still In. Here's Why.

Jul 8, 2026 10 min read Srikanth Badavath

Reading…
 JWT · Stateless Auth · Security
You logged out. The hacker didn't.
Every JWT carries its own expiry. When you log out, only your browser deletes the token — the server has no memory of the logout event. If a hacker already has your JWT, they keep using it until it naturally expires.
Live token stream — these tokens are structurally valid

1 The Interview Puzzle

 Interviewer asks
A hacker intercepts your JWT token. You immediately click Logout. But the hacker can still access your account. How do you solve this?

Most people's instinct is: "just invalidate the token on logout." But a JWT is self-contained and stateless — there is nothing to invalidate unless you add a server-side mechanism. Understanding why is the key to giving a strong answer.

2 What Is a JWT?

A JSON Web Token is a compact, URL-safe string divided into three Base64-encoded parts separated by dots. Here is a real one — hover over each colored section to see what it contains:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI0MiIsImVtYWlsIjoic3JpQGV4YW1wbGUuY29tIiwiaWF0IjoxNzIwMzg0MDAwLCJleHAiOjE3MjAzODc2MDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Part 1
Header
Specifies the token type and signing algorithm. Always HS256 (HMAC-SHA256) or RS256 (RSA).
{ "alg": "HS256",
  "typ": "JWT" }
Part 2
Payload
The claims — who you are and when the token expires. Not encrypted, just Base64-encoded. Anyone can read it.
{ "userId": "42",
  "email": "sri@...",
  "exp": 1720387600 }
Part 3
Signature
HMAC of header + payload using a secret only the server knows. This is what proves the token was not tampered with.
HMAC-SHA256(
  header + "." + payload,
  SERVER_SECRET
)
The payload is public. Never put a password, credit card number, or anything sensitive in a JWT payload. Anyone who has the token can decode the payload with a single atob() call — no key needed. The signature only proves the token was not modified, not that the payload is secret.

3 The Stateless Problem

Traditional session-based auth stores a session ID in the server's memory (or a database). When you log out, the server deletes that session. Future requests with the old session ID fail immediately. JWTs work the opposite way — the server stores nothing.

Session-based auth
session:abc123 → user:42
session:xyz789 → REVOKED
session:def456 → user:17
Logout deletes the session — attacker's copy is instantly dead
JWT-based auth
(no session store)
(no logout list)
SECRET_KEY only
Logout deletes client cookie — server never knew, still accepts old tokens

The server only asks two questions when it receives a JWT: is the signature valid, and has the token expired? If both answers are yes/no respectively, access is granted — regardless of whether the user "logged out" on the client side.

4 The Attack, Step by Step

Here is exactly what happens when a hacker intercepts a JWT, and why logging out fails to stop them:

Attack timeline
Step 1
You log in. The server signs a JWT with your userId and an expiry 1 hour from now. It sends the token to your browser.
Step 2
On a coffee-shop WiFi, a hacker intercepts the token from an unencrypted request (or XSS, or a compromised extension). They now have a structurally valid JWT.
Step 3
You notice something suspicious and click Logout. Your browser deletes the token from localStorage. From your side, you are gone.
Step 4 — the gap
The server receives no notification of your logout. It has no "logout list." It never stored the token to begin with. Nothing changed on the server side.
Step 5 — the breach
The hacker sends a request with your stolen JWT. The server checks: signature valid? Yes. Expired? No. Access granted — for up to an hour.
Hacker is inside your account
Your logout did nothing. The token is still fully valid on the server.
This is not a theoretical risk. XSS attacks, browser extensions with excessive permissions, shared computers, compromised npm packages, and network interception are all real-world JWT theft vectors. The stateless model makes every stolen token a ticking time bomb with a known expiry window.

5 Four Solutions

There is no single canonical fix — production systems combine these approaches depending on their security requirements and performance constraints. Understand each so you can reason about the trade-offs.

Short-lived Access Tokens (5–15 minutes)
Drastically reduce the token expiry time. Even if a hacker steals a token, it is worthless in minutes — before they can typically use it. The window is small enough to limit the blast radius without requiring a logout mechanism.
Token lifetime
Valid
15:00
If stolen at any point, the attacker's window closes automatically without any server action.

Trade-off: Short-lived tokens force the user to log in again frequently, which destroys user experience. In practice you pair them with refresh tokens (see the next tab) — the access token is short-lived for security, and the refresh token is long-lived for convenience.

Refresh Tokens
Issue two tokens on login: a short-lived access token (5–15 min) for API calls, and a long-lived refresh token (days or weeks) stored in the database. When the access token expires, the client presents the refresh token to get a new pair. On logout, the refresh token is deleted from the DB — so no new access tokens can be issued.
Client: "Access token expired. Here is my refresh token."
Server: Checking refresh token in database...
DB: Found and valid. Issuing new access token + rotating refresh token.
Server: New access token (15 min) + new refresh token returned.
On logout: Refresh token deleted from DB. Attacker cannot get new access tokens.
Token Blacklisting
On logout, store the JWT's unique ID (jti claim) in a fast store like Redis with a TTL matching the token's original expiry. Every request checks this blacklist. If the jti is in Redis, reject the request — even if the signature is valid and the token has not expired.
Redis blacklist
// No entries yet

Trade-off: Every request now hits Redis — you have reintroduced a network round-trip. For high-traffic systems, Redis is fast enough (sub-millisecond) to make this acceptable. The blacklist entries auto-expire via Redis TTL, so storage does not grow unbounded.

Token Versioning
Store a tokenVersion integer in the user's database row. Embed the current version in the JWT at issuance time. On every request, the server fetches the user's current version and compares it with the version inside the token. On logout (or password change), increment the version — all existing tokens with an older version are instantly invalid.
Token (issued before logout)
userId42
tokenVersion3
exp1h from now
Database (current)
userId42
tokenVersion3
emailsri@...
Versions match — access granted

6 What Production Systems Actually Do

In real production systems, the most common approach combines all three concepts into a single coherent strategy:

The production standard
Access Token (5–15 min)
+
Refresh Token (7–30 days)
+
Refresh Token Revocation
Stateless for most requests
Access tokens are verified by signature alone — no DB hit needed on every API call.
Logout actually works
Deleting the refresh token from the DB means no new access tokens can be minted — session truly ends.
Rotation on every refresh
Each refresh issues a new refresh token and invalidates the old one. If a refresh token is stolen, a single use flags it — both sessions see the anomaly.

This is exactly what you see in major auth libraries: Auth0, Firebase Auth, Supabase, and NextAuth all implement this pattern. The access token is a short-lived, stateless JWT. The refresh token is a long-lived, database-backed credential that gives you the revocation hook.

7 Solution Comparison

Here is how the four approaches compare across the dimensions that matter in production:

Solution 1
Short-lived Tokens
Theft window is small. No DB hit needed. But users must re-authenticate frequently — unacceptable alone. Always paired with refresh tokens.
Solution 2
Refresh Tokens
Logout actually invalidates session. One DB write on logout, one DB read on refresh. The industry standard pattern for scalable, revocable JWTs.
Solution 3
Token Blacklisting
Immediate revocation of any specific token. Costs one Redis read per request. Good for "log out all devices" scenarios. Storage auto-expires via TTL.
Solution 4
Token Versioning
One DB read per request (fetch tokenVersion). Instantly invalidates all tokens on logout or password change. Simple to implement but requires a DB lookup every time.

8 Complete Flow: Access + Refresh + Revocation

Here is the production-grade token lifecycle — from login to logout to attacker's failed attempt — in a single connected flowchart:

flowchart TD A[User logs in] --> B[Server issues Access Token 15 min + Refresh Token] B --> C[Refresh Token saved to database] B --> D[Client stores both tokens] D --> E{Access token expired?} E -->|No| F[Use access token directly] E -->|Yes| G[Send refresh token to server] G --> H{Refresh token in DB?} H -->|Yes| I[Issue new access token + rotate refresh token] H -->|No - was revoked| J[Force re-login] I --> F F --> K{User logs out?} K -->|Yes| L[Delete refresh token from DB] K -->|No| E L --> M[Attacker tries old access token] M --> N{Still within 15 min window?} N -->|No - expired| O[Access denied] N -->|Yes - brief window| P[Limited access until expiry] P --> Q[Attacker tries refresh token] Q --> R{In DB?} R -->|No - was deleted| O
Figure: Full access + refresh + revocation lifecycle

9 Implementation (For Developers)

Here is the minimal code for a secure access + refresh + revocation flow in Node.js:

Node.js — Login & Issue
// npm install jsonwebtoken uuid const jwt = require('jsonwebtoken'); const uuid = require('uuid'); async function login(userId, db) { // Short-lived access token — stateless const accessToken = jwt.sign( { userId, jti: uuid.v4() }, process.env.JWT_SECRET, { expiresIn: '15m' } ); // Long-lived refresh token — stored in DB const refreshToken = uuid.v4(); await db.query( `INSERT INTO refresh_tokens (token, user_id, expires_at) VALUES ($1, $2, NOW() + INTERVAL '30 days')`, [refreshToken, userId] ); return { accessToken, refreshToken }; }
Node.js — Logout & Rotate
async function logout(refreshToken, db) { // Deleting from DB instantly revokes the session await db.query( 'DELETE FROM refresh_tokens WHERE token = $1', [refreshToken] ); } async function refreshSession(refreshToken, db) { // Verify refresh token exists and is not expired const row = await db.query( `SELECT user_id FROM refresh_tokens WHERE token = $1 AND expires_at > NOW()`, [refreshToken] ); if (!row.rowCount) throw new Error('Invalid or expired refresh token'); const userId = row.rows[0].user_id; // Rotate: delete old, issue new pair await db.query('DELETE FROM refresh_tokens WHERE token = $1', [refreshToken]); return login(userId, db); // issues a fresh pair }
Always use HttpOnly cookies for refresh tokens. Storing a refresh token in localStorage makes it accessible to JavaScript, which means any XSS vulnerability on your page can steal it. An HttpOnly cookie cannot be read by JavaScript at all — only sent automatically by the browser on requests to your domain.

10 Frequently Asked Questions

You can, but the user experience becomes painful quickly. With a 5-minute expiry and no refresh token, users have to re-enter their credentials every 5 minutes. Refresh tokens exist precisely to solve this: the short-lived access token handles security, and the refresh token handles convenience. Dropping refresh tokens entirely forces a choice between security and usability that you don't have to make.

This is why refresh token rotation matters. With rotation, each time a refresh token is used, it is immediately replaced with a new one and the old one is deleted. If an attacker steals a refresh token and tries to use it after the legitimate user has already used it (and rotated it), the attacker's copy will be rejected — and the server can flag the account as potentially compromised. This is called refresh token reuse detection. The tricky edge case is when a network failure means the client never receives the new refresh token. Most systems handle this with a small grace window or by storing the "family" of refresh tokens.

Only partially. The access token remains stateless — API requests are still validated by signature alone, with no DB hit. The DB lookup happens only on the refresh endpoint, which is called every 15 minutes at most. Compare this to session-based auth where every single request requires a DB lookup. So the access + refresh pattern gives you stateless performance for 99% of requests, with a DB interaction only at token refresh time. That is a significant win over pure session auth.

JWTs shine in distributed systems and microservice architectures where multiple services need to verify identity without sharing session storage. But for a straightforward monolith with a single database, traditional session cookies are simpler, more secure by default (HttpOnly, database-backed), and easier to revoke instantly. JWTs add complexity — and every additional mechanism (refresh tokens, blacklisting, rotation) erodes the "stateless simplicity" argument. If you are not distributing auth verification across multiple services, evaluate whether sessions are simply the right tool.