← Back to Blog

JWT Security Best Practices: From Algorithms to Storage

Why JWT Security Matters So Much

JSON Web Tokens have become the de facto standard for modern web authentication: stateless, cross-domain friendly, and a natural fit for mobile. But that same flexibility means one wrong step and the entire authentication system is decorative.

The history of JWT implementations is littered with high-severity vulnerabilities from exactly this: alg: none bypasses, algorithm confusion, tokens that never expire and then leak. This guide works through five dimensions — algorithms, expiration, refresh, storage, and attack defences — and lays out the best practice for each.

1. Choosing an Algorithm

Ranked Recommendations

Algorithm Type Rating Best for
EdDSA (Ed25519) Asymmetric Best New projects; fast with short keys
RS256 / RS384 / RS512 Asymmetric (RSA) Strong Microservices, multi-service verification
ES256 / ES384 / ES512 Asymmetric (ECDSA) Strong Mobile, performance-sensitive paths
HS256 / HS384 / HS512 Symmetric (HMAC) Acceptable Monoliths, internal services
none None Never Permanently disabled

Why Asymmetric First?

Symmetric algorithms (HS256) use one key for both signing and verification. That causes three problems:

  1. Any service able to verify a token is also able to forge one
  2. In a microservice architecture the key must be distributed everywhere, widening the leak surface
  3. Once the key leaks, an attacker can mint a token for any identity

Asymmetric algorithms (RS256 / EdDSA) sign with a private key and verify with a public key:

  • Only the authorisation server holds the private key, so only it can issue tokens
  • Other services verify with the public key and cannot forge tokens even if fully compromised
  • The public key can be distributed openly, for example through a JWKS endpoint

The Fatal Trap: alg: none

The JWT specification permits a header of alg: none, meaning unsigned. An attacker can simply construct:

{"alg":"none","typ":"JWT"}.{"sub":"admin","role":"superadmin"}.

Some early libraries accepted these outright. Defences:

  1. Use a mainstream, actively maintained library (jose, PyJWT 2.x, jjwt)
  2. State the expected algorithm explicitly at verification time; never trust alg from the header:
// Dangerous: trusts the header's alg
jwt.verify(token, secret);

// Safe: pins the algorithm
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

Algorithm Confusion

The attacker rewrites an RS256 token as HS256 and re-signs it using the public key as the HMAC secret. If the server does not pin the algorithm, it will run an HMAC check with the public key — which is, by definition, public — and the attacker can forge any identity.

Defence: pin the algorithm (above), and never mix public and private key material.

Key Strength

  • HS256: at least 256 bits (32 bytes) of random bytes — not a short passphrase or anything guessable
  • RS256: at least 2048 bits, 3072 preferred
  • EdDSA: a 255-bit key is enough, with excellent performance and security
# 32 random bytes
openssl rand -base64 32

# RSA key pair
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem

# Ed25519 key pair
openssl genpkey -algorithm Ed25519 -out private.pem
openssl pkey -in private.pem -pubout -out public.pem

Need a strong random key rather than a passphrase? Our token generator produces one locally in your browser.

2. Expiration Policy

Core Principle: Every Token Must Expire

A JWT with no expiry is a serious security defect — once it leaks, there is no recovery. The exp claim is the first line of defence.

Recommended Lifetimes

Token type Recommended lifetime Notes
Access Token 15 minutes – 1 hour Short, so the leak window is small
Refresh Token 7 – 30 days Exchanged for a new Access Token
ID Token (OIDC) 5 – 10 minutes One-shot identity handoff only
// Issuing an Access Token
const accessToken = jwt.sign(
  { sub: userId, role: 'user' },
  privateKey,
  {
    algorithm: 'RS256',
    expiresIn: '15m',  // 15 minutes
    issuer: 'https://auth.example.com',
    audience: 'https://api.example.com'
  }
);

Why Not Just Use a Long Expiry?

Once issued, a token cannot be revoked before exp (short of maintaining a denylist, which defeats the point of statelessness). Short lifetimes plus a Refresh Token is the standard balance between security and user experience:

  • The Access Token is short-lived, so a leak does limited damage
  • The Refresh Token can be revoked proactively, because it is stored server-side
  • The user notices nothing — the front end silently swaps in a new Access Token when the old one expires

3. Refresh Token Rotation

The Flow

Client                      Auth Service
  │                              │
  │── login ────────────────────▶│
  │◀─ Access Token (15m) ────────│
  │   + Refresh Token (7d) ──────│
  │                              │
  │   ... 15 minutes later ...    │
  │                              │
  │── exchange Refresh for AT ──▶│
  │◀─ new Access Token ──────────│
  │   + new Refresh Token ───────│  ← the old Refresh dies at once
  │                              │

Why Rotation Matters

Every time a Refresh Token is exchanged, issue a new Refresh Token and invalidate the old one. This gives you:

  1. Each Refresh Token is single-use
  2. If an attacker steals an already-used Refresh Token, using it trips reuse detection
  3. On detection, revoke that user's whole token family and force re-authentication

Implementation Notes

async function refresh(refreshToken) {
  // 1. Verify the Refresh Token signature and expiry
  const payload = jwt.verify(refreshToken, publicKey, {
    algorithms: ['RS256']
  });

  // 2. Check the revocation list (needs Redis or similar)
  const isRevoked = await redis.get(`revoked:${payload.jti}`);
  if (isRevoked) {
    // Reuse detected -> revoke the entire token family
    await revokeTokenFamily(payload.family_id);
    throw new Error('Token reuse detected');
  }

  // 3. Add the current Refresh Token to the revocation list
  await redis.setex(`revoked:${payload.jti}`, payload.exp - now, '1');

  // 4. Issue a new Access Token and Refresh Token
  const newAccess = signAccessToken(payload.sub);
  const newRefresh = signRefreshToken(payload.sub, payload.family_id);

  return { accessToken: newAccess, refreshToken: newRefresh };
}

Proactive Revocation

Although JWTs themselves are stateless, Refresh Tokens must be revocable server-side:

  • Denylist: on logout, password change, or anomaly detection, write the Refresh Token's jti to Redis
  • Allowlist: maintain the set of valid Refresh Tokens per user and delete on logout
  • The denylist is generally recommended: less storage, better performance

4. Where to Store Tokens

This is where front ends most often get it wrong. Three options compared:

Option A: localStorage (not recommended)

// Dangerous
localStorage.setItem('token', accessToken);

The problem: localStorage is readable by any same-origin JavaScript, so a single XSS vulnerability lets an attacker lift the token outright. Never put tokens in localStorage.

Option B: HttpOnly Cookie (recommended)

The server writes the token via Set-Cookie with HttpOnly + Secure + SameSite:

Set-Cookie: access_token=eyJ...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900

Advantages:

  • JavaScript cannot read it (document.cookie returns nothing), so XSS token theft is blocked by construction
  • The browser attaches it automatically; no front-end wiring needed

Caveat: you must pair this with CSRF protection (below).

Option C: In Memory (pairs with Option B)

// Access Token held in memory
let accessToken = null;

// After a page reload, fetch a new one using the Refresh Token (HttpOnly cookie)

Advantage: the token vanishes on reload, so the attack window is tiny. Disadvantage: every reload costs an extra exchange request.

Recommended Combination

Token Storage
Access Token Memory (a JavaScript variable)
Refresh Token HttpOnly + Secure + SameSite=Strict cookie

With this split, XSS can only reach a short-lived Access Token and never the Refresh Token, while SameSite cookies stop CSRF.

Mobile Apps

Native apps are not covered by the browser's same-origin policy, so:

  • Store tokens in the platform secure storage: iOS Keychain / Android Keystore
  • Protect the Refresh Token with biometric unlock
  • Never use UserDefaults or SharedPreferences — those are plaintext

5. XSS and CSRF Defences

XSS

Cross-site scripting is the single biggest threat to JWTs: injected script reads the token and ships it off.

Layers of defence:

  1. Output encoding: escape all user input rendered into HTML (React and Vue escape by default)
  2. Content Security Policy: restrict where scripts may come from
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'
  1. HttpOnly cookies: keep the token away from JavaScript (above)
  2. Trusted Types: a modern browser API that enforces sanitised input at the DOM sink

For a full treatment of XSS — including the correct encoding for each output context, CSP nonces, and Trusted Types — see our XSS prevention guide.

CSRF

Cross-site request forgery exploits the browser's automatic cookie attachment to make a logged-in user issue a request they never intended.

Note: CSRF only threatens cookie-based schemes; a token sent manually in the Authorization header is unaffected. But the XSS advantage of cookies is too large to give up, so pair them with CSRF defences:

  1. SameSite cookies (first choice)
Set-Cookie: ...; SameSite=Strict   # never sent cross-site
Set-Cookie: ...; SameSite=Lax      # allows top-level GET navigation (the default)

Strict is safest but hurts the experience of arriving from an external link. Lax is a reasonable middle ground.

  1. CSRF token

The server generates a random token, embeds it in the page, and validates it on submission:

<meta name="csrf-token" content="abc123">
fetch('/api/data', {
  headers: { 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content }
});
  1. Double-submit cookie: keep a CSRF token in a cookie and send the same value in a request header; the server compares them.

6. Other Security Essentials

1. Minimise Claims

Put only what is necessary in the payload and never sensitive data (passwords, national ID numbers, payment details). A JWT payload is Base64-encoded, not encrypted — anyone can decode it.

// Dangerous
{ sub: '123', password: 'abc123', creditCard: '4111...' }

// Safe
{ sub: '123', role: 'user' }

You can decode any token safely in your browser with our JWT parser — nothing is uploaded.

2. Verify Every Claim

Do not stop at the signature; check all the standard claims:

jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'https://auth.example.com',     // check the issuer
  audience: 'https://api.example.com',    // check the audience
  clockTimestamp: Date.now() / 1000       // guard against clock skew
});
// The library checks exp, nbf, and iat for you

3. Use jti Against Replay

Give each token a unique ID (jti) and record the ones already consumed for sensitive operations, so a captured request cannot be replayed.

4. Rotate Signing Keys

Replace signing keys periodically (every 90 days, say), accepting both old and new during the transition:

function verify(token) {
  // Try the new key
  try { return jwt.verify(token, newKey, { algorithms: ['RS256'] }); }
  catch { /* fallthrough */ }
  // Fall back to the old key
  return jwt.verify(token, oldKey, { algorithms: ['RS256'] });
}

Exposing a public key set through a JWKS endpoint and matching on the kid header makes rotation clean.

5. Redact Tokens From Logs

Never log a full token:

// Wrong
console.log(`Auth: ${token}`);

// Right
console.log(`Auth: jti=${payload.jti}, sub=${payload.sub}`);

Quick Reference

Dimension Best practice
Algorithm EdDSA or RS256, pinned explicitly, none disabled
Keys Prefer asymmetric, long enough, rotated regularly
Expiration Access Token 15 minutes, Refresh Token 7–30 days
Refresh Refresh Token rotation plus reuse detection
Revocation Refresh Token denylist (Redis)
Storage (web) Access Token in memory, Refresh Token in an HttpOnly cookie
Storage (app) Keychain / Keystore plus biometrics
XSS defence HttpOnly cookies + CSP + output encoding
CSRF defence SameSite cookies + CSRF token
Payload Minimal, no sensitive data
Verification Check signature + exp + iss + aud + nbf

For a side-by-side comparison of signing algorithm strength, see the hash and encryption algorithm cheat sheet; for cookie security attributes, see the HTTP headers cheat sheet.

Advertisement

Frequently Asked Questions

Where should I store a JWT? Is localStorage acceptable?

**Do not use localStorage.** Any same-origin JavaScript can read it, so a single XSS hole hands the token straight to an attacker. The recommended split: keep the Access Token **in memory** (a JS variable) and the Refresh Token in an **HttpOnly + Secure + SameSite=Strict cookie**. XSS can then only reach the short-lived Access Token and never the Refresh Token, while SameSite blocks CSRF. Native apps should use the iOS Keychain or Android Keystore — never UserDefaults or SharedPreferences, which store values in plaintext.

Why can't a JWT be revoked after issuance, and what do I do about it?

JWTs are stateless: the server keeps no record of what it issued, and verification relies only on the signature plus `exp`. There is therefore **no built-in revocation before expiry** — maintaining a denylist defeats the point of statelessness by reintroducing server-side state. The standard answer is **short lifetimes plus a Refresh Token**: the Access Token lives 15 minutes so a leak is contained, while the Refresh Token is server-side and can be denylisted on demand (logout, password change, anomaly detection writes its `jti` to Redis). For genuinely high-risk operations that need instant invalidation, add an extra server-side check on top.

What is refresh token rotation, and why bother?

Every time a Refresh Token is exchanged for a new Access Token, **also issue a fresh Refresh Token and immediately invalidate the old one**, so each Refresh Token is single-use. The payoff is **reuse detection**: if an attacker steals a Refresh Token that has already been used, their attempt to reuse it tells the server the token was compromised, so it can revoke that user's entire token family and force re-authentication. Without rotation, a stolen Refresh Token keeps working silently for its whole lifetime.

What are the `alg: none` and algorithm confusion attacks?

Both come from the same root cause: **the server trusting the `alg` field in the token header.** With `alg: none`, the attacker crafts a token declaring that it is unsigned (early libraries accepted these outright). With algorithm confusion, they rewrite an RS256 token as HS256 and re-sign it using the **public key** as the HMAC secret — if the server does not pin the algorithm, it runs an HMAC check with a key that is public by definition, so any identity can be forged. **One fix covers both: state the expected algorithm explicitly at verification time** and never trust `alg` from the header.

← Back to Blog