JSON Web Tokens (JWTs) are everywhere in modern authentication. Understanding how they work—and their security implications—is crucial for any developer building APIs or web applications.
What is a JWT?
A JWT (pronounced "jot") is a compact, URL-safe way to represent claims between two parties. It's commonly used for:
- Authentication: After login, users receive a JWT to prove their identity
- Authorization: JWTs can contain permissions/roles
- Information exchange: Secure data transfer between services
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Notice the three parts separated by dots.
The Three Parts
1. Header
{
"alg": "HS256",
"typ": "JWT"
}
The header specifies:
- alg: The signing algorithm (HS256, RS256, etc.)
- typ: Token type (always "JWT")
This is Base64URL encoded to become:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
2. Payload (Claims)
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022
}
The payload contains claims—statements about the user and metadata.
Base64URL encoded:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0
3. Signature
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
The signature ensures the token hasn't been tampered with. It's created by:
- Combining the encoded header and payload with a dot
- Signing with the secret key using the specified algorithm
Base64URL Encoding
JWTs use Base64URL (not regular Base64):
+→-/→_- Padding (
=) is removed
This makes JWTs safe for URLs and HTTP headers.
Important: Base64 is encoding, not encryption! Anyone can decode the header and payload:
const payload = JSON.parse(atob(token.split('.')[1]));
Never put sensitive data in JWTs without additional encryption.
How JWT Verification Works
When a server receives a JWT:
- Split the token into header, payload, and signature
- Decode the header to get the algorithm
- Recalculate the signature using the secret key
- Compare the calculated signature with the received signature
- Check claims (expiration, issuer, etc.)
function verifyJWT(token, secret) {
const [header, payload, signature] = token.split('.');
const expectedSignature = sign(header + '.' + payload, secret);
if (signature !== expectedSignature) {
throw new Error('Invalid signature');
}
const claims = JSON.parse(base64UrlDecode(payload));
if (claims.exp && Date.now() >= claims.exp * 1000) {
throw new Error('Token expired');
}
return claims;
}
JWT Claims
Registered Claims (Standard)
| Claim | Name | Description |
|---|---|---|
iss |
Issuer | Who created the token |
sub |
Subject | Who the token is about (usually user ID) |
aud |
Audience | Who the token is intended for |
exp |
Expiration | When the token expires (Unix timestamp) |
nbf |
Not Before | Token not valid before this time |
iat |
Issued At | When the token was created |
jti |
JWT ID | Unique identifier for the token |
Public Claims
Custom claims registered with IANA or using collision-resistant names:
{
"https://example.com/roles": ["admin", "user"]
}
Private Claims
Custom claims agreed upon by parties:
{
"user_id": "12345",
"department": "engineering"
}
Common JWT Claims in Practice
{
"iss": "https://auth.example.com",
"sub": "user_12345",
"aud": "https://api.example.com",
"exp": 1705312800,
"iat": 1705309200,
"email": "user@example.com",
"roles": ["user", "premium"],
"permissions": ["read:profile", "write:profile"]
}
JWT vs Session Cookies
| Feature | JWT | Session Cookie |
|---|---|---|
| Storage | Client (localStorage, cookie) | Server (database, memory) |
| Stateless | Yes | No |
| Scalability | Easier (no session store) | Requires shared session store |
| Revocation | Difficult | Easy (delete session) |
| Size | Larger (contains data) | Small (just session ID) |
| Cross-domain | Works well | Cookie restrictions |
JWTs shine in:
- Microservices architecture
- Mobile apps
- Single Page Applications (SPAs)
- Cross-domain authentication
Sessions are better when:
- You need immediate revocation
- Token size is a concern
- You're in a single-domain, traditional web app
Security Considerations
1. Signature Verification is Critical
Never trust a JWT without verifying the signature!
// WRONG - No verification
const payload = JSON.parse(atob(token.split('.')[1]));
// RIGHT - Verify first
const payload = jwt.verify(token, secret);
2. The "alg: none" Vulnerability
Some libraries accept "alg": "none", bypassing signature verification entirely.
// Always specify allowed algorithms
jwt.verify(token, secret, { algorithms: ['HS256'] });
3. Algorithm Confusion Attack
Attacker changes RS256 (asymmetric) to HS256 (symmetric) and signs with the public key (which is public).
// Specify the expected algorithm
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
4. Sensitive Data Exposure
Remember: payloads are encoded, not encrypted!
// NEVER include in JWT:
{
"password": "secret123",
"credit_card": "4111111111111111",
"ssn": "123-45-6789"
}
5. Token Lifetime
Short-lived tokens reduce risk:
- Access tokens: 15 minutes to 1 hour
- Refresh tokens: Days to weeks (with rotation)
Token Storage: localStorage vs Cookies
localStorage
localStorage.setItem('token', jwt);
const token = localStorage.getItem('token');
Pros: Easy to use, accessible from JavaScript Cons: Vulnerable to XSS attacks
HTTP-Only Cookies
// Server sets cookie
res.cookie('token', jwt, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'strict'
});
Pros: Protected from XSS Cons: Vulnerable to CSRF (mitigate with SameSite, CSRF tokens)
Recommendation: Use HTTP-only cookies when possible, with proper CSRF protection.
Refresh Token Pattern
Access tokens are short-lived; refresh tokens get new access tokens:
1. Login → Access Token (15 min) + Refresh Token (7 days)
2. API call with Access Token
3. Access Token expires
4. Exchange Refresh Token for new Access Token
5. Repeat...
6. Refresh Token expires → Re-login required
Benefits:
- Short-lived access tokens limit damage if stolen
- Refresh tokens can be revoked server-side
- Users don't re-login frequently
JWT in Code
Creating a JWT (Node.js)
const jwt = require('jsonwebtoken');
const token = jwt.sign(
{
sub: 'user_12345',
name: 'John Doe',
roles: ['user']
},
process.env.JWT_SECRET,
{
expiresIn: '1h',
issuer: 'my-app'
}
);
Verifying a JWT
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'my-app'
});
// Token is valid, use decoded.sub, decoded.name, etc.
} catch (err) {
if (err.name === 'TokenExpiredError') {
// Handle expiration
} else {
// Invalid token
}
}
Summary
JWTs are powerful tools for authentication:
- Three parts: Header, Payload, Signature (separated by dots)
- Base64URL encoded: Not encrypted—don't include sensitive data
- Signature verification: Always verify before trusting
- Common claims:
sub,exp,iat,iss,aud - Security: Watch for "alg: none", algorithm confusion, proper storage
Key decisions:
- Token lifetime (shorter = more secure)
- Storage method (HTTP-only cookies preferred)
- Refresh token strategy
- What claims to include
Need to decode a JWT? Try our JWT Decoder!