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
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 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):
no audience= passed: InvalidAudienceError Invalid audienceSo 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
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)}, 401PyJWTError 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, 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:
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.
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.
# 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 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 PyJWTwithout[crypto]only supports HMAC. The import works;encodewith RS256 raisesNotImplementedErroranddecoderaisesInvalidAlgorithmError, which anexcept PyJWTErrorturns 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 carryingaudis rejected withInvalidAudienceError. - Catching
Exceptioninstead ofPyJWTError— 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,
expandnbfby default, rejects a token withaudwhen noaudience=is given, and (since 2.10) rejects a non-stringsub. Issuer and required claims need explicit parameters. - “
jwt.decodedecodes 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 withverify_signature=Falseworks 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 passalgorithmsas an explicit list. - “How do you handle key rotation with PyJWT?” — use
PyJWKClientto fetch and cache JWKS from the OIDC issuer. It readskidfrom the token, looks up the matching key, validates. Cache lifetime tunable. - “How do you integrate JWT with FastAPI / Django?” — FastAPI:
HTTPBearerextracts the token, a dependency validates and returns the payload. Django/DRF:djangorestframework-simplejwtprovides full auth backend + token endpoints. - “What exceptions does PyJWT raise?” —
ExpiredSignatureError,InvalidSignatureError,InvalidAudienceError,InvalidIssuerError,InvalidTokenError,DecodeError. All inherit fromPyJWTError. CatchPyJWTErrorfor 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 bykid, 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.