JWT Structure, Security Risks, and Best Practices
What Makes JWT Tricky
JWT (JSON Web Token) looks approachable but is easy to misuse.
Teams adopt JWT for "stateless authentication," then discover their payloads contain personally identifiable information visible to any client, their parser accepts alg: none tokens, or their logout flow is cosmetic because the token remains valid until it expires. These aren't edge cases — they surface regularly in production systems.
This guide covers the precise structure defined in RFC 7519 (JWT), RFC 7515 (JWS), and RFC 7516 (JWE), along with the practical security decisions that matter.
The Three Parts of a JWT
A JWT is three Base64url-encoded strings joined by dots (.).
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Decoded:
1. Header
{
"alg": "HS256",
"typ": "JWT"
}
alg specifies the signing algorithm. typ is typically "JWT".
Important: The Header is Base64url encoded — not encrypted. Anyone can decode it.
2. Payload
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022,
"exp": 1516242622,
"iss": "https://auth.example.com",
"aud": "https://api.example.com"
}
Registered claims (RFC 7519 Section 4.1):
| Claim | Description |
|---|---|
iss |
Issuer — entity that issued the token |
sub |
Subject — the principal (usually a user ID) |
aud |
Audience — intended recipient(s) |
exp |
Expiration Time — Unix timestamp after which the token is invalid |
nbf |
Not Before — token is not valid before this time |
iat |
Issued At — token creation time |
jti |
JWT ID — unique identifier for the token |
The Payload is also not encrypted (in JWS). Anyone who has the token can decode it by Base64url decoding the middle segment.
3. Signature
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
The signature enables tamper detection. A valid signature means: "this token was issued by the party holding the signing key, and neither the header nor the payload has been modified since."
Use the JWT Decoder to inspect any JWT.
JWS vs JWE
The JWT ecosystem has two protection modes:
| Spec | What It Protects | Payload Visibility |
|---|---|---|
| JWS (RFC 7515) | Integrity (tamper detection) | Visible to anyone |
| JWE (RFC 7516) | Integrity + Confidentiality | Encrypted, opaque |
When developers say "JWT," they almost always mean JWS. JWE encrypts the payload, but its complexity makes it a niche choice. The more practical guidance: don't put secrets in JWTs at all.
HS256 vs RS256: Choosing a Signing Algorithm
HS256 (HMAC-SHA256)
A symmetric algorithm: the same secret key is used for both generation and verification.
Sign: HMACSHA256(header.payload, secret_key)
Verify: Recompute HMACSHA256(header.payload, secret_key) and compare
Problem: Every service that verifies tokens must hold the same secret key. In a microservices architecture, sharing a secret key across services creates a broad attack surface.
RS256 (RSA-SHA256)
An asymmetric algorithm: the private key signs tokens; the public key verifies them.
Sign (auth server only): RSA-sign(header.payload, private_key)
Verify (all API servers): RSA-verify(header.payload, signature, public_key)
Advantage: The public key is, literally, public. API servers never hold the private key, so a compromised API server cannot forge new tokens.
const jwt = require('jsonwebtoken');
const fs = require('fs');
const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');
// Issue a token (auth server)
const token = jwt.sign(
{ sub: 'user-123' },
privateKey,
{
algorithm: 'RS256',
expiresIn: '15m',
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
}
);
// Verify (API server — public key only)
try {
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // explicit allowlist — never include 'none'
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
console.log(decoded.sub); // 'user-123'
} catch (err) {
// TokenExpiredError, JsonWebTokenError, NotBeforeError
console.error('Token rejected:', err.message);
}
Algorithm Recommendations
| Algorithm | Type | Recommendation | Use Case |
|---|---|---|---|
| RS256 | RSA + SHA-256 | ✓ Recommended | Multiple services, microservices |
| ES256 | ECDSA + P-256 | ✓ Recommended | Shorter keys than RS256, same security |
| HS256 | HMAC-SHA256 | △ Limited | Single service, internal only |
| RS512 / ES512 | Larger key | △ Situational | High-security requirements |
The alg: none Vulnerability
One of the best-known JWT vulnerabilities, rooted in RFC 7519 Section 6, which acknowledges that JWTs with alg: none carry no integrity protection.
Several real-world JWT libraries historically accepted the following attack:
- Take a valid token and decode the Header and Payload
- Replace the Header with
{"alg": "none", "typ": "JWT"} - Modify the Payload:
{"sub": "admin", "role": "superadmin", ...} - Reconstruct the token with an empty Signature segment
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJzdXBlcmFkbWluIn0.
Mitigation: always specify an explicit algorithm allowlist:
// ❌ Dangerous: algorithm not constrained
jwt.verify(token, publicKey);
// ✅ Safe: explicit allowlist, 'none' excluded
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
# Python (PyJWT)
import jwt
# ❌ Dangerous
decoded = jwt.decode(token, public_key)
# ✅ Safe
decoded = jwt.decode(
token,
public_key,
algorithms=["RS256"],
options={"require": ["exp", "iss", "aud"]}
)
Never Put Secrets in Payloads
The Payload is decoded with a trivial one-liner:
function decodePayload(token) {
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(atob(base64));
}
// If the token is in localStorage, any JavaScript on the page can read it
Never include in payloads:
- Passwords, API keys, private keys
- PII: email addresses, phone numbers, physical addresses
- Financial data: card numbers, account balances
- Any value you would not store in a URL parameter
Appropriate payload content:
- User ID (a reference, not the full user record)
- Roles (
"admin","editor") - Scopes (
"read:articles","write:orders") - Standard time claims (
exp,iat,nbf)
Stateless Authentication Has Real Limitations
JWT's "stateless" property means the server verifies the token without a database lookup. This is its selling point — and its constraint.
Stateless (JWT):
Client → [JWT] → Server → verify signature → done
(no database query)
Stateful (Session):
Client → [Session ID] → Server → query DB for session → done
The Immediate Revocation Problem
If a user changes their password, an admin disables an account, or a token is compromised, you cannot invalidate an issued JWT before it expires in a purely stateless model. If the access token expires in one hour, the compromised token is valid for up to one hour.
Strategies:
1. Short access token lifetime + refresh token rotation
// Access token: 15 minutes
const accessToken = jwt.sign({ sub: userId }, privateKey, {
algorithm: 'RS256',
expiresIn: '15m',
});
2. Token version field in the user record
// Add token_version to the user record
// Increment on password change, account disable, etc.
async function verifyToken(token) {
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
const user = await db.users.findById(decoded.sub);
if (user.token_version !== decoded.token_version) {
throw new Error('Token has been revoked');
}
return decoded;
}
This sacrifices pure statelessness for correctness — one database query per request.
3. JTI denylist (blocklist)
Store revoked JWT IDs (the jti claim) in Redis with TTL matching the token's remaining lifetime. This also requires a lookup per request, but the lookup is a fast key-value read.
Refresh Token Design
The standard pattern separates short-lived access tokens (JWTs) from long-lived refresh tokens (opaque random values stored in the database).
Access token: JWT, 15–30 minutes, stateless verification
Refresh token: random bytes, 7–30 days, stored in DB as hash
const crypto = require('crypto');
async function issueTokenPair(userId) {
const accessToken = jwt.sign(
{ sub: userId, type: 'access' },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
// Refresh token is NOT a JWT — it's a random opaque value
const refreshToken = crypto.randomBytes(32).toString('hex');
// Store hash of refresh token (not the token itself)
await db.refreshTokens.insert({
hash: crypto.createHash('sha256').update(refreshToken).digest('hex'),
user_id: userId,
expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
});
return { accessToken, refreshToken };
}
async function rotateRefreshToken(incomingToken) {
const hash = crypto.createHash('sha256').update(incomingToken).digest('hex');
const record = await db.refreshTokens.findOne({ hash });
if (!record || record.expires_at < new Date()) {
throw new Error('Invalid or expired refresh token');
}
// Rotate: delete old token and issue new pair
await db.refreshTokens.delete({ hash });
return issueTokenPair(record.user_id);
}
Why not make the refresh token a JWT? Because refresh tokens need to be immediately revocable when a device is lost or a session is explicitly terminated. Storing them in the database enables this.
Security Checklist
Based on the OWASP JWT Cheat Sheet:
- Specify
algorithmsallowlist explicitly — never include"none" - Always set
expand validate it on every request - Validate
iss(issuer) andaud(audience) claims - Keep the access token lifetime at 15–30 minutes maximum
- Never include secrets or PII in the payload
- Rotate refresh tokens on every use
- Transmit tokens only over HTTPS
- Prefer
httpOnlycookies overlocalStoragefor browser storage (XSS mitigation) - Configure JWK Set URL (jwks_uri) for automatic public key rotation (RS256/ES256)
- Use constant-time comparison when checking signatures or token hashes
Summary
JWT done right is a powerful authentication mechanism. The key points:
- The payload is public — use JWE if confidentiality is required
- Always specify the algorithm explicitly — prevent
alg: noneattacks - Short access tokens + rotating refresh tokens — balance usability and security
- Immediate revocation requires a database lookup — accept that trade-off when needed
- RS256 or ES256 over HS256 — asymmetric keys are safer in multi-service architectures
Decode and inspect any JWT with the JWT Decoder.
