JWT Best Practices — Secure JSON Web Token Implementation
JWT implementation mistakes lead to authentication bypasses and security vulnerabilities. Here's how to sign, validate, and store JWTs correctly — covering algorithm confusion,...
JWTs are powerful but easy to implement insecurely. Common mistakes include using weak algorithms, skipping expiration, storing tokens in localStorage, and not validating claims properly. Here’s how to implement JWTs correctly.
Use the JWT Decoder to decode and inspect JWT tokens.
JWT structure
A JWT is three base64url-encoded parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNzA5NTUxMjM0fQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
└──────────────────────────────────────────────┘ └────────────────────────────────────────────────┘ └──────────────────────────────────────────────┘
header payload signature
Header (decoded):
{ "alg": "HS256", "typ": "JWT" }
Payload (decoded):
{
"sub": "1234567890",
"name": "Alice",
"iat": 1709551234,
"exp": 1709554834
}
The signature verifies the header + payload haven’t been tampered with.
Algorithm selection
Use RS256 or ES256, not HS256 for APIs
HS256 — symmetric (HMAC):
- Same secret for signing AND verifying
- If your API server is compromised, attacker can create valid JWTs
- Fine for internal single-service auth
RS256 — asymmetric (RSA):
- Private key signs, public key verifies
- Resource servers only need public key
- Compromise of resource server doesn't enable token forgery
For APIs consumed by third parties or microservices:
// Generate RS256 key pair:
const { privateKey, publicKey } = await crypto.subtle.generateKey(
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
true,
['sign', 'verify']
);
// Node.js (jose library):
import { generateKeyPair, SignJWT, jwtVerify } from 'jose';
const { privateKey, publicKey } = await generateKeyPair('RS256');
// Sign with private key:
const token = await new SignJWT({ sub: 'user-123' })
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(privateKey);
// Verify with public key:
const { payload } = await jwtVerify(token, publicKey);
The “alg: none” attack
Never accept "alg": "none" — it means the token has no signature and anyone can forge it:
// WRONG: vulnerable to alg:none attack
const payload = jwt.verify(token, secret);
// CORRECT: explicitly specify allowed algorithms
const payload = jwt.verify(token, secret, { algorithms: ['HS256'] });
// or:
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
The HS256/RS256 confusion attack
If your code accepts both HS256 and RS256, an attacker can sign a JWT with HS256 using your PUBLIC key (which is public information):
// WRONG: accepts any algorithm
jwt.verify(token, publicKey);
// CORRECT: whitelist algorithms explicitly
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
Required claims
Every JWT should have:
const token = await new SignJWT({
sub: userId, // Who the token is about
iss: 'myapp.com', // Who issued the token
aud: 'myapp.com', // Intended audience
})
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt() // iat: issued at
.setExpirationTime('1h') // exp: expires in 1 hour
.setJti(randomUUID()) // jti: unique token ID (for revocation)
.sign(privateKey);
// Verification with claim validation:
const { payload } = await jwtVerify(token, publicKey, {
issuer: 'myapp.com',
audience: 'myapp.com',
});
Expiration strategy
Access tokens: Short-lived (15 minutes to 1 hour). Limits damage from stolen tokens.
Refresh tokens: Longer-lived (days to weeks). Stored securely. Used to get new access tokens.
// Issue both tokens at login:
async function issueTokens(userId) {
const accessToken = await createJWT({ sub: userId, type: 'access' }, '15m');
const refreshToken = await createJWT({ sub: userId, type: 'refresh', jti: uuid() }, '7d');
// Store refresh token JTI in database for revocation:
await db.refreshTokens.create({ userId, jti: parseJWT(refreshToken).jti });
return { accessToken, refreshToken };
}
// Refresh flow:
async function refreshAccessToken(refreshToken) {
const { payload } = await jwtVerify(refreshToken, publicKey);
// Check token is not revoked:
const stored = await db.refreshTokens.findOne({ jti: payload.jti });
if (!stored || stored.revoked) throw new Error('Token revoked');
return createJWT({ sub: payload.sub, type: 'access' }, '15m');
}
Token storage
HttpOnly cookies (recommended for web):
// Set cookie:
res.cookie('accessToken', token, {
httpOnly: true, // JS can't access it (prevents XSS theft)
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 15 * 60 * 1000, // 15 minutes
});
// Read cookie (automatic via fetch with credentials):
fetch('/api/data', { credentials: 'include' });
localStorage (avoid for sensitive tokens):
- Accessible to JavaScript — XSS can steal tokens
- No CSRF risk (no automatic inclusion)
- Fine for non-sensitive data, but not auth tokens
In-memory (for SPAs):
- Only exists while page is open
- Can’t survive page refresh without secure refresh mechanism
- Not accessible from XSS in other browser tabs
Token revocation
JWTs are stateless — once issued, they’re valid until expiration. To revoke them:
Option 1: Short expiration (simplest) Keep access tokens short-lived (15 minutes). Stolen token is only valid for 15 minutes.
Option 2: JTI blocklist (more complex) Store revoked JTI values in Redis. Check on every request:
async function verifyToken(token) {
const { payload } = await jwtVerify(token, publicKey);
// Check if JTI is revoked:
const isRevoked = await redis.exists(`revoked_jti:${payload.jti}`);
if (isRevoked) throw new Error('Token revoked');
return payload;
}
async function revokeToken(jti, expiresAt) {
// Store in Redis until natural expiration:
await redis.setex(`revoked_jti:${jti}`, Math.floor(expiresAt - Date.now()/1000));
}
Option 3: Version counter Store a token version per user. Increment to invalidate all tokens:
// In JWT payload:
{ sub: userId, version: 3 }
// Verification:
const user = await db.users.findById(payload.sub);
if (user.tokenVersion !== payload.version) {
throw new Error('Token invalidated');
}
Related tools
- JWT Decoder — decode and inspect JWT tokens
- JWT Token Decoder — JWT decoding guide
- JWT Security Checklist — security review checklist
Related posts
- Decoding a JWT Is Not the Same as Verifying It — Every JWT bug in production reduces to the same mistake: trusting a decoded toke…
- JWT Security Checklist for 2026 — Twelve checks every JWT implementation should pass before shipping. The actual c…
- JWT Authentication Flow — Login, Token Storage, Refresh Tokens — JWT authentication involves issuing tokens on login, sending them with requests,…
- JWT Token Decoder — Decode and Inspect JWT Tokens Online — A JWT decoder reveals the header, payload, and signature of any JSON Web Token w…
Related tool
Decode and inspect JSON Web Tokens. Local-only — tokens never leave your browser.
Written by Mian Ali Khalid. Part of the Dev Productivity pillar.