Install
pip install "pyjwt[crypto]"The crypto extra brings in the cryptography package, which is required for RSA and ECDSA. Leave it off only if you will never use anything but HMAC.
Signing
import jwt
from datetime import datetime, timedelta, timezone
SECRET = os.environ["JWT_SECRET"] # 32+ random bytes
now = datetime.now(tz=timezone.utc)
token = jwt.encode(
{
"sub": "user_123",
"role": "admin",
"iss": "https://api.example.com",
"aud": "https://api.example.com",
"iat": now,
"exp": now + timedelta(minutes=15),
},
SECRET,
algorithm="HS256",
)PyJWT accepts datetime objects for the time claims and converts them to Unix timestamps. Always use timezone-aware datetimes; a naive datetime.utcnow() is deprecated and easy to get wrong.
For RS256, pass the PEM private key as the key argument and add a kid so verifiers can pick the right public key.
with open("private.pem", "rb") as f:
PRIVATE_KEY = f.read()
token = jwt.encode(
payload,
PRIVATE_KEY,
algorithm="RS256",
headers={"kid": "key-2026-09"},
)Verifying
jwt.decode verifies the signature, checks exp and nbf, and validates iss and aud when you pass them. It returns the payload as a dict.
import jwt
try:
payload = jwt.decode(
token,
PUBLIC_KEY, # or SECRET for HS256
algorithms=["RS256"], # always explicit
issuer="https://api.example.com",
audience="https://api.example.com",
leeway=30, # seconds of clock skew to tolerate
)
except jwt.ExpiredSignatureError:
... # exp has passed
except jwt.InvalidAudienceError:
... # aud does not match
except jwt.InvalidIssuerError:
... # iss does not match
except jwt.InvalidSignatureError:
... # wrong key, tampered token, or wrong algorithm
except jwt.InvalidTokenError as e:
... # base class for everything above; catch this if you don't need detailalgorithms is required. Passing it is what defends against alg: none and key confusion. If you leave it out, PyJWT 2.x raises rather than guessing, which is the correct behaviour.
If you pass audience, the token must contain a matching aud. If the token has an aud claim and you do not pass audience, decode raises InvalidAudienceError. That surprises people the first time; it is the library refusing to let you skip a check the issuer clearly intended.
Reading a token without verifying
Occasionally you need the claims before you know which key to use, for instance to read iss and pick a tenant. Do this with verification explicitly off, and never trust the result until you have verified with the proper key.
unverified = jwt.decode(token, options={"verify_signature": False})
header = jwt.get_unverified_header(token) # for kid, algThe same view is available in this site's decoder, which shows the header and payload of any token without a key.
Verifying against a JWKS endpoint
PyJWKClient fetches a JSON Web Key Set, caches it, and picks the key matching the token's kid. This is how you verify tokens from Auth0, Cognito, Firebase, Okta, or Keycloak.
from jwt import PyJWKClient
jwks_client = PyJWKClient(
"https://YOUR_DOMAIN/.well-known/jwks.json",
cache_keys=True,
)
def verify(token: str) -> dict:
signing_key = jwks_client.get_signing_key_from_jwt(token)
return jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
issuer="https://YOUR_DOMAIN/",
audience="https://your-api-identifier",
)Instantiate PyJWKClient once at import time. Creating it per request throws away the cache and hits the JWKS URL on every call.
FastAPI dependency
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
import jwt
bearer = HTTPBearer(auto_error=False)
def current_user(
creds: HTTPAuthorizationCredentials | None = Depends(bearer),
) -> dict:
if creds is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "missing bearer token")
try:
return verify(creds.credentials)
except jwt.InvalidTokenError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid token")
@app.get("/me")
def me(user: dict = Depends(current_user)):
return {"id": user["sub"]}Return the same 401 for every failure reason. Telling the client "expired" versus "bad signature" leaks which tokens are worth replaying.
Flask decorator
from functools import wraps
from flask import request, jsonify, g
import jwt
def require_auth(f):
@wraps(f)
def wrapper(*args, **kwargs):
auth = request.headers.get("Authorization", "")
scheme, _, token = auth.partition(" ")
if scheme != "Bearer" or not token:
return jsonify(error="missing bearer token"), 401
try:
g.user = verify(token)
except jwt.InvalidTokenError:
return jsonify(error="invalid token"), 401
return f(*args, **kwargs)
return wrapperErrors you will meet
InvalidSignatureError. Wrong key, wrong algorithm, or a modified token. Paste the token into the decoder and compare alg and kid with the key you are verifying with. For PEM keys read from environment variables, check that newlines survived; a PEM with \n as two literal characters is not a PEM.
ExpiredSignatureError. Check the server clock first. Then check whether the issuer's lifetime is shorter than you assumed.
InvalidAudienceError with a token that has no aud. You passed audience but the issuer did not set one. Either configure the issuer to set it (preferred) or stop passing audience.
PyJWKClientError: Unable to find a signing key that matches. The kid in the token is not in the JWKS. Usually the JWKS URL points at a different tenant or environment than the one that issued the token.
Generating test tokens
The encoder builds a signed token from a header, payload, and key you paste, entirely in the browser. It is quicker than a script for producing a token with a specific aud or an already-expired exp to test your error handling.