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 PyJWTwithout[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
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.” — validates signature +
exp+nbfby default. Audience, 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
- “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 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.