JSON Web Tokens are everywhere in modern authentication. They’re stateless, portable, and self-contained. They’re also easy to implement insecurely.

These practices help you use JWTs without shooting yourself in the foot.

JWT Structure

A JWT has three parts, base64-encoded and dot-separated:

heeyaJdhebrG.cpiaOyilJoIaUdz.Is1iNginJa9t.ueryeJzdWIiOiIxMjMifQ.sIG5n8l7...

Header:

1
2
3
4
{
  "alg": "HS256",
  "typ": "JWT"
}

Payload (claims):

1
2
3
4
5
{
  "sub": "user_123",
  "iat": 1704067200,
  "exp": 1704153600
}

Signature: HMAC or RSA signature of header + payload.

Basic Implementation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import jwt
from datetime import datetime, timedelta

SECRET_KEY = os.environ["JWT_SECRET"]  # Use strong secret
ALGORITHM = "HS256"

def create_token(user_id: str, expires_minutes: int = 60) -> str:
    payload = {
        "sub": user_id,
        "iat": datetime.utcnow(),
        "exp": datetime.utcnow() + timedelta(minutes=expires_minutes),
        "type": "access"
    }
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)

def verify_token(token: str) -> dict:
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        return payload
    except jwt.ExpiredSignatureError:
        raise AuthError("Token expired")
    except jwt.InvalidTokenError:
        raise AuthError("Invalid token")

Use Strong Secrets

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# ❌ Bad: Weak secret
SECRET_KEY = "secret"
SECRET_KEY = "my-jwt-secret"

# ✅ Good: Strong random secret (at least 256 bits)
import secrets
SECRET_KEY = secrets.token_hex(32)  # 64 hex chars = 256 bits

# Or generate once and store in environment
# openssl rand -hex 32

For HS256, the secret should be at least as long as the hash output (256 bits).

Algorithm Confusion Attack

Always specify allowed algorithms:

1
2
3
4
5
# ❌ Bad: Accepts any algorithm
payload = jwt.decode(token, SECRET_KEY)

# ✅ Good: Explicitly allow only expected algorithms
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])

Attackers can forge tokens by changing the algorithm to “none” or switching from RS256 to HS256 (using the public key as a symmetric secret).

Short-Lived Access Tokens

1
2
3
4
5
# Access token: short-lived (15 min - 1 hour)
access_token = create_token(user_id, expires_minutes=15)

# Refresh token: longer-lived (days to weeks), stored securely
refresh_token = create_token(user_id, expires_minutes=10080, token_type="refresh")

Short access tokens limit damage from stolen tokens. Refresh tokens enable seamless re-authentication.

Refresh Token Flow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def create_token_pair(user_id: str) -> dict:
    access_token = jwt.encode({
        "sub": user_id,
        "exp": datetime.utcnow() + timedelta(minutes=15),
        "type": "access"
    }, SECRET_KEY, algorithm=ALGORITHM)
    
    refresh_token = jwt.encode({
        "sub": user_id,
        "exp": datetime.utcnow() + timedelta(days=7),
        "type": "refresh",
        "jti": secrets.token_hex(16)  # Unique ID for revocation
    }, SECRET_KEY, algorithm=ALGORITHM)
    
    # Store refresh token ID for revocation checking
    store_refresh_token(user_id, refresh_token["jti"])
    
    return {"access_token": access_token, "refresh_token": refresh_token}

def refresh_access_token(refresh_token: str) -> str:
    payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
    
    if payload.get("type") != "refresh":
        raise AuthError("Invalid token type")
    
    # Check if refresh token is revoked
    if is_token_revoked(payload["jti"]):
        raise AuthError("Token revoked")
    
    # Issue new access token
    return create_token(payload["sub"], expires_minutes=15)

Token Revocation

JWTs are stateless — you can’t invalidate them directly. Options:

1. Short expiry (simplest)

1
2
# 15-minute tokens mean maximum 15 minutes of unauthorized access
access_token = create_token(user_id, expires_minutes=15)

2. Blocklist for critical cases

1
2
3
4
5
6
7
8
# Store revoked token IDs (jti) in Redis
def revoke_token(token: str):
    payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    ttl = payload["exp"] - int(datetime.utcnow().timestamp())
    redis.setex(f"revoked:{payload['jti']}", ttl, "1")

def is_revoked(jti: str) -> bool:
    return redis.exists(f"revoked:{jti}")

3. Token version per user

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Store version in user record, increment on logout/password change
def create_token(user_id: str, token_version: int):
    return jwt.encode({
        "sub": user_id,
        "ver": token_version,
        "exp": datetime.utcnow() + timedelta(minutes=15)
    }, SECRET_KEY, algorithm=ALGORITHM)

def verify_token(token: str):
    payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    user = get_user(payload["sub"])
    if payload["ver"] != user.token_version:
        raise AuthError("Token invalidated")
    return payload

What to Put in the Payload

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# ✅ Good: Minimal, non-sensitive data
{
    "sub": "user_123",           # User identifier
    "iat": 1704067200,           # Issued at
    "exp": 1704153600,           # Expiration
    "type": "access",            # Token type
    "roles": ["user", "admin"]   # Permissions (if needed)
}

# ❌ Bad: Sensitive data
{
    "sub": "user_123",
    "email": "user@example.com",  # PII
    "ssn": "123-45-6789",         # Sensitive!
    "password_hash": "..."        # Never!
}

JWTs are encoded, not encrypted. Anyone can decode the payload.

Secure Token Storage (Browser)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ❌ Bad: localStorage (accessible to XSS)
localStorage.setItem('token', accessToken);

// ✅ Better: httpOnly cookie (not accessible to JavaScript)
// Set from server:
// Set-Cookie: access_token=xxx; HttpOnly; Secure; SameSite=Strict

// ✅ Also acceptable: In-memory only (lost on refresh)
let accessToken = null;
// Store refresh token in httpOnly cookie for persistence

RS256 vs HS256

HS256 (HMAC): Symmetric — same secret for signing and verifying.

1
2
3
# Good for single-service auth
token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])

RS256 (RSA): Asymmetric — private key signs, public key verifies.

1
2
3
# Good for microservices — auth service has private key, others have public
token = jwt.encode(payload, PRIVATE_KEY, algorithm="RS256")
payload = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])

RS256 is better when multiple services need to verify tokens but shouldn’t be able to create them.

FastAPI Integration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

security = HTTPBearer()

async def get_current_user(
    credentials: HTTPAuthorizationCredentials = Depends(security)
) -> dict:
    try:
        payload = jwt.decode(
            credentials.credentials,
            SECRET_KEY,
            algorithms=[ALGORITHM]
        )
        return payload
    except jwt.InvalidTokenError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid token"
        )

@app.get("/protected")
async def protected_route(user: dict = Depends(get_current_user)):
    return {"user_id": user["sub"]}

Common Mistakes

Not validating expiration

1
2
3
4
5
# ❌ Library might not check exp by default
payload = jwt.decode(token, SECRET_KEY)

# ✅ Ensure exp is validated (most libraries do this by default)
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])

Trusting client-provided data

1
2
3
4
5
# ❌ Bad: Using claims without verification
user_id = request.headers.get("X-User-ID")

# ✅ Good: Always get user ID from verified token
user_id = verified_token["sub"]

Not rotating secrets

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Support multiple secrets during rotation
CURRENT_SECRET = os.environ["JWT_SECRET"]
OLD_SECRET = os.environ.get("JWT_SECRET_OLD")

def verify_token(token: str):
    for secret in [CURRENT_SECRET, OLD_SECRET]:
        if secret:
            try:
                return jwt.decode(token, secret, algorithms=["HS256"])
            except jwt.InvalidTokenError:
                continue
    raise AuthError("Invalid token")

JWTs are a tool, not a security silver bullet. Use strong secrets. Keep tokens short-lived. Specify algorithms explicitly. Don’t store sensitive data in payloads. Plan for revocation.

The JWT that “just works” in development might be leaking data or accepting forged tokens in production. Test your implementation against known attacks.