JSON Web Tokens (JWT)
TL;DR
JWTs are self-contained tokens that encode claims as JSON, signed to ensure integrity. They enable stateless authentication but come with trade-offs around revocation and size. Most JWT security issues stem from implementation errors, not protocol flaws.
JWT Structure
A JWT consists of three base64url-encoded parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4iLCJpYXQiOjE1MTYyMzkwMjJ9.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header.Payload.SignatureHeader
{
"alg": "HS256", // Signing algorithm
"typ": "JWT" // Token type
}Common algorithms:
- HS256: HMAC with SHA-256 (symmetric)
- RS256: RSA signature with SHA-256 (asymmetric)
- ES256: ECDSA with SHA-256 (asymmetric)
Payload (Claims)
{
"iss": "https://auth.example.com", // Issuer
"sub": "user_12345", // Subject (user ID)
"aud": "my_api", // Audience
"exp": 1704067200, // Expiration (Unix timestamp)
"iat": 1704063600, // Issued at
"nbf": 1704063600, // Not valid before
"jti": "unique-token-id", // JWT ID (for revocation)
// Custom claims
"role": "admin",
"permissions": ["read", "write"]
}Signature
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)Symmetric vs. Asymmetric Signing
Symmetric (HS256)
Same secret for signing and verification.
┌─────────────────┐ ┌─────────────────┐
│ Auth Server │ │ API Server │
│ (signs JWT) │ │ (verifies JWT) │
│ │ │ │
│ secret: xyz │ │ secret: xyz │
└─────────────────┘ └─────────────────┘
Problem: Every service that verifies needs the secret
If any service is compromised, attacker can forge tokensAsymmetric (RS256/ES256)
Private key signs, public key verifies.
┌─────────────────┐ ┌─────────────────┐
│ Auth Server │ │ API Server │
│ (signs JWT) │ │ (verifies JWT) │
│ │ │ │
│ PRIVATE key │ │ PUBLIC key │
│ (kept secret) │ │ (shareable) │
└─────────────────┘ └─────────────────┘
Advantage:
- Only auth server can create tokens
- Compromised API server can't forge tokens
- Public keys can be published via JWKSWhen to Use Which
| Scenario | Recommendation |
|---|---|
| Single monolithic app | HS256 (simpler) |
| Microservices | RS256/ES256 |
| Third-party integration | RS256/ES256 |
| High-security environments | ES256 (smaller, faster) |
Creating JWTs
Shell-Level Construction
Build a JWT by hand to understand the structure:
# 1. Create the header
HEADER=$(echo -n '{"alg":"HS256","typ":"JWT"}' | base64 | tr '+/' '-_' | tr -d '=')
# 2. Create the payload
NOW=$(date +%s)
EXP=$((NOW + 3600))
PAYLOAD=$(echo -n "{\"sub\":\"user_123\",\"iat\":$NOW,\"exp\":$EXP,\"iss\":\"my-auth-server\",\"aud\":\"my-api\",\"role\":\"admin\"}" \
| base64 | tr '+/' '-_' | tr -d '=')
# 3. Create the signature (HS256 = HMAC-SHA256)
SIGNATURE=$(echo -n "$HEADER.$PAYLOAD" \
| openssl dgst -sha256 -hmac "your-256-bit-secret" -binary \
| base64 | tr '+/' '-_' | tr -d '=')
# 4. Assemble the JWT
JWT="$HEADER.$PAYLOAD.$SIGNATURE"
echo "$JWT"For RS256 (asymmetric), sign with a private key instead:
# Generate an RSA key pair (one-time setup)
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem
# Header for RS256
HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | base64 | tr '+/' '-_' | tr -d '=')
# Sign with the private key
SIGNATURE=$(echo -n "$HEADER.$PAYLOAD" \
| openssl dgst -sha256 -sign private.pem -binary \
| base64 | tr '+/' '-_' | tr -d '=')
JWT="$HEADER.$PAYLOAD.$SIGNATURE"Node.js Example (jsonwebtoken)
const jwt = require('jsonwebtoken');
// Create token
const token = jwt.sign(
{
sub: 'user_123',
role: 'admin'
},
process.env.JWT_SECRET,
{
algorithm: 'HS256',
expiresIn: '1h',
issuer: 'my-auth-server',
audience: 'my-api'
}
);Validating JWTs
Validation Checklist
# 1. Decode header and payload (does NOT verify signature)
HEADER=$(echo "$JWT" | cut -d. -f1 | base64 -d 2>/dev/null)
CLAIMS=$(echo "$JWT" | cut -d. -f2 | base64 -d 2>/dev/null)
echo "$HEADER" | jq
echo "$CLAIMS" | jq
# 2. Verify algorithm is expected (reject 'none' or unexpected algorithms)
ALG=$(echo "$HEADER" | jq -r '.alg')
[ "$ALG" = "RS256" ] || { echo "Unexpected algorithm: $ALG"; exit 1; }
# 3. Verify standard claims
echo "$CLAIMS" | jq -e '.iss == "my-auth-server"' # Issuer
echo "$CLAIMS" | jq -e '.aud == "my-api"' # Audience
echo "$CLAIMS" | jq -e ".exp > $(date +%s)" # Not expired
echo "$CLAIMS" | jq -e 'has("sub", "iat")' # Required claims present
# 4. Verify signature (RS256) — fetch JWKS, then verify with openssl
curl -s https://auth.example.com/.well-known/jwks.json | jq '.keys[0]'
# Extract the public key matching the "kid" from the header, then:
echo -n "$(echo "$JWT" | cut -d. -f1-2)" \
| openssl dgst -sha256 -verify public.pem \
-signature <(echo "$JWT" | cut -d. -f3 | tr '_-' '/+' | base64 -d 2>/dev/null)
# 5. Additional business logic
ROLE=$(echo "$CLAIMS" | jq -r '.role')
[[ "$ROLE" == "admin" || "$ROLE" == "user" ]] || { echo "Invalid role"; exit 1; }Critical: Always Specify Algorithm
VULNERABLE: Libraries that read "alg" from the token header and trust it.
- Attacker sets alg=none → unsigned token accepted
- Attacker sets alg=HS256 when server expects RS256 →
uses public key as HMAC secret to forge tokens
SECURE: Always enforce expected algorithm on the verification side.
- Check the header "alg" matches exactly what you expect
- Never allow 'none'
- Never allow both symmetric and asymmetric algorithmsJWT Security Vulnerabilities
1. Algorithm Confusion Attack
Attack scenario:
1. Server expects RS256 (asymmetric)
2. Attacker takes PUBLIC key (which is public)
3. Attacker creates token with alg=HS256
4. Attacker signs with public key as HMAC secret
5. Server (misconfigured) verifies HS256 using public key as secret
6. Signature matches! Attacker forges tokens.
Prevention:
- NEVER accept algorithm from token header
- Always specify expected algorithm in verification2. None Algorithm Attack
Attack scenario:
1. Attacker sets header: {"alg": "none"}
2. Attacker removes signature
3. Poorly configured library accepts unsigned token
Prevention:
- Explicitly specify algorithms=['RS256'] in decode
- Never include 'none' in allowed algorithms3. Weak Secrets
# BAD - easily brute-forced
SECRET="secret"
SECRET="password123"
# GOOD - cryptographically random (256 bits)
SECRET=$(openssl rand -hex 32)
echo "$SECRET"
# e.g. a3f1b7c9d4e8f2...64 hex chars (32 bytes = 256 bits)Brute Force Reality:
Secret length | Time to crack (modern GPU)
---------------|---------------------------
8 chars | Seconds to minutes
16 chars | Days to weeks
32 chars | Computationally infeasible4. Token Stored in Vulnerable Location
// BAD - XSS can steal token
localStorage.setItem('token', jwt);
// BAD - Same issue
sessionStorage.setItem('token', jwt);
// BETTER - Not accessible via JavaScript
// Set via HttpOnly cookie from server
// BEST - Keep in memory, use refresh token rotation
let accessToken = null; // In-memory only5. No Expiration or Too Long
NOW=$(date +%s)
# BAD - No expiration
PAYLOAD='{"sub":"user123"}'
# BAD - 30-day access token
PAYLOAD="{\"sub\":\"user123\",\"exp\":$((NOW + 2592000))}"
# GOOD - Short-lived access token (15 minutes)
PAYLOAD="{\"sub\":\"user123\",\"exp\":$((NOW + 900))}"Token Revocation Strategies
JWTs are stateless - by design, you can't revoke them. Here are workarounds:
Strategy 1: Short Expiration + Refresh Tokens
Access Token: 15 minutes
Refresh Token: 7 days (stored in DB, revocable)
Flow:
1. User logs out
2. Delete refresh token from DB
3. Access token still valid for up to 15 min (acceptable)
4. After 15 min, refresh fails, user must re-loginStrategy 2: Token Blacklist
# Redis-based blacklist — revoke a token by its jti claim
JTI=$(echo "$JWT" | cut -d. -f2 | base64 -d 2>/dev/null | jq -r '.jti')
EXP=$(echo "$JWT" | cut -d. -f2 | base64 -d 2>/dev/null | jq -r '.exp')
TTL=$((EXP - $(date +%s)))
# Add to blacklist with TTL matching token expiration
redis-cli SETEX "blacklist:$JTI" "$TTL" "revoked"
# On every request, check if the token is blacklisted
redis-cli EXISTS "blacklist:$JTI"
# Returns 1 → token revoked, reject with 401
# Returns 0 → token not revoked, proceedTrade-off: Adds database lookup to every request, partially negating stateless benefit.
Strategy 3: Token Versioning
# Store token version per user in DB/cache.
# When user logs out or changes password, increment the version.
# Token creation — include current version in the payload:
# {"sub":"user_123","token_version":3,"exp":...}
# Token validation — decode and compare version against DB:
TOKEN_VER=$(echo "$JWT" | cut -d. -f2 | base64 -d 2>/dev/null | jq -r '.token_version')
USER_ID=$(echo "$JWT" | cut -d. -f2 | base64 -d 2>/dev/null | jq -r '.sub')
# Fetch current version from DB/cache (e.g., Redis)
CURRENT_VER=$(redis-cli GET "user:$USER_ID:token_version")
# If they don't match, the token has been invalidated
[ "$TOKEN_VER" = "$CURRENT_VER" ] \
&& echo "Token version valid" \
|| echo "Token invalidated — return 401"Strategy 4: Hybrid Approach
Short-lived JWT (15 min) for most requests
↓ Expired?
Refresh with refresh token (checked against DB)
↓ Valid?
Issue new access token
↓ Invalid?
Force re-authentication
Critical actions (password change, payment):
- Always verify against DB regardless of JWT validityJWT Size Considerations
JWTs can get large, impacting performance.
Size Breakdown
Typical JWT:
Header: ~36 bytes (base64)
Payload: ~200-500 bytes (base64)
Signature: ~86 bytes (RS256) or ~43 bytes (HS256)
Total: ~300-700 bytes
Problematic JWT (too many claims):
Payload with roles, permissions, user data: 2-4 KBSize Impact
Every HTTP request includes JWT in header:
Authorization: Bearer <token>
If token is 2KB and user makes 100 requests:
200KB of bandwidth just for tokens
Mobile/slow networks: Significant latency impactSize Reduction Strategies
// BAD — embedding all user data inflates the token
{
"sub": "user123",
"name": "John Doe",
"email": "john@example.com",
"address": { "...": "..." },
"permissions": ["read:users", "write:users", "...50 more..."],
"roles": ["admin", "manager"]
}
// GOOD — minimal claims, fetch details when needed
{
"sub": "user123",
"role": "admin",
"exp": 1704067200
}
// Fetch full permissions from cache/DB when neededAccess Token vs. ID Token
Access Token
- Purpose: Authorize access to resources
- Audience: Resource server (API)
- Contents: Permissions, scopes
- Validation: Resource server validates
- Opacity: Can be opaque (not JWT) or JWT
ID Token (OpenID Connect)
- Purpose: Authenticate user identity
- Audience: Client application
- Contents: User identity claims
- Validation: Client validates
- Format: Always JWT
# Access token — for API calls to the resource server
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
https://api.example.com/data
# ID token — decode locally to get user info in the client
echo "$ID_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq
# {
# "sub": "user_12345",
# "email": "john@example.com",
# "name": "John Doe",
# ...
# }Important: Never send ID token to resource servers. It's not for authorization.
Implementation Patterns
Middleware Pattern
# Validate JWT before accessing a protected endpoint
TOKEN="$1" # Passed as argument or extracted from request
# 1. Check token is present
[ -z "$TOKEN" ] && { echo '{"error":"Missing token"}'; exit 1; }
# 2. Decode and verify claims
CLAIMS=$(echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null)
ALG=$(echo "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null | jq -r '.alg')
[ "$ALG" = "RS256" ] || { echo '{"error":"Invalid algorithm"}'; exit 1; }
echo "$CLAIMS" | jq -e ".exp > $(date +%s)" > /dev/null 2>&1 \
|| { echo '{"error":"Token expired"}'; exit 1; }
echo "$CLAIMS" | jq -e '.aud == "my-api"' > /dev/null 2>&1 \
|| { echo '{"error":"Invalid audience"}'; exit 1; }
# 3. Call the protected resource
curl -s -H "Authorization: Bearer $TOKEN" https://api.example.com/protected
# {"user": "user_123"}Scope-Based Authorization
# Verify the token has the required scope before allowing access
REQUIRED_SCOPE="admin:read"
CLAIMS=$(echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null)
SCOPES=$(echo "$CLAIMS" | jq -r '.scope')
echo "$SCOPES" | tr ' ' '\n' | grep -qx "$REQUIRED_SCOPE" \
&& curl -s -H "Authorization: Bearer $TOKEN" https://api.example.com/admin \
|| echo '{"error":"Insufficient scope"} # 403 Forbidden'Node.js Example (Express Middleware)
const jwt = require('jsonwebtoken');
function requireAuth(req, res, next) {
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'Missing token' });
try {
req.user = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
audience: 'my-api'
});
next();
} catch (err) {
res.status(401).json({ error: err.message });
}
}
function requireScope(scope) {
return [requireAuth, (req, res, next) => {
const scopes = (req.user.scope || '').split(' ');
if (!scopes.includes(scope)) {
return res.status(403).json({ error: 'Insufficient scope' });
}
next();
}];
}
app.get('/protected', requireAuth, (req, res) => {
res.json({ user: req.user.sub });
});
app.get('/admin', ...requireScope('admin:read'), (req, res) => {
res.json({ admin: true });
});Testing JWTs
Generating Test Tokens
# Helper: create a test JWT (HS256) with optional claim overrides
create_test_token() {
local NOW=$(date +%s)
local EXP=${1:-$((NOW + 3600))}
local AUD=${2:-"test-audience"}
local HEADER=$(echo -n '{"alg":"HS256","typ":"JWT"}' | base64 | tr '+/' '-_' | tr -d '=')
local PAYLOAD=$(echo -n "{\"sub\":\"test_user\",\"iat\":$NOW,\"exp\":$EXP,\"iss\":\"test-issuer\",\"aud\":\"$AUD\"}" \
| base64 | tr '+/' '-_' | tr -d '=')
local SIG=$(echo -n "$HEADER.$PAYLOAD" \
| openssl dgst -sha256 -hmac "test-secret" -binary \
| base64 | tr '+/' '-_' | tr -d '=')
echo "$HEADER.$PAYLOAD.$SIG"
}
# Test: expired token should return 401
EXPIRED_TOKEN=$(create_test_token $(($(date +%s) - 3600)))
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $EXPIRED_TOKEN" \
https://api.example.com/protected
# Expected: 401
# Test: wrong audience should return 401
WRONG_AUD_TOKEN=$(create_test_token $(($(date +%s) + 3600)) "wrong-audience")
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $WRONG_AUD_TOKEN" \
https://api.example.com/protected
# Expected: 401JWT Debugging
# Decode JWT without verification (for debugging only!)
echo "eyJhbGciOiJIUzI1NiIs..." | cut -d. -f2 | base64 -d | jq
# Or use jwt.io (NEVER paste production tokens!)Best Practices Summary
Token Creation:
□ Use RS256/ES256 for distributed systems
□ Include standard claims (iss, sub, aud, exp, iat)
□ Keep payload minimal
□ Use cryptographically strong secrets (≥256 bits)
□ Short expiration (15 min for access tokens)
Token Validation:
□ Always specify allowed algorithms explicitly
□ Validate all standard claims (iss, aud, exp)
□ Use constant-time comparison for signatures
□ Handle validation errors gracefully
Storage:
□ Never store in localStorage/sessionStorage
□ Use HttpOnly cookies or in-memory storage
□ Implement secure refresh token rotation
Revocation:
□ Implement refresh token rotation
□ Consider token blacklist for critical apps
□ Increment token version on security events