Intermediate

Session cookies work fine for traditional web apps, but modern REST APIs and microservices need a stateless authentication mechanism — one where the server does not store session state and any service in the cluster can verify a token independently. JSON Web Tokens (JWTs) are the standard answer. A JWT is a base64-encoded JSON payload with a cryptographic signature that any server can verify without hitting a database.

The PyJWT library is the most widely used Python implementation of the JWT standard (RFC 7519). It is used internally by AWS SDK, GitHub Actions, and dozens of popular frameworks. In this article you will learm to create signed tokens with HS256 and RS256, validate claims like expiry and audience, implement refresh token rotation, and wire JWTs into a FastAPI dependency.

PyJWT Quick Example: Issue and Verify in 10 Lines

# quick_jwt.py
import jwt, datetime

SECRET = "my-256-bit-secret"
payload = {
    "sub": "user_42",
    "exp": datetime.datetime.now(datetime.UTC) + datetime.timedelta(hours=1)
}

token = jwt.encode(payload, SECRET, algorithm="HS256")
print(f"Token: {token[:50]}...")

decoded = jwt.decode(token, SECRET, algorithms=["HS256"])
print(f"Subject: {decoded['sub']}")
Output:
Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIi...
Subject: user_42

Installing PyJWT

pip install PyJWT

# For RSA/EC algorithm support, install the cryptography extra:
pip install "PyJWT[cryptography]"

python -c "import jwt; print(jwt.__version__)"
Output:
2.8.0
AlgorithmKey TypeVerificationUse Case
HS256Shared secretSame secretSingle service, internal APIs
HS512Shared secretSame secretHigher security single service
RS256RSA private keyRSA public keyMicroservices, OAuth2, OIDC
ES256EC private keyEC public keyMobile, IoT (smaller tokens)
JWT HS256 wax seal verification
JWT: a self-contained passport that any server can verify.

Standard Claims and Expiry

JWTs have registered claim names defined by RFC 7519. PyJWT validates them automatically during decoding:

# jwt_claims.py
import jwt, datetime

SECRET = "production-secret-min-32-chars-long"

def create_access_token(user_id: str, roles: list) -> str:
    now = datetime.datetime.now(datetime.UTC)
    payload = {
        # Registered claims (RFC 7519)
        "iss": "https://api.myapp.com",     # Issuer
        "sub": user_id,                      # Subject (user identifier)
        "aud": "myapp-frontend",             # Audience
        "exp": now + datetime.timedelta(minutes=15),  # Expiry
        "iat": now,                          # Issued At
        "jti": f"{user_id}-{now.timestamp()}",  # JWT ID (unique per token)
        # Custom claims
        "roles": roles,
        "tier": "premium"
    }
    return jwt.encode(payload, SECRET, algorithm="HS256")

def verify_access_token(token: str) -> dict:
    return jwt.decode(
        token,
        SECRET,
        algorithms=["HS256"],
        audience="myapp-frontend",           # Must match aud claim
        options={"require": ["exp", "iat", "sub", "iss"]}
    )

token = create_access_token("user_42", ["read", "write"])
claims = verify_access_token(token)
print(f"User: {claims['sub']}, Roles: {claims['roles']}, Tier: {claims['tier']}")

# Test expiry
import time
expired_payload = {
    "sub": "user_1",
    "exp": datetime.datetime.now(datetime.UTC) - datetime.timedelta(seconds=1)
}
expired_token = jwt.encode(expired_payload, SECRET, algorithm="HS256")
try:
    jwt.decode(expired_token, SECRET, algorithms=["HS256"])
except jwt.ExpiredSignatureError as e:
    print(f"Rejected: {e}")
Output:
User: user_42, Roles: ['read', 'write'], Tier: premium
Rejected: Signature has expired.

RS256: Asymmetric JWT for Microservices

In a microservice architecture, each service needs to verify tokens independently without sharing a secret. RS256 lets a central auth service sign with its private key, and all other services verify with the public key:

# rs256_jwt.py
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import datetime

# Generate RSA key pair (in production: load from file/secret manager)
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()

# Auth service: sign with private key
payload = {
    "sub": "service_account_7",
    "scope": "billing:read orders:write",
    "exp": datetime.datetime.now(datetime.UTC) + datetime.timedelta(hours=1),
    "iss": "https://auth.myapp.com"
}
token = jwt.encode(payload, private_key, algorithm="RS256")
print(f"RS256 token length: {len(token)} chars")

# Any microservice: verify with public key only (no private key needed)
decoded = jwt.decode(
    token,
    public_key,
    algorithms=["RS256"],
    options={"verify_aud": False}
)
print(f"Verified service: {decoded['sub']}, scope: {decoded['scope']}")

# Serialize public key to PEM for distribution
pem = public_key.public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo
)
# Load back from PEM and verify again
loaded_pubkey = serialization.load_pem_public_key(pem)
decoded2 = jwt.decode(token, loaded_pubkey, algorithms=["RS256"], options={"verify_aud": False})
print(f"PEM round-trip OK: {decoded2['sub']}")
Output:
RS256 token length: 472 chars
Verified service: service_account_7, scope: billing:read orders:write
PEM round-trip OK: service_account_7
RS256 private key forge distributing public keys
RS256: one private key signs, unlimited services verify.

Access and Refresh Token Pattern

Short-lived access tokens (15 minutes) with long-lived refresh tokens (7 days) are the standard pattern for secure stateless authentication:

# token_pair.py
import jwt, datetime, secrets

SECRET = "your-secret-key-min-32-chars"
REFRESH_SECRET = "different-refresh-secret-min-32-chars"

def issue_token_pair(user_id: str) -> dict:
    now = datetime.datetime.now(datetime.UTC)
    access_token = jwt.encode({
        "sub": user_id,
        "type": "access",
        "exp": now + datetime.timedelta(minutes=15),
        "iat": now
    }, SECRET, algorithm="HS256")

    refresh_token = jwt.encode({
        "sub": user_id,
        "type": "refresh",
        "jti": secrets.token_hex(16),  # Unique ID for revocation
        "exp": now + datetime.timedelta(days=7),
        "iat": now
    }, REFRESH_SECRET, algorithm="HS256")

    return {"access_token": access_token, "refresh_token": refresh_token}

def refresh_access_token(refresh_token: str) -> str:
    payload = jwt.decode(refresh_token, REFRESH_SECRET, algorithms=["HS256"])
    if payload.get("type") != "refresh":
        raise ValueError("Not a refresh token")
    # In production: check jti against a revocation list in Redis
    now = datetime.datetime.now(datetime.UTC)
    return jwt.encode({
        "sub": payload["sub"],
        "type": "access",
        "exp": now + datetime.timedelta(minutes=15),
        "iat": now
    }, SECRET, algorithm="HS256")

tokens = issue_token_pair("user_42")
print(f"Access token (first 40 chars): {tokens['access_token'][:40]}...")

new_access = refresh_access_token(tokens["refresh_token"])
decoded = jwt.decode(new_access, SECRET, algorithms=["HS256"])
print(f"Refreshed token for: {decoded['sub']}, expires in 15 min")
Output:
Access token (first 40 chars): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIi...
Refreshed token for: user_42, expires in 15 min

Frequently Asked Questions

How long should my HS256 secret be?

At least 32 characters (256 bits) for HS256, and 64 characters for HS512. Use secrets.token_hex(32) to generate a cryptographically random 64-character hex secret. Short or dictionary-word secrets are vulnerable to offline brute-force attacks — an attacker who captures a token can try millions of secrets per second without hitting your server.

Where should I store JWTs on the client side?

For browser apps, httpOnly cookies are more secure than localStorage because JavaScript cannot read httpOnly cookies, preventing XSS attacks from stealing tokens. For mobile apps and server-to-server calls, storing in memory or secure device storage is fine. Never store tokens in localStorage if the site runs any third-party JavaScript. For refresh tokens specifically, httpOnly + Secure + SameSite=Strict cookies are the recommended pattern.

How do I revoke a JWT before it expires?

JWTs are stateless by design — the server does not store them, so you cannot “delete” one. The standard approaches are: keep access tokens very short-lived (15 minutes or less) so revocation is rarely needed; maintain a Redis blocklist of revoked jti values checked on each request; or use opaque tokens for security-critical resources and only use JWTs for low-risk claims. The blocklist approach reintroduces some statefulness but is still much lighter than full session storage.

What is the “none” algorithm attack?

Early JWT libraries accepted tokens with "alg": "none" — meaning no signature — allowing attackers to forge any payload. PyJWT is safe by default: you must explicitly list allowed algorithms in jwt.decode(), and none is never allowed unless you pass algorithms=["none"] explicitly. Always pass the algorithms parameter and never include "none" in it. Similarly, never pass the encoded token’s header algorithm as the allowed algorithm — always hardcode the expected algorithm on the server.

How do I use PyJWT with FastAPI?

Create a dependency that reads the Authorization: Bearer <token> header, calls jwt.decode(), and either returns the claims dict or raises HTTPException(401) on failure. Use Depends(verify_token) in any route that requires authentication. FastAPI’s dependency injection system handles the rest — the dependency runs before the route handler, and any exception it raises short-circuits the request.

Conclusion

PyJWT makes stateless authentication straightforward in Python. For single-service APIs, HS256 with a 32+ character secret and 15-minute expiry is simple and secure. For microservices where multiple independent services need to verify tokens, RS256 lets you distribute the public key freely while keeping the signing key private. In both cases, always specify algorithms explicitly in jwt.decode(), always validate exp and iss, and use short-lived access tokens with refresh token rotation for production systems. The jti claim combined with a Redis blocklist gives you revocation capability when you need it without abandoning the stateless model entirely.