Skip to content

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.Signature
json
{
    "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)

json
{
    "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 tokens

Asymmetric (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 JWKS

When to Use Which

ScenarioRecommendation
Single monolithic appHS256 (simpler)
MicroservicesRS256/ES256
Third-party integrationRS256/ES256
High-security environmentsES256 (smaller, faster)

Creating JWTs

Shell-Level Construction

Build a JWT by hand to understand the structure:

bash
# 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:

bash
# 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)

javascript
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

bash
# 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 algorithms

JWT 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 verification

2. 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 algorithms

3. Weak Secrets

bash
# 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 infeasible

4. Token Stored in Vulnerable Location

javascript
// 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 only

5. No Expiration or Too Long

bash
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-login

Strategy 2: Token Blacklist

bash
# 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, proceed

Trade-off: Adds database lookup to every request, partially negating stateless benefit.

Strategy 3: Token Versioning

bash
# 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 validity

JWT 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 KB

Size 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 impact

Size Reduction Strategies

json
// 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 needed

Access 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
bash
# 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

bash
# 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

bash
# 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)

javascript
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

bash
# 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: 401

JWT Debugging

bash
# 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

References

Built with depth by the Babushkai community. Released under the MIT License.