JWT Authentication in Python — Deep Dive

Token structure under the hood

A JWT like eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiJ9.signature decodes to three JSON objects. The header {"alg": "HS256", "typ": "JWT"} tells the verifier which algorithm to use. The payload {"sub": "42"} carries your claims. The signature is HMAC-SHA256(base64url(header) + "." + base64url(payload), secret).

Base64url encoding (not standard base64) replaces + with - and / with _, making tokens URL-safe without percent-encoding.

Signing algorithms: symmetric vs. asymmetric

HS256 (HMAC-SHA256) uses one shared secret for both signing and verification. It’s fast and simple but requires every service that verifies tokens to hold the same secret. If that secret leaks from any service, an attacker can forge tokens.

RS256 (RSA-SHA256) uses a private key to sign and a public key to verify. Only the auth service holds the private key; all other services only need the public key. This is the standard pattern for microservices. Token forging requires the private key, and public keys can be distributed freely via JWKS endpoints.

ES256 (ECDSA-P256) offers the same asymmetric benefit as RS256 with smaller keys and faster verification. A 256-bit EC key provides comparable security to a 3072-bit RSA key.

import jwt
from datetime import datetime, timedelta, timezone

# HS256 — symmetric
secret = "your-256-bit-secret"
payload = {
    "sub": "user_42",
    "role": "admin",
    "iat": datetime.now(timezone.utc),
    "exp": datetime.now(timezone.utc) + timedelta(minutes=15),
}
token = jwt.encode(payload, secret, algorithm="HS256")

# RS256 — asymmetric
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization

private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()

token_rsa = jwt.encode(payload, private_key, algorithm="RS256")
decoded = jwt.decode(token_rsa, public_key, algorithms=["RS256"])

Always specify algorithms as a list when decoding. Without it, an attacker could craft a token with "alg": "none" or switch from RS256 to HS256 using the public key as a secret — a well-known vulnerability called the algorithm confusion attack.

Claims and validation

Standard registered claims (sub, exp, iat, iss, aud, nbf, jti) have defined semantics in RFC 7519. PyJWT validates exp and nbf automatically. You should also validate iss and aud explicitly:

decoded = jwt.decode(
    token,
    secret,
    algorithms=["HS256"],
    issuer="auth.myapp.com",
    audience="api.myapp.com",
    options={"require": ["exp", "iss", "aud", "sub"]},
)

The options.require list forces these claims to be present. Without it, a token missing exp would be accepted as never-expiring.

Custom claims carry application-specific data: user roles, tenant IDs, feature flags. Keep them minimal — every claim increases token size, and tokens travel with every request.

The refresh token pattern

Short-lived access tokens (15 minutes) combined with longer-lived refresh tokens (7-30 days) balance security and usability.

def create_token_pair(user_id: str, secret: str) -> dict:
    now = datetime.now(timezone.utc)
    access = jwt.encode(
        {"sub": user_id, "type": "access", "exp": now + timedelta(minutes=15)},
        secret,
        algorithm="HS256",
    )
    refresh = jwt.encode(
        {"sub": user_id, "type": "refresh", "exp": now + timedelta(days=7)},
        secret,
        algorithm="HS256",
    )
    return {"access_token": access, "refresh_token": refresh}


def refresh_access_token(refresh_token: str, secret: str) -> str:
    payload = jwt.decode(refresh_token, secret, algorithms=["HS256"])
    if payload.get("type") != "refresh":
        raise ValueError("Not a refresh token")
    return jwt.encode(
        {
            "sub": payload["sub"],
            "type": "access",
            "exp": datetime.now(timezone.utc) + timedelta(minutes=15),
        },
        secret,
        algorithm="HS256",
    )

The refresh endpoint should also rotate the refresh token itself (issue a new one and invalidate the old). This limits the damage if a refresh token is stolen — the real user’s next refresh attempt fails, alerting them to the breach.

Revocation strategies

Since JWTs are stateless, revocation requires extra infrastructure:

Token blocklist (denylist): Store revoked token IDs (jti claim) in Redis with a TTL matching the token’s remaining lifetime. Check the blocklist on every request. This is a small, fast lookup — far lighter than full session storage.

import redis

r = redis.Redis()

def revoke_token(token: str, secret: str):
    payload = jwt.decode(token, secret, algorithms=["HS256"])
    jti = payload["jti"]
    exp = payload["exp"]
    ttl = exp - int(datetime.now(timezone.utc).timestamp())
    if ttl > 0:
        r.setex(f"revoked:{jti}", ttl, "1")

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

Short expiration + no blocklist: Accept that tokens live for their full lifetime (e.g., 5 minutes). For most apps, a 5-minute window is acceptable.

Token versioning: Store a version counter per user. Include the version in the token. Incrementing the counter invalidates all existing tokens for that user. Requires one database read per request (the version check), but it’s a fast integer comparison.

FastAPI integration example

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

app = FastAPI()
security = HTTPBearer()

def get_current_user(
    credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
    try:
        payload = jwt.decode(
            credentials.credentials,
            "secret-key",
            algorithms=["HS256"],
            issuer="auth.myapp.com",
        )
    except jwt.ExpiredSignatureError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Token expired",
        )
    except jwt.InvalidTokenError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid token",
        )
    return payload

@app.get("/profile")
def profile(user: dict = Depends(get_current_user)):
    return {"user_id": user["sub"], "role": user.get("role")}

This pattern centralizes token validation in a dependency. Every protected route gets the verified user payload injected automatically.

Security pitfalls to avoid

Storing secrets in code. Use environment variables or a secrets manager. Rotate secrets periodically — PyJWT supports decoding with a list of keys, so you can accept tokens signed with the old key during a rotation window.

Not validating the alg header. Always pass algorithms=["HS256"] (or whichever you use). Never let the token dictate the algorithm.

Massive payloads. JWTs travel in HTTP headers. Headers over 8 KB get rejected by many proxies and load balancers. Keep tokens under 1 KB.

Using JWTs for sessions that need server-side control. If you need “log out everywhere” or “revoke this specific session” as core features, a server-side session store (Redis, database) with opaque session IDs is simpler and more reliable than bolting revocation onto JWTs.

Performance considerations

Token generation with HS256 takes roughly 0.02ms on modern hardware. RS256 signing takes 1-2ms; verification takes 0.05ms (public key operations are fast). For high-throughput APIs doing thousands of requests per second, HS256’s speed advantage matters.

Token size: a minimal HS256 token is about 150 bytes. With typical claims (user ID, role, email, expiration), tokens run 300-500 bytes. RS256 tokens are larger due to the signature format — typically 500-800 bytes.

The one thing to remember: Choose your signing algorithm based on your architecture (HS256 for single services, RS256/ES256 for distributed systems), always validate the algorithm explicitly, and pair short-lived access tokens with rotating refresh tokens for a practical balance of security and usability.

pythonsecurityauthenticationweb

See Also