JWTs are the most widely used token format for API authentication, but a misconfigured JWT implementation is worse than no authentication at all. An attacker who forges or steals a token gains full access with zero server interaction. This guide covers the security practices every production system should follow — from signature validation and algorithm selection to token storage, refresh patterns, and revocation strategies. If you are new to the format, read What is a JWT? first.
Why should you always validate the signature?
A JWT without signature verification is just a JSON blob anyone can edit. The signature is the only mechanism that proves the token was issued by your server and has not been tampered with. Always verify with the expected algorithm and key — never decode the payload alone.
import { jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
async function verifyToken(token: string) {
// jwtVerify throws if signature is invalid or token is expired
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'], // explicitly restrict allowed algorithms
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
return payload;
}Specifying algorithms, issuer, and audience prevents multiple classes of attack in a single call. Treat JWT_SECRET like any other production secret — see environment variable security for handling, rotation, and leak detection.
How short should token expiration be?
The exp claim is your primary line of defense against stolen tokens. Industry recommendation: 5-15 minutes for access tokens. A shorter expiration window shrinks the attack surface — even if a token leaks, it becomes useless quickly.
import { SignJWT } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
async function issueAccessToken(userId: string) {
return new SignJWT({ sub: userId, role: 'user' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m') // 15-minute access token
.setIssuer('https://auth.example.com')
.setAudience('https://api.example.com')
.sign(secret);
}Never issue tokens without exp. A token without expiration is valid forever if compromised.
Why use asymmetric algorithms in production?
Symmetric algorithms like HS256 require every service that verifies tokens to hold the same secret. In a microservices architecture this is a significant risk — one compromised service exposes the signing key. Asymmetric algorithms (RS256, ES256) solve this: only the auth service holds the private key, while all other services verify with the public key.
| Algorithm | Key type | Key size | Best for |
|---|---|---|---|
| HS256 | Symmetric | 256-bit secret | Single-service, internal tools |
| RS256 | RSA key pair | 2048+ bit | Multi-service, JWKS-based verification |
| ES256 | ECDSA key pair | P-256 curve | Compact tokens, mobile apps, high throughput |
ES256 produces smaller signatures than RS256 (64 bytes vs 256 bytes) and is faster to sign. Prefer it for new systems unless you need RSA for compatibility.
What should you never store in a JWT payload?
JWT payloads are Base64URL-encoded, not encrypted. Anyone with the token can decode the payload instantly. Never include passwords, credit card numbers, API keys, PII like social security numbers, or any data you would not want exposed publicly. Stick to identifiers and roles.
Safe payload claims: sub (user ID), role, iss, aud, exp, iat, jti (token ID for revocation).
Never include: passwords, secrets, tokens, full email addresses, financial data, or any sensitive PII.
How does the access + refresh token pattern work?
Short-lived access tokens limit damage from theft, but forcing users to re-authenticate every 15 minutes is unacceptable. The solution: pair a short-lived access token with a longer-lived refresh token. The refresh token is stored securely and exchanged for new access tokens without requiring credentials.
import { SignJWT, jwtVerify } from 'jose';
import crypto from 'node:crypto';
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
async function issueTokenPair(userId: string) {
const accessToken = await new SignJWT({ sub: userId })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('15m')
.setIssuedAt()
.sign(secret);
// Refresh token: opaque, stored in DB with expiration
const refreshToken = crypto.randomBytes(64).toString('base64url');
// Store in database: { token: refreshToken, userId, expiresAt: Date.now() + 7 days, family: familyId }
return { accessToken, refreshToken };
}
async function refresh(refreshToken: string) {
// 1. Look up refresh token in database
// 2. Check it has not expired or been revoked
// 3. Invalidate the old refresh token (rotation)
// 4. Issue a new token pair
return issueTokenPair(/* userId from DB lookup */);
}Always rotate refresh tokens on use. If a refresh token is used twice, it indicates theft — revoke the entire token family.
Where should you store tokens on the client?
Token storage directly impacts your vulnerability to XSS and CSRF attacks. There is no perfect solution, but some options are significantly safer than others.
HttpOnly cookies (recommended)
JavaScript cannot access HttpOnly cookies, eliminating XSS token theft entirely. Set Secure, SameSite=Strict, and Path=/. Add CSRF protection (double-submit cookie or synchronizer token).
In-memory (good for SPAs)
Store the access token in a JavaScript variable. It survives as long as the tab is open and is invisible to XSS in other tabs. Combine with a refresh token in an HttpOnly cookie to rehydrate on page load.
localStorage (avoid if possible)
Accessible to any JavaScript running on the page. A single XSS vulnerability exposes the token. If you must use it, pair it with very short expiration times and strict Content Security Policy headers.
What are effective revocation strategies?
JWTs are stateless by design — the server does not track them. This makes revocation inherently difficult. Three practical strategies:
- Short-lived tokens. The simplest approach. With 5-15 minute access tokens and refresh token rotation, compromised tokens expire quickly without any server-side state.
- Deny list. Maintain a server-side set of revoked
jti(token ID) values. Check on every request. Use Redis with TTL matching the token expiration so entries auto-clean. Adds latency but gives instant revocation. - Token families. Group all refresh tokens from a single login session into a family. If reuse is detected (a rotated-out token is presented), revoke the entire family. This catches theft of refresh tokens.
What are the most common JWT vulnerabilities?
alg: "none"attack. Attacker sets the algorithm tononeand strips the signature. Vulnerable libraries accept the token as valid. Fix: always specify the expected algorithm in verification options.- HMAC/RSA confusion. Server expects RS256 (asymmetric) but attacker sends HS256 (symmetric) using the public key as the HMAC secret. Since the public key is known, the attacker can forge tokens. Fix: explicitly set
algorithms: ['RS256']in verification — never let the token header dictate the algorithm. kidinjection. Thekid(Key ID) header parameter is used to look up the verification key. If this value is passed unsanitized into a file path or SQL query, it enables directory traversal or SQL injection. Fix: validatekidagainst an allowlist.- Missing expiration. Tokens without
expnever expire. A single leaked token grants permanent access. Fix: always set and enforce expiration.
Which JWT libraries should you use?
Use well-maintained, audited libraries that default to secure behavior. Avoid libraries that allow alg: "none" or do not enforce algorithm specification.
| Language | Library | Notes |
|---|---|---|
| Node.js | jose | Web Crypto API, Edge-compatible, no native deps |
| Python | PyJWT | Widely adopted, supports RS/ES/PS algorithms |
| Go | golang-jwt/jwt | Successor to dgrijalva/jwt-go, actively maintained |
Quick reference checklist
- Always verify signatures with an explicit algorithm allowlist
- Set access token expiration to 5-15 minutes
- Use RS256 or ES256 in multi-service architectures
- Never put sensitive data in the payload
- Rotate refresh tokens on every use
- Store tokens in HttpOnly cookies or in-memory, not localStorage
- Include
jtiif you need revocation capability - Validate
iss,aud, andexpon every verification call
References
- RFC 7519 — JSON Web Token (JWT) — the core JWT specification (claims, structure, validation rules).
- RFC 7515 — JSON Web Signature (JWS) — how JWT signatures are constructed and verified.
- RFC 8725 — JWT Best Current Practices — IETF guidance on avoiding the most common JWT vulnerabilities.
- OWASP JWT Cheat Sheet — practical attack patterns (alg=none, key confusion) and mitigations.
- panva/jose — the JavaScript JOSE library used in the examples above (Node, browsers, Workers).
- golang-jwt/jwt — the actively maintained Go JWT library (successor to dgrijalva/jwt-go).
- PyJWT — the standard Python JWT library, with HS/RS/ES/PS algorithm support.
Inspect and debug your tokens with the JWT Debugger, or learn the fundamentals in What is a JWT?