Backend / Authentication / JWT / 06_python_pyjwt.md

Python JWT with PyJWT

Updated 7 interview angles 6 min read source
On this page13
  1. Install
  2. Minimal example — HS
  3. RS256 — asymmetric signing
  4. Validating all the claims
  5. Catching specific exceptions
  6. JWKS — verify with rotating keys (OIDC)
  7. Reading the header before verification
  8. FastAPI integration
  9. Django integration (DRF)
  10. Common patterns
  11. Common pitfalls
  12. Common interview confusions
  13. Interview angle

Python JWT with PyJWT

PyJWT is the de-facto standard JWT library for Python. Maintained, well-documented, widely used. The alternatives are authlib and joserfc (which also do JWE); python-jose is effectively unmaintained and has had CVEs, so do not start new code on it.

Install

bash
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

python
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

python
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

python
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 issuer=, the issuer is not checked. Audience is stricter than people expect: if the token carries aud and you pass no audience=, PyJWT rejects it (checked on 2.15.1):

text
no audience= passed: InvalidAudienceError Invalid audience

So always pass both, and note that some tokens (Cognito access tokens) carry client_id instead of aud and need that checked by hand.

Catching specific exceptions

python
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:

python
import jwt
from jwt import PyJWKClient

jwks_url = "https://auth.example.com/.well-known/jwks.json"
jwks_client = PyJWKClient(jwks_url, lifespan=3600)   # JWK set cached 1 h

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 and looks it up in the cached JWK set, refetching the set when the kid is unknown (after the issuer rotates keys). It does not refetch on a signature failure.

Avoid cache_keys=True: it adds an LRU of individual keys with no expiry, so a key the issuer removed (for example after a compromise) stays trusted until the process restarts. Rate-limit refetches on unknown kids, or a flood of garbage tokens becomes a flood of requests to the identity provider.

Reading the header before verification

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

python
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

python
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)

python
# 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.

What simplejwt rotation gives you

Know what its rotation settings do and do not give you: ROTATE_REFRESH_TOKENS + BLACKLIST_AFTER_ROTATION reject an old refresh token once it has been used, but a replayed old token does not revoke the child token issued from it, and the check-then-blacklist step can race. That is rotation without reuse detection; the full design is in Refresh Token Rotation.

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

python
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 JWT Claims.

Async-friendly verification

PyJWT’s decode is synchronous (CPU-only crypto, no I/O) and safe in async code. PyJWKClient is not: it fetches with blocking urllib (30 s default timeout), so a JWKS refetch inside an async handler stalls the event loop for every request. Call it via asyncio.to_thread, or fetch the JWKS with an async client.

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. The import works; encode with RS256 raises NotImplementedError and decode raises InvalidAlgorithmError, which an except PyJWTError turns into a 401 on every request.
  • Not passing algorithms=[...] — modern PyJWT raises; older versions defaulted to “whatever the token says” (alg confusion vulnerability).
  • Not passing audience= / issuer= — the issuer is not checked, and any token carrying aud is rejected with InvalidAudienceError.
  • 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.” — it validates signature, exp and nbf by default, rejects a token with aud when no audience= is given, and (since 2.10) rejects a non-string sub. 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 7

  • “What library do you use for JWT in Python?” — PyJWT with [crypto] extra (for asymmetric algorithms). Maintained, well-documented, the de-facto standard. Alternatives: authlib and joserfc; not python-jose, which is effectively unmaintained.
  • “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.