JWT in Python: PyJWT and python-jose Guide
Last updated:
PyJWT is the standard library for working with JSON Web Tokens in Python. It handles encoding (signing) and decoding (verifying) JWTs with a clean API, supports all common algorithms (HS256, RS256, ES256), and automatically validates the expiry claim. For environments that use JWKS endpoints — Auth0, AWS Cognito, Google — python-jose extends PyJWT with multi-key support. This guide covers the full workflow: installation, encoding, decoding, RS256 asymmetric keys, JWKS verification, refresh token patterns, and common errors.
Installing PyJWT
PyJWT has two installation modes. The base package supports HS256 (HMAC with a shared secret). Installing the optional cryptography extra unlocks RS256, ES256, and other asymmetric algorithms. Use the extras syntax to install both in one command:
# Minimal install — HS256 only
pip install PyJWT
# Full install — HS256, RS256, ES256, PS256
pip install PyJWT cryptography
# python-jose with cryptography backend (for JWKS support)
pip install "python-jose[cryptography]"
# Verify installed versions
pip show PyJWT python-josePyJWT 2.x (released 2021) broke backward compatibility with 1.x: jwt.encode() now returns a str instead of bytes. If you encounter AttributeError: 'str' object has no attribute 'decode' it means your code was written for 1.x. Remove the .decode('utf-8') call — the return value is already a string.
Encoding a JWT (HS256)
jwt.encode(payload, secret, algorithm) signs the payload with the given secret and returns the compact JWT string (three Base64url segments separated by dots). Always include the exp (expiry) claim. PyJWT accepts Python datetime objects and converts them to Unix timestamps automatically:
import jwt
import datetime
SECRET = "your-256-bit-secret" # store in env var, never hardcode
payload = {
"sub": "user_123", # subject — who the token refers to
"iat": datetime.datetime.utcnow(), # issued at
"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1), # expiry
"role": "admin", # custom claim
}
token = jwt.encode(payload, SECRET, algorithm="HS256")
# token is a str in PyJWT 2.x: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...."
print(token) # compact JWT string
print(type(token)) # <class 'str'>The secret should be a long, randomly generated string (at least 256 bits / 32 bytes). Load it from an environment variable: SECRET = os.environ["JWT_SECRET"]. For production, use a secrets manager (AWS Secrets Manager, HashiCorp Vault) rather than a plain environment variable.
Decoding and Verifying a JWT
jwt.decode() verifies the signature and checks the exp claim in one call. If either check fails it raises an exception — there is no partial success. Always pass the algorithms list explicitly to prevent algorithm confusion attacks (where an attacker swaps RS256 for HS256 in the header):
import jwt
def verify_token(token: str, secret: str) -> dict:
try:
payload = jwt.decode(
token,
secret,
algorithms=["HS256"], # always specify; never use algorithms=["none"]
)
return payload # dict with all claims
except jwt.ExpiredSignatureError:
raise ValueError("Token has expired — issue a new one or use refresh token")
except jwt.InvalidSignatureError:
raise ValueError("Signature verification failed — wrong secret or tampered token")
except jwt.DecodeError:
raise ValueError("Token is malformed — not a valid JWT string")
except jwt.InvalidTokenError as e:
raise ValueError(f"Invalid token: {e}")
# Usage
try:
data = verify_token(token, SECRET)
print(data["sub"]) # "user_123"
print(data["role"]) # "admin"
except ValueError as e:
print(e)The decoded payload is a plain Python dict. All registered claims (sub, iat, exp, iss, aud) are present if they were included when encoding. Custom claims are accessible by their key names.
To validate the aud (audience) or iss (issuer) claims, pass them to jwt.decode(): jwt.decode(token, secret, algorithms=["HS256"], audience="my-api", issuer="https://auth.example.com"). PyJWT raises InvalidAudienceError or InvalidIssuerError if they do not match.
RS256 with Public/Private Keys
RS256 uses a 2048-bit (or larger) RSA key pair: the private key signs the token; any service with only the public key can verify it. This is the right choice when tokens are issued by one service (an auth server) and verified by many downstream services. Generate keys with OpenSSL:
# Generate 2048-bit RSA private key
openssl genrsa -out private.pem 2048
# Extract the public key
openssl rsa -in private.pem -pubout -out public.pemimport jwt
# Auth server: sign with private key
private_key = open("private.pem", "rb").read()
token = jwt.encode(
{"sub": "user_123", "exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1)},
private_key,
algorithm="RS256",
)
# Any service: verify with public key (no private key needed)
public_key = open("public.pem", "rb").read()
data = jwt.decode(token, public_key, algorithms=["RS256"])
print(data["sub"]) # "user_123"Store the private key in a secrets manager and deploy only the public key to downstream services. Never commit PEM files to version control. In containerised environments, mount keys as read-only volumes or inject them via environment variables in PEM format.
For ES256 (ECDSA with P-256), the API is identical — replace algorithm="RS256" with algorithm="ES256" and generate an EC key pair: openssl ecparam -name prime256v1 -genkey -noout -out ec-private.pem. ES256 produces shorter signatures (~64 bytes vs ~256 bytes for RS256) with equivalent security.
JWKS Endpoint Verification (Auth0, Cognito)
JWKS (JSON Web Key Set) is a JSON document that contains one or more public keys. Identity providers rotate keys over time and publish them all at a well-known URL. python-jose fetches the JWKS, selects the correct key using the kid (Key ID) in the JWT header, and verifies the signature:
import requests
from jose import jwt as jose_jwt
from jose import jwk
from jose.utils import base64url_decode
import json
# Auth0 example — replace with your domain
JWKS_URL = "https://YOUR_DOMAIN.auth0.com/.well-known/jwks.json"
AUDIENCE = "https://your-api-identifier"
ISSUER = "https://YOUR_DOMAIN.auth0.com/"
def get_jwks() -> dict:
"""Fetch JWKS (cache this in production — refresh every 24h or on kid miss)."""
response = requests.get(JWKS_URL, timeout=5)
response.raise_for_status()
return response.json()
def verify_auth0_token(token: str) -> dict:
jwks = get_jwks()
# Decode header to find which key to use
unverified_header = jose_jwt.get_unverified_header(token)
kid = unverified_header.get("kid")
# Find matching key in JWKS
rsa_key = next(
(key for key in jwks["keys"] if key["kid"] == kid),
None,
)
if rsa_key is None:
raise ValueError(f"Public key not found for kid={kid}")
payload = jose_jwt.decode(
token,
rsa_key,
algorithms=["RS256"],
audience=AUDIENCE,
issuer=ISSUER,
)
return payload
# AWS Cognito — similar pattern
COGNITO_POOL_ID = "us-east-1_XXXXXXXX"
COGNITO_JWKS_URL = (
f"https://cognito-idp.us-east-1.amazonaws.com/{COGNITO_POOL_ID}/.well-known/jwks.json"
)In production, cache the JWKS response (e.g. in Redis or an in-process dict with a TTL). Refresh the cache when a kid miss occurs (the provider rotated keys) or on a scheduled basis (every 24 hours). Fetching JWKS on every request adds ~50–200ms of latency and risks rate limits.
Refresh Token Pattern
Access tokens should be short-lived (15 minutes to 1 hour) to limit the damage from token theft. Refresh tokens are long-lived (7–30 days) and allow clients to obtain new access tokens without re-authenticating. The key rule: access tokens are stateless; refresh tokens are stored server-side so they can be revoked:
import jwt
import datetime
import uuid
SECRET = "your-secret"
REFRESH_SECRET = "different-refresh-secret" # use a separate secret
def create_tokens(user_id: str) -> dict:
"""Issue an access token + refresh token pair."""
now = datetime.datetime.utcnow()
access_payload = {
"sub": user_id,
"type": "access",
"iat": now,
"exp": now + datetime.timedelta(minutes=15),
}
refresh_payload = {
"sub": user_id,
"type": "refresh",
"jti": str(uuid.uuid4()), # unique ID for revocation
"iat": now,
"exp": now + datetime.timedelta(days=30),
}
access_token = jwt.encode(access_payload, SECRET, algorithm="HS256")
refresh_token = jwt.encode(refresh_payload, REFRESH_SECRET, algorithm="HS256")
# Store refresh_payload["jti"] in DB/Redis for revocation checks
# store_refresh_token(user_id, refresh_payload["jti"], expires_at=now + timedelta(days=30))
return {"access_token": access_token, "refresh_token": refresh_token}
def refresh_access_token(refresh_token: str) -> str:
"""Verify refresh token and issue a new access token."""
try:
payload = jwt.decode(refresh_token, REFRESH_SECRET, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise ValueError("Refresh token expired — user must log in again")
except jwt.InvalidTokenError:
raise ValueError("Invalid refresh token")
if payload.get("type") != "refresh":
raise ValueError("Not a refresh token")
# Check revocation (look up jti in DB/Redis)
# if is_revoked(payload["jti"]):
# raise ValueError("Refresh token has been revoked")
# Issue new access token
new_payload = {
"sub": payload["sub"],
"type": "access",
"iat": datetime.datetime.utcnow(),
"exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=15),
}
return jwt.encode(new_payload, SECRET, algorithm="HS256")Implement refresh token rotation: on each use, invalidate the old refresh token (delete its jti from the database) and issue a new one. This limits the refresh token reuse window and detects token theft — if a stolen refresh token is used after the legitimate client has rotated it, the server will see a revoked jti and can invalidate the entire session.
Common Errors and Fixes
| Exception | Cause | Fix |
|---|---|---|
ExpiredSignatureError | exp timestamp is in the past | Issue a new token via refresh token endpoint |
InvalidSignatureError | Wrong secret / key used for verification | Ensure signing and verifying keys match |
DecodeError | Token string is not a valid JWT | Validate that the token has three dot-separated segments |
InvalidAlgorithmError | Header algorithm not in algorithms= list | Always pass algorithms=["HS256"] explicitly |
InvalidAudienceError | aud claim does not match expected value | Pass correct audience= to decode() |
MissingRequiredClaimError | Expected claim absent from payload | Add the claim to the payload at encode time |
AttributeError: 'str' has no 'decode' | Code written for PyJWT 1.x | Remove .decode('utf-8') — encode() returns str in 2.x |
# Robust decode with leeway for clock skew
import jwt
from datetime import timedelta
payload = jwt.decode(
token,
SECRET,
algorithms=["HS256"],
leeway=timedelta(seconds=10), # tolerate up to 10s clock drift
options={
"require": ["exp", "sub"], # enforce required claims
},
)
# Inspect token WITHOUT verification (debugging only — never in production)
header = jwt.get_unverified_header(token)
claims = jwt.decode(token, options={"verify_signature": False})
print(header) # {"alg": "HS256", "typ": "JWT"}
print(claims) # {"sub": "user_123", "exp": 1716163200, ...}Definitions
- JWT (JSON Web Token)
- A compact, URL-safe token format defined in RFC 7519. Consists of three Base64url-encoded segments: header (algorithm and token type), payload (claims), and signature. The signature proves the token has not been tampered with.
- Claim
- A key/value pair in the JWT payload. Registered claims (
sub,exp,iat,iss,aud,jti) have defined meanings in the RFC. Custom claims carry application-specific data such as user roles or permissions. - HS256
- HMAC-SHA256. A symmetric algorithm where the same secret key signs and verifies the token. Fast and simple, but requires all verifying parties to possess the secret. Best for single-service applications where the auth and resource server are the same.
- RS256
- RSA-SHA256. An asymmetric algorithm: a private key signs the token; the corresponding public key verifies it. Verifying services never need the private key. The standard choice for multi-service architectures and identity providers.
- JWKS (JSON Web Key Set)
- A JSON document containing an array of public keys (in JWK format) used to verify JWTs. Published by identity providers at a well-known URL (e.g.
/.well-known/jwks.json). Supports key rotation: multiple keys can be active simultaneously, identified by theirkid(Key ID) field.
FAQ
What is the best library for JWT in Python?
PyJWT is the standard choice: lightweight, actively maintained, and supports HS256, RS256, ES256, PS256. Install with pip install PyJWT cryptography. For JWKS endpoint support (Auth0, AWS Cognito), add python-jose: pip install "python-jose[cryptography]". Avoid PyJWT 1.x — it is unmaintained and has known security issues.
How do I encode a JWT with PyJWT?
Call jwt.encode(payload, secret, algorithm="HS256"). In PyJWT 2.x this returns a str. Always include exp in the payload: {"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1)}. PyJWT converts datetime objects to Unix timestamps automatically. Use iat (issued at) to record when the token was created.
How do I decode and verify a JWT in Python?
Call jwt.decode(token, secret, algorithms=["HS256"]). This verifies the signature and checks exp simultaneously. Catch jwt.ExpiredSignatureError for expired tokens and jwt.InvalidTokenError (the base class) for all other failures. Never pass options={"verify_signature": False} in production — it skips all security checks.
What is the difference between HS256 and RS256?
HS256 is symmetric: one shared secret signs and verifies. Simple but requires distributing the secret to every verifying service. RS256 is asymmetric: a private key signs; any service with the public key can verify. Use HS256 for monolith or single-service apps. Use RS256 when multiple independent services verify tokens — they only need the public key, never the private key.
How do I use RS256 with PyJWT?
Read your PEM-encoded keys as bytes and pass them directly: jwt.encode(payload, private_key_bytes, algorithm="RS256") to sign; jwt.decode(token, public_key_bytes, algorithms=["RS256"]) to verify. Generate a key pair with openssl genrsa -out private.pem 2048 and openssl rsa -in private.pem -pubout -out public.pem. The cryptography package must be installed.
What is JWKS and how do I verify JWTs using a JWKS endpoint?
JWKS is a JSON document of public keys published by identity providers (Auth0: https://YOUR_DOMAIN/.well-known/jwks.json; Cognito has a similar URL). Use python-jose to fetch the JWKS, match the kid from the JWT header to the correct key, and call jose_jwt.decode(). Cache the JWKS response — fetching it on every request adds latency and risks rate limiting.
How do I implement refresh tokens with PyJWT?
Issue two tokens on login: a short-lived access token (15 minutes) and a long-lived refresh token (30 days) with a unique jti claim. Store the jti in a database or Redis for revocation tracking. On token expiry, the client sends the refresh token to /token/refresh; the server verifies it, checks the jti is not revoked, and issues a new access token. Rotate refresh tokens on each use to detect theft.
What are the most common JWT errors in Python and how do I fix them?
ExpiredSignatureError: token is past its exp — use refresh token flow. InvalidSignatureError: wrong secret or tampered token — check that signing and verifying secrets match. DecodeError: malformed token string — validate the token has three dot-separated segments. AttributeError: 'str' object has no attribute 'decode': code written for PyJWT 1.x — remove the .decode('utf-8') call.
Further reading and primary sources
- PyJWT Docs — Official PyJWT documentation with API reference and examples
- JWT.io — JWT debugger and library directory
- RFC 7519 JWT Spec — The official JWT specification