PyJWT — JSON Web Tokens in Python¶
PyJWT (package PyJWT, imported as jwt) signs and verifies JSON Web Tokens: jwt.encode() turns a dict of claims into a signed token, jwt.decode() checks the signature and the registered claims (exp, nbf, iat, iss, aud, sub, jti) and returns the claims. It implements signed tokens (JWS) only: there is no encryption, no OAuth flow, no session handling.
For a QA engineer it matters twice: as the library behind most Python API authentication, and as a source of precise negative tests (expired, wrong audience, wrong signature, alg: none, key confusion).
Installation¶
uv add pyjwt # HS256/384/512 only
uv add "pyjwt[crypto]" # + RS*/PS*/ES*/EdDSA and JWK support (installs cryptography)
uv add --dev pytest freezegun # testing helpers used in this guide
Covers PyJWT 2.x (current release 2.15.1, Python 3.9 or newer). The crypto extra is required for every asymmetric algorithm and for PyJWK / PyJWKClient with RSA, EC or OKP keys.
Starlette TestClient needs httpx2
The FastAPI examples in 04 and the tests in 05 use fastapi.testclient.TestClient. With Starlette 1.x install httpx2 (uv add --dev httpx2); with plain httpx it still works but emits a StarletteDeprecationWarning.
Section Map¶
| File | Topics |
|---|---|
| 01 Encode, Decode & Claims | Token structure, encode / decode / decode_complete, get_unverified_header, registered claims and how each is validated, options, leeway, PyJWT object, json_encoder, datetimes, exception hierarchy and HTTP 401/403/503 mapping |
| 02 Algorithms, Keys & JWKS | HS vs RS vs ES vs PS vs EdDSA, key formats, generating test keys, PyJWK, PyJWKSet, PyJWKClient caching, kid and rotation, OIDC discovery, key-length warnings, register_algorithm |
| 03 Security Pitfalls & Hardening | algorithms=[...], alg: none, algorithm confusion, aud / iss / exp, lifetimes, jti revocation, refresh tokens, token storage, secrets, kid / jku / x5u, JWE, PyJWT advisories |
| 04 FastAPI Integration & Token Service | TokenService (issue access and refresh tokens, verify with require, aud, iss, token_use), FastAPI Bearer dependency, require_scope, refresh rotation, status mapping |
| 05 Testing: Token Factory & Negative Matrix | Token factory fixture, parametrised matrix of rejected tokens, 401 vs 403, WWW-Authenticate, refresh reuse, controlling time with freezegun or an injected clock, token leakage in logs |
| 06 Testing: JWKS Rotation, Smoke Tests & CI | Local fake JWKS server, key rotation and stale-cache tests, fetch_data override, forged-token smoke tests against a deployed API, CI gates |
When to Use / Not Use¶
| Need | Choose |
|---|---|
| Sign and verify JWTs (HS256, RS256, ES256, EdDSA) in an API or a test suite | PyJWT |
| Verify tokens issued by an identity provider through its JWKS endpoint | PyJWT PyJWKClient |
| Encrypted tokens (JWE) | a JOSE library that implements JWE, such as jwcrypto or joserfc |
| Full OAuth 2.0 / OpenID Connect client or authorization server | a framework such as Authlib; PyJWT only handles the token |
| Instant revocation, server-side session data, tokens that must never be readable by the client | opaque session IDs or reference tokens, not JWTs |
| Password hashing | not PyJWT: see Auth, Config & Security Headers |
Mental Model¶
flowchart LR
subgraph I["Issuer"]
C["claims dict<br/>iss aud sub exp jti"] -->|"jwt.encode(claims, private_key, algorithm=...)"| T
end
T["header.payload.signature<br/>(base64url, JSON)"] -->|"Authorization: Bearer ...<br/>or cookie"| V
subgraph R["Resource server"]
V["jwt.decode(token, public_key,<br/>algorithms=[...], audience=, issuer=)"] --> OK["claims dict"]
V -->|"InvalidTokenError subclass"| E["401 + WWW-Authenticate"]
OK --> Z["authorization:<br/>scope / role check → 403"]
end
- Header —
alg(algorithm),typ, optionalkid(which key signed it). - Payload — JSON claims. Base64url-encoded, not encrypted: anyone holding the token can read it.
- Signature — proves integrity and origin. It does not hide anything.
- Verification — signature first, then claim checks. Authorization (what the caller may do) is separate and happens after
decode.
Minimal Example¶
from datetime import datetime, timedelta, timezone
import jwt
SECRET = "placeholder-secret-use-32-random-bytes-or-more" # load from env or a secret store
now = datetime.now(timezone.utc)
token = jwt.encode(
{"sub": "user-42", "iat": now, "exp": now + timedelta(minutes=15)},
SECRET,
algorithm="HS256",
)
claims = jwt.decode(token, SECRET, algorithms=["HS256"], options={"require": ["exp", "sub"]})
print(claims["sub"]) # user-42
try:
jwt.decode(token, "another-placeholder-secret-of-32-bytes!", algorithms=["HS256"])
except jwt.InvalidTokenError as exc:
print(type(exc).__name__) # InvalidSignatureError
exp and iat can be datetime objects: encode converts them to integer seconds since the epoch. decode returns them as integers.
Names from Older Tutorials¶
Many posts predate PyJWT 2.x or use a different library.
| Old code | Current |
|---|---|
jwt.encode(...).decode("utf-8") |
encode returns str; calling .decode raises AttributeError |
jwt.decode(token, key) |
algorithms=[...] is required, otherwise DecodeError |
jwt.decode(token, key, verify=False) |
The verify argument does nothing (a mismatch with verify_signature only raises a DeprecationWarning), so the signature is still checked. Use options={"verify_signature": False} |
algorithms="HS256" (a string) |
Pass a list. A bare string is matched as a substring, so "HS256 RS256" would allow both |
jwt.ExpiredSignature, jwt.InvalidAudience, jwt.InvalidIssuer |
jwt.ExpiredSignatureError, jwt.InvalidAudienceError, jwt.InvalidIssuerError; the old names no longer exist |
from jose import jwt, JWTError |
A different package (python-jose). With PyJWT: import jwt and catch jwt.InvalidTokenError or jwt.PyJWTError |
datetime.utcnow() + timedelta(...) for exp |
datetime.now(timezone.utc) + timedelta(...). utcnow() is deprecated in Python 3.12+; encode treats a naive datetime as UTC |
Extra keyword arguments to decode() |
Emit RemovedInPyjwt3Warning (a DeprecationWarning subclass) and are not used. Move settings into options={...} |
jwt.encode(payload, key) with no algorithm |
Defaults to HS256. Always pass algorithm= explicitly (with a PyJWK key the key's own algorithm is the default) |
jwt.InvalidSubjectError, jwt.InvalidJTIError |
Not exported at the top level: use jwt.exceptions.InvalidSubjectError / jwt.exceptions.InvalidJTIError |
Quick Rules¶
- Always pass
algorithms=[...]as a list, hard-coded or configured next to the key. Never derive it from the token header. - Verify
audandisswithaudience=andissuer=, and putexp(plusiss,aud,sub,jtiwhere you use them) inoptions={"require": [...]}:verify_*options only run on claims that are present. - Keep access tokens short (minutes), allow a small
leewayfor clock skew, and use refresh tokens with rotation for long sessions. - Treat the payload as public. It is base64url JSON, not encrypted: no passwords, PII or secrets.
- Use asymmetric keys when other services verify your tokens (RS256 / ES256 / EdDSA with
pyjwt[crypto]); use HS256 only when issuer and verifier are the same service, with a 32-byte or longer random secret. - Verify identity-provider tokens through
PyJWKClient: one shared instance, so the JWKS cache works; read the key withget_signing_key_from_jwt. - Map failures correctly: any
InvalidTokenErroris HTTP 401 withWWW-Authenticate: Bearer; 403 is for a valid token without permission; a JWKS fetch failure is 503. - Test the negative paths: a token factory plus a parametrised matrix covers expired, not-yet-valid, wrong
aud/iss, wrong key, tampered payload,alg: noneand missing claims. - Keep PyJWT current. Several key-confusion and JWKS-client fixes landed in 2.13 to 2.15; pin a recent lower bound and audit dependencies (Dependency Security).
See also¶
- Digital Garden: Knowledge Base
- Python Libraries
- FastAPI
- Pytest
- HTTPX — Modern Python HTTP Client
- Pydantic — Data Validation
- OWASP API Security
- Auth, Config & Security Headers
- Security & Observability (API architectures)