PyJWT — Encode, Decode & Claims¶
How a token is built, what jwt.decode checks and in which order, which exception each failure raises, and how to map those exceptions to HTTP responses. Examples use HS256 with placeholder secrets; algorithms and keys are in 02.
import base64
import json
from datetime import datetime, timedelta, timezone
import jwt
SECRET = "placeholder-secret-use-32-random-bytes-or-more"
Token Structure¶
A compact JWT is three base64url segments joined by dots: header.payload.signature. The signature covers the first two segments exactly as transmitted.
| Part | Content | Example (decoded) |
|---|---|---|
| Header | JSON object: alg, typ, optional kid |
{"alg":"HS256","kid":"k1","typ":"JWT"} |
| Payload | JSON object of claims | {"sub":"u1","exp":1893499200} |
| Signature | Raw bytes of the MAC or signature, base64url | 43 characters for HS256 |
token = jwt.encode({"sub": "u1", "exp": 1893499200}, SECRET, algorithm="HS256", headers={"kid": "k1"})
def segment(part: str) -> dict:
return json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)))
header, payload, signature = token.split(".")
print(segment(header)) # {'alg': 'HS256', 'kid': 'k1', 'typ': 'JWT'}
print(segment(payload)) # {'sub': 'u1', 'exp': 1893499200}
Reading the payload needs no key. That is why nothing secret belongs in it. In tests, jwt.decode(token, options={"verify_signature": False}) does the same thing and returns the claims without checking anything: use it to assert on what a service issued, never to authenticate.
jwt.encode¶
jwt.encode(payload, key, algorithm="HS256", headers=None, json_encoder=None, sort_headers=True) -> str
| Parameter | Behaviour |
|---|---|
payload |
Must be a dict, otherwise TypeError. The dict is copied, not modified |
key |
Secret string/bytes for HS, PEM or cryptography private key for RS/PS/ES/EdDSA, or a PyJWK |
algorithm |
Defaults to HS256 when omitted. With a PyJWK key the key's algorithm is the default. "none" produces an unsigned token. An unknown name raises NotImplementedError |
headers |
Extra header fields (kid, typ, ...). A headers["alg"] wins over algorithm. {"typ": None} removes typ |
json_encoder |
A json.JSONEncoder subclass for payload and header values that json cannot serialise (UUID, Decimal, ...) |
sort_headers |
Sort header keys for stable output (default True) |
import uuid
class Encoder(json.JSONEncoder):
def default(self, o):
return str(o) if isinstance(o, uuid.UUID) else super().default(o)
token = jwt.encode({"sub": uuid.UUID(int=1)}, SECRET, algorithm="HS256", json_encoder=Encoder)
print(jwt.decode(token, SECRET, algorithms=["HS256"])) # {'sub': '00000000-0000-0000-0000-000000000001'}
Time claims¶
exp, iat and nbf are NumericDate values: seconds since the Unix epoch (RFC 7519).
Value passed to encode |
Stored as |
|---|---|
timezone-aware datetime |
Integer seconds, converted to UTC |
naive datetime |
Integer seconds, interpreted as UTC (a local-time naive value is silently shifted) |
int or float |
Stored unchanged (a float is accepted by decode) |
now = datetime.now(timezone.utc)
token = jwt.encode(
{"iat": now, "nbf": now, "exp": now + timedelta(minutes=15)}, SECRET, algorithm="HS256"
)
Always build times from datetime.now(timezone.utc). iss must be a string: encode raises TypeError otherwise.
jwt.decode and jwt.decode_complete¶
jwt.decode(
token, key, algorithms=None, options=None,
audience=None, subject=None, issuer=None, leeway=0,
) -> dict
| Parameter | Behaviour |
|---|---|
token |
str or bytes; anything else raises DecodeError |
key |
Verification key: secret, PEM public key, cryptography public key or PyJWK |
algorithms |
Required list of allowed alg values (DecodeError if missing). With a PyJWK key it defaults to that key's algorithm |
options |
Per-call overrides, see the table below |
audience |
Expected audience: a string, or an iterable where any match is enough |
issuer |
Expected issuer: a string, or a container of allowed issuers |
subject |
Expected sub value |
leeway |
Clock-skew tolerance in seconds (float) or a timedelta |
decode_complete takes the same arguments and returns {"header": ..., "payload": ..., "signature": ...}, which is how you read kid or typ after verification:
token = jwt.encode({"sub": "u1"}, SECRET, algorithm="HS256", headers={"kid": "k1", "typ": "at+jwt"})
decoded = jwt.decode_complete(token, SECRET, algorithms=["HS256"])
print(decoded["header"]) # {'alg': 'HS256', 'kid': 'k1', 'typ': 'at+jwt'}
print(decoded["payload"]) # {'sub': 'u1'}
jwt.get_unverified_header(token) returns the header without verifying anything. Its only legitimate use is picking the verification key (kid) before decode. Never use its alg to choose algorithms.
print(jwt.get_unverified_header(token)["kid"]) # k1
Registered Claims and How PyJWT Checks Them¶
| Claim | Meaning | Check in decode |
Exception |
|---|---|---|---|
exp |
Expiry | Present and exp <= now - leeway means expired (a token is already invalid at the exact second exp). Non-numeric value raises DecodeError |
ExpiredSignatureError |
nbf |
Not before | nbf > now + leeway; non-numeric value raises DecodeError |
ImmatureSignatureError |
iat |
Issued at | iat > now + leeway (token from the future); non-numeric value |
ImmatureSignatureError, InvalidIssuedAtError |
iss |
Issuer | Compared only if issuer= is passed; exact match (a list of issuers is a membership test); non-string value; claim absent |
InvalidIssuerError, MissingRequiredClaimError |
aud |
Audience | See below | InvalidAudienceError, MissingRequiredClaimError |
sub |
Subject | If present it must be a string; must equal subject= when passed (since 2.10) |
jwt.exceptions.InvalidSubjectError |
jti |
Token ID | If present it must be a string (since 2.10). PyJWT does not track reuse | jwt.exceptions.InvalidJTIError |
Rules that surprise people:
- Absent claims are skipped.
exp,nbf,iat,subandjtiare checked only if the token contains them. A token withoutexpnever expires unless you addoptions={"require": ["exp"]}. requirechecks presence only, and a claim set tonullcounts as missing (MissingRequiredClaimError, with the claim name in.claim). Value checks still come fromverify_*.aud: if the token has anaudand you pass noaudience=,decoderaisesInvalidAudienceError. If you passaudience=and the token has none, it raisesMissingRequiredClaimError. The token'saudmay be a string or a list; one overlap withaudienceis enough.options={"strict_aud": True}demands a single string on both sides with exact equality.iss: passingissuer=also makes the claim mandatory. Matching is exact, sohttps://a.exampledoes not accepthttps://a.example.evil.- Order of checks:
require, theniat,nbf,exp,iss,aud,sub,jti; the first failure raises. The signature is verified before any claim check. decodereturns time claims as the integers stored in the token.
options¶
| Option | Default | Effect |
|---|---|---|
verify_signature |
True |
False skips the signature check and flips every verify_* default below to False, algorithms is no longer required |
require |
[] |
Claims that must be present |
verify_exp, verify_nbf, verify_iat |
True |
Time checks |
verify_aud, verify_iss |
True |
Audience and issuer checks |
verify_sub, verify_jti |
True |
Type and subject checks |
strict_aud |
False |
Single-value audience, exact match |
enforce_minimum_key_length |
False |
Raise InvalidKeyError instead of warning for short keys (see 02) |
import time
expired = jwt.encode({"sub": "u1", "exp": int(time.time()) - 60}, SECRET, algorithm="HS256")
try:
jwt.decode(expired, SECRET, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
print("expired") # expired
print(jwt.decode(expired, SECRET, algorithms=["HS256"], leeway=120)) # exp is within the leeway
print(jwt.decode(expired, SECRET, algorithms=["HS256"], options={"verify_exp": False}))
Disabling a verify_* option is a test tool (for example, reading the claims of an expired token). In production code it is a bug waiting to happen.
leeway¶
leeway moves the boundary of exp later and of nbf and iat earlier. It accepts seconds or a timedelta. Use 5 to 30 seconds for servers whose clocks drift; a minute or more makes stolen tokens live longer.
claims = jwt.decode(expired, SECRET, algorithms=["HS256"], leeway=timedelta(minutes=2))
The PyJWT Object¶
jwt.encode, jwt.decode and jwt.decode_complete are bound methods of a module-level jwt.PyJWT() instance. Create your own instance to set defaults once:
strict = jwt.PyJWT(options={"require": ["exp", "iat", "sub"], "enforce_minimum_key_length": True})
token = strict.encode({"sub": "u1", "iat": 1, "exp": 4102444800}, SECRET, algorithm="HS256")
print(strict.decode(token, SECRET, algorithms=["HS256"]))
| Detail | Behaviour |
|---|---|
| Merging | Instance options are merged with defaults; per-call options are merged on top, and the instance is not modified |
verify_signature=False in options |
The other verify_* flags default to False unless you set them explicitly |
| Raw (non-JSON) payloads | jwt.PyJWS().encode(b"raw bytes", key, algorithm="HS256") signs arbitrary bytes; PyJWS().decode(...) returns the bytes |
Exceptions¶
PyJWTError
├── InvalidKeyError wrong key type/format, empty or short HMAC key (enforced)
├── PyJWKError
│ └── MissingCryptographyError algorithm needs the cryptography package
├── PyJWKSetError JWKS has no usable keys
├── PyJWKClientError no matching key, bad JWKS response
│ └── PyJWKClientConnectionError JWKS endpoint unreachable
└── InvalidTokenError base class for every bad token
├── DecodeError malformed token, missing algorithms=, bad exp type
│ └── InvalidSignatureError
├── ExpiredSignatureError
├── ImmatureSignatureError nbf or iat in the future
├── InvalidIssuedAtError
├── InvalidAlgorithmError alg not in algorithms=, or unknown alg
├── InvalidAudienceError
├── InvalidIssuerError
├── InvalidSubjectError
├── InvalidJTIError
└── MissingRequiredClaimError has a .claim attribute
All of these are importable from jwt except InvalidSubjectError, InvalidJTIError and MissingCryptographyError, which live in jwt.exceptions. Catch InvalidTokenError for "the caller sent a bad token", and PyJWKClientError separately for key-lookup problems.
Mapping to HTTP¶
| Exception | Meaning | Status | Notes |
|---|---|---|---|
Any InvalidTokenError subclass |
Token is unacceptable | 401 | WWW-Authenticate: Bearer error="invalid_token" |
| No credentials / wrong scheme | Not authenticated | 401 | WWW-Authenticate: Bearer without an error code |
| Valid token, missing scope or role | Authenticated but not allowed | 403 | Raised by your authorization code, not by PyJWT. RFC 6750 uses error="insufficient_scope" |
PyJWKClientError (unknown kid, token without kid) |
Token references a key the IdP does not publish | 401 | Caller-caused |
PyJWKClientConnectionError |
JWKS endpoint down or slow | 503 | Server-side; do not blame the caller |
InvalidKeyError, MissingCryptographyError, PyJWKSetError |
Misconfigured keys on the server | 500 | Alert, do not return details |
| Payload fails your Pydantic model | Token is signed but not shaped as expected | 401 | Validate claims with a model after decode |
The FastAPI dependency that applies this mapping is in 04. Keep the response body generic for every 401: {"detail": "Not authenticated"}. Log the exception class name, never the token or the full message. Some APIs return a distinct code for expiry so clients know to refresh; that leaks nothing, because exp is readable in the payload.
def classify(exc: Exception) -> int:
if isinstance(exc, jwt.PyJWKClientConnectionError):
return 503
if isinstance(exc, (jwt.InvalidTokenError, jwt.PyJWKClientError)):
return 401
return 500
print(classify(jwt.ExpiredSignatureError())) # 401
print(classify(jwt.PyJWKClientConnectionError("down"))) # 503
print(classify(jwt.InvalidKeyError("bad key"))) # 500
Quick Rules¶
algorithms=[...]is a list you control;get_unverified_headeris for picking a key bykid, never an algorithm.requirethe claims you rely on (expfirst); an absent claim is otherwise skipped silently.- Pass
audience=andissuer=wherever tokens come from a shared identity provider. - Build times with
datetime.now(timezone.utc); a naivedatetimeis treated as UTC. - Keep
leewayin seconds, not minutes. options={"verify_signature": False}is for inspection in tests, never for authentication.- Catch
InvalidTokenError(401),PyJWKClientConnectionError(503) and let key errors surface (500). - Return one generic 401 body and log only the exception class.
See also¶
- PyJWT — JSON Web Tokens in Python
- 02 Algorithms, Keys & JWKS
- 03 Security Pitfalls & Hardening
- FastAPI
- Pydantic — Data Validation
- OWASP API Security