JWT Demystified: Everything Developers Need to Know
JSON Web Tokens (JWTs) are ubiquitous in modern authentication systems. They are used in everything from OAuth 2.0 to microservice-to-microservice communication. Yet JWT misimplementation is responsible for a significant class of authentication vulnerabilities. This guide covers the JWT specification, signing algorithms, critical attacks, and best practices for building secure systems.
Decode and inspect any JWT token with our JWT Decoder.
JWT Structure: Header.Payload.Signature
A JWT consists of three base64url-encoded parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Payload: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
Signature: SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Decoded header:
{"alg": "HS256", "typ": "JWT"}
Decoded payload:
{"sub": "1234567890", "name": "John Doe", "iat": 1516239022}
The signature is computed as:
HMACSHA256(
base64url(header) + "." + base64url(payload),
secret
)
The JOSE Header
The JOSE (JSON Object Signing and Encryption) header tells the verifier how the token is signed. Key header parameters:
alg: Algorithm used to sign the token (REQUIRED)typ: Token type, usually "JWT"kid: Key ID—a hint indicating which key was used to sign the token, for key rotationjku: JWK Set URL—a URL pointing to a set of JSON public keys (potential SSRF vector!)x5u: X.509 URL (also a potential SSRF vector)
Standard Claims (Registered Claims)
| Claim | Name | Description |
|---|---|---|
iss | Issuer | Who issued the token (e.g., "https://auth.example.com") |
sub | Subject | Who the token is about (usually user ID) |
aud | Audience | Intended recipients (e.g., "api.example.com") |
exp | Expiration | Unix timestamp after which the token is invalid |
nbf | Not Before | Unix timestamp before which the token is invalid |
iat | Issued At | Unix timestamp when the token was issued |
jti | JWT ID | Unique identifier for the token (prevents replay attacks) |
JWT is NOT Encrypted
This is one of the most important points to understand about JWTs. A signed JWT (JWS—JSON Web Signature) is encoded, not encrypted. The payload can be decoded by anyone who has the token, without knowing the secret key:
// Anyone can decode the payload!
const [header, payload, sig] = token.split('.');
const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
console.log(decoded); // All claims visible
Never put sensitive information in a JWT payload—passwords, PII, credit card numbers, or anything you wouldn't want exposed. Usernames, user IDs, roles, and permissions are acceptable.
If you need encrypted tokens, use JWE (JSON Web Encryption), which has a 5-part structure (header.key.iv.ciphertext.tag) and actually encrypts the payload.
Verify vs Decode: A Critical Distinction
// WRONG: Just decoding - NEVER do this for authentication
const payload = JSON.parse(atob(token.split('.')[1]));
// This accepts any token, even forged ones!
// CORRECT: Verify the signature first
const jwt = require('jsonwebtoken');
// Verification checks:
// 1. Signature matches (proves token wasn't tampered with)
// 2. exp claim hasn't passed
// 3. nbf claim is in the past
// 4. iss matches expected issuer
// 5. aud matches expected audience
try {
const verified = jwt.verify(token, secret, {
algorithms: ['HS256'], // Explicitly allow only expected algorithms
issuer: 'https://auth.example.com',
audience: 'api.example.com'
});
console.log(verified.sub); // Only access claims after successful verification
} catch (err) {
// Token invalid, expired, or tampered
console.error('Token verification failed:', err.message);
}
HS256 vs RS256 vs ES256
HS256 (HMAC-SHA256) uses a shared secret. The same key is used to sign and verify. This means every service that needs to verify tokens must also have the signing secret—a significant security surface. Use HS256 only when a single service both issues and verifies tokens.
RS256 (RSA + SHA256) uses asymmetric cryptography. The private key signs tokens; the public key verifies them. Services can verify tokens without access to the signing key. The JWKS endpoint exposes the public key. Downsides: RSA keys are large (2048+ bits) and RSA signing is CPU-intensive.
ES256 (ECDSA with P-256 curve + SHA256) is the modern choice. Smaller keys (256 bits) with equivalent security to RSA-3072. Faster signing and verification than RSA. Used by major OAuth providers. Always prefer ES256 or ES384 for new systems.
Critical JWT Attacks
1. Algorithm Confusion: alg:none
// Attack: Forged token with alg:none
const maliciousHeader = btoa(JSON.stringify({"alg": "none", "typ": "JWT"}));
const maliciousPayload = btoa(JSON.stringify({"sub": "admin", "role": "superuser"}));
const maliciousToken = `${maliciousHeader}.${maliciousPayload}.`;
// No signature! If the server accepts alg:none, this is a valid admin token.
// Defense: Always explicitly specify allowed algorithms
jwt.verify(token, secret, { algorithms: ['HS256'] }); // Never omit this!
2. RS256-to-HS256 Algorithm Confusion
// If a server uses RS256 but doesn't enforce the algorithm:
// An attacker can sign a token with the PUBLIC KEY using HS256.
// The server's RSA public key is often publicly available via JWKS.
// If the verifier uses the public key as the HS256 secret, it succeeds.
// Defense: Always pin the expected algorithm
jwt.verify(token, publicKey, { algorithms: ['RS256'] }); // Not ['RS256', 'HS256']!
3. kid Header Injection
// If the kid parameter is used unsafely to look up keys:
// Attacker modifies kid to include SQL injection
// {"kid": "' OR 1=1 --", "alg": "HS256"}
// Or path traversal: {"kid": "/dev/null"} (empty key = trivial HMAC)
// Defense: Whitelist valid kid values, never use kid directly in DB queries
localStorage vs httpOnly Cookie: The Eternal Debate
localStorage/sessionStorage:
- Accessible via JavaScript (
localStorage.getItem('token')) - Vulnerable to XSS: any injected script can steal the token
- Easy to implement with Authorization header
- Works across subdomains easily
httpOnly Cookie:
- Not accessible via JavaScript—immune to XSS token theft
- Automatically sent with requests to the same domain
- Vulnerable to CSRF (mitigated with SameSite=Strict or CSRF tokens)
- More complex to implement for single-page apps with APIs on different domains
The security community consensus in 2026: use httpOnly, Secure, SameSite=Strict cookies for session tokens. The XSS risk of localStorage is harder to mitigate than the CSRF risk of cookies (which SameSite largely solves).
Access Token + Refresh Token Rotation
// Pattern: Short-lived access tokens + long-lived refresh tokens
{
"accessToken": "eyJ...", // Expires in 15 minutes
"refreshToken": "eyJ...", // Expires in 7 days, stored in httpOnly cookie
"expiresIn": 900 // Seconds
}
// When access token expires:
POST /auth/refresh
Cookie: refreshToken=eyJ...
// Server issues new access token AND rotates refresh token
// Old refresh token is invalidated (detect token reuse)
{
"accessToken": "eyJ...(new)",
"refreshToken": "eyJ...(rotated)"
}
JWKS Endpoint and Key Rotation
// Standard JWKS endpoint
GET https://auth.example.com/.well-known/jwks.json
{
"keys": [
{
"kty": "EC",
"use": "sig",
"crv": "P-256",
"kid": "key-2026-04",
"x": "...",
"y": "..."
}
]
}
// Libraries fetch and cache JWKS automatically
const jwksClient = require('jwks-rsa');
const client = jwksClient({ jwksUri: 'https://auth.example.com/.well-known/jwks.json' });
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
callback(err, key?.getPublicKey());
});
}
For hash generation and verification tools, see our Hash Generator. Full Base64 and base64url encoding is available at our Base64 Encoder.