backend / authentication / jwt / 06_python_pyjwt.md

Python JWT with PyJWT

7 interview angles 5 min read source

Python JWT with PyJWT

PyJWT is the de-facto standard JWT library for Python. Maintained, well-documented, widely used. The alternatives (python-jose, authlib) work but PyJWT is the default choice.

Install

pip install "PyJWT[crypto]"

[crypto] installs cryptography — needed for RS256, ES256, EdDSA. Without it, only HMAC works. Always include [crypto] unless you’re certain you only need HMAC.

Minimal example — HS256

import jwt
import datetime

SECRET = "supersecret"  # use os.environ in production

# Encode
token = jwt.encode(
    {
        "sub": "user_42",
        "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1),
    },
    SECRET,
    algorithm="HS256",
)
print(token)

# Decode
payload = jwt.decode(token, SECRET, algorithms=["HS256"])
print(payload)

algorithms=[...] is mandatory in modern PyJWT — without it, decode raises. The list is your allowlist; don’t rely on the token’s own alg.

RS256 — asymmetric signing

from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa

# Generate keys (one-time, store securely)
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_pem = private_key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.PKCS8,
    encryption_algorithm=serialization.NoEncryption(),
)
public_pem = private_key.public_key().public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo,
)

# Sign with private key
token = jwt.encode({"sub": "user_42"}, private_pem, algorithm="RS256")

# Verify with public key
payload = jwt.decode(token, public_pem, algorithms=["RS256"])

Distribute public_pem to verifiers. Keep private_pem on the issuer only.

For real apps: store keys in secret managers (AWS Secrets Manager, Vault, environment variables encrypted at rest). Never commit them to git.

Validating all the claims

payload = jwt.decode(
    token,
    public_pem,
    algorithms=["RS256"],
    audience="myapp.example.com",
    issuer="https://auth.example.com",
    options={
        "require": ["exp", "iat", "sub", "aud", "iss"],
        "verify_exp": True,
        "verify_nbf": True,
        "verify_iat": True,
        "verify_aud": True,
        "verify_iss": True,
    },
    leeway=30,    # 30s tolerance for clock skew
)

PyJWT validates exp, nbf, aud, iss automatically when you pass them. require enforces that specific claims must be present.

Without explicit audience and issuer parameters, those checks don’t run. Always pass them.

Catching specific exceptions

import jwt
from jwt.exceptions import (
    ExpiredSignatureError,
    InvalidSignatureError,
    InvalidAudienceError,
    InvalidIssuerError,
    DecodeError,
    PyJWTError,
)

try:
    payload = jwt.decode(token, key, algorithms=["RS256"], audience="myapp")
except ExpiredSignatureError:
    return {"error": "token_expired"}, 401
except InvalidAudienceError:
    return {"error": "wrong_audience"}, 401
except InvalidSignatureError:
    return {"error": "invalid_signature"}, 401
except PyJWTError as e:
    return {"error": "invalid_token", "detail": str(e)}, 401

PyJWTError is the base for all PyJWT exceptions. Catch it at minimum; specific subclasses for fine-grained handling.

JWKS — verify with rotating keys (OIDC)

For OIDC providers (Okta, Auth0, Cognito), keys rotate periodically. Use PyJWKClient to fetch and cache JWKS:

import jwt
from jwt import PyJWKClient

jwks_url = "https://auth.example.com/.well-known/jwks.json"
jwks_client = PyJWKClient(jwks_url, cache_keys=True, lifespan=3600)

def verify(token):
    signing_key = jwks_client.get_signing_key_from_jwt(token)
    return jwt.decode(
        token,
        signing_key.key,
        algorithms=["RS256"],
        audience="myapp",
        issuer="https://auth.example.com",
    )

PyJWKClient reads the kid from the token, fetches the matching key from JWKS, caches for lifespan seconds. On cache miss after rotation, it re-fetches.

For high-traffic apps: increase lifespan to reduce JWKS fetches; the client also handles cache invalidation on signature failure.

Reading the header before verification

Sometimes you need to know which key to use before validating:

unverified_header = jwt.get_unverified_header(token)
kid = unverified_header["kid"]
key = my_key_store[kid]
payload = jwt.decode(token, key, algorithms=["RS256"])

get_unverified_header doesn’t validate anything; use only to look up the key, then verify.

Never trust unverified payload claims. jwt.decode(..., options={"verify_signature": False}) returns the payload but skips verification — useful for debugging, dangerous in production.

FastAPI integration

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

app = FastAPI()
security = HTTPBearer()

def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
    token = credentials.credentials
    try:
        payload = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"], audience="myapp")
    except jwt.PyJWTError:
        raise HTTPException(status_code=401, detail="Invalid token")
    return payload

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

HTTPBearer extracts Authorization: Bearer <token>. The dependency validates and returns the payload (or raises 401).

Django integration (DRF)

# settings.py
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": (
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ),
}

from datetime import timedelta
SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=7),
    "ROTATE_REFRESH_TOKENS": True,
    "BLACKLIST_AFTER_ROTATION": True,
    "ALGORITHM": "RS256",
    "SIGNING_KEY": open("private.pem").read(),
    "VERIFYING_KEY": open("public.pem").read(),
    "AUDIENCE": "myapp",
    "ISSUER": "https://auth.example.com",
}

djangorestframework-simplejwt provides a full Django implementation: token endpoints, refresh rotation, blocklist support. Don’t roll your own for Django.

# urls.py
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView

urlpatterns = [
    path("api/token/", TokenObtainPairView.as_view()),
    path("api/token/refresh/", TokenRefreshView.as_view()),
]

Common patterns

Custom payload at issue time

def issue_access_token(user):
    return jwt.encode(
        {
            "sub": str(user.id),
            "email": user.email,
            "role": user.role,
            "tenant_id": user.tenant_id,
            "iat": datetime.datetime.now(datetime.timezone.utc),
            "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=15),
            "iss": "https://auth.example.com",
            "aud": "myapp",
            "jti": str(uuid.uuid4()),
        },
        PRIVATE_KEY,
        algorithm="RS256",
    )

Standard registered claims + your custom ones. Always include jti if you might revoke later. See 03_claims.md.

Async-friendly verification

PyJWT’s decode is synchronous (CPU-only crypto, no I/O). Safe in async code without thread offloading. For JWKS fetches (network I/O), use the sync PyJWKClient — JWKS fetches are infrequent (cached), so the occasional blocking call is acceptable.

For high-throughput async apps, pre-fetch the JWKS at startup and refresh in a background task.

Common pitfalls

  • Forgetting [crypto]pip install PyJWT without [crypto] only supports HMAC. RS256 fails at import.
  • Not passing algorithms=[...] — modern PyJWT raises; older versions defaulted to “whatever the token says” (alg confusion vulnerability).
  • Not passing audience= / issuer= — verification skips these checks.
  • Catching Exception instead of PyJWTError — masks unrelated bugs.
  • Reusing one secret for all environments — dev leak = prod compromise. Per-environment secrets.
  • Storing private keys in code / image — use secret managers.
  • options={"verify_signature": False} in production — accepts ANY token.

Common interview confusions

  • “PyJWT validates everything by default.” — validates signature + exp + nbf by default. Audience, issuer, and required claims need explicit parameters.
  • jwt.decode decodes the payload.” — it verifies AND decodes. If verification fails, it raises; you can’t get the payload without passing verification (without explicit opt-out flags).
  • “You can verify a JWT without the key.” — only get_unverified_header() / decoding with verify_signature=False works without a key. Don’t trust the result.

Interview angle

  • “What library do you use for JWT in Python?” — PyJWT with [crypto] extra (for asymmetric algorithms). Maintained, well-documented, the de-facto standard. Alternatives: python-jose, authlib.
  • “How do you encode and decode a JWT in PyJWT?”jwt.encode(payload, key, algorithm="RS256") to issue; jwt.decode(token, key, algorithms=["RS256"], audience="myapp", issuer="https://...") to verify. Always pass algorithms as an explicit list.
  • “How do you handle key rotation with PyJWT?” — use PyJWKClient to fetch and cache JWKS from the OIDC issuer. It reads kid from the token, looks up the matching key, validates. Cache lifetime tunable.
  • “How do you integrate JWT with FastAPI / Django?” — FastAPI: HTTPBearer extracts the token, a dependency validates and returns the payload. Django/DRF: djangorestframework-simplejwt provides full auth backend + token endpoints.
  • “What exceptions does PyJWT raise?”ExpiredSignatureError, InvalidSignatureError, InvalidAudienceError, InvalidIssuerError, InvalidTokenError, DecodeError. All inherit from PyJWTError. Catch PyJWTError for general handling.
  • “How do you read the JWT header without verifying?”jwt.get_unverified_header(token) returns the header dict. Use to look up the key by kid, then verify. Never trust unverified payload contents.
  • “What’s the security implication of options={'verify_signature': False}?” — verification disabled — accepts any forged token. Useful in tests with known-fake tokens; never in production. Some codebases sneak this in during debugging and forget to remove.