pyjwt
PyJWT is the standard Python implementation of JSON Web Tokens (RFC 7519). It encodes and decodes the compact signed-token format, verifying signatures and registered claims like exp, aud, and iss. It is a focused signing and verification library, not an auth framework: sessions, OAuth flows, and user models are your problem. Symmetric HS256 works out of the box; RSA and ECDSA algorithms need the optional cryptography dependency.
The correct default for signing and verifying JWTs in Python. The moment you need encryption, key management, or login flows, step up to joserfc or Authlib instead of bolting extras onto PyJWT.
Use it if
- You issue or verify JWTs in a Python API and want the boring, widely audited default that most frameworks build on
- You verify RS256/ES256 tokens against a JWKS endpoint (Auth0, Cognito, Entra ID); PyJWKClient handles key fetching and caching
- You want a light dependency: the base install is pure Python, and cryptography is only pulled in when you opt into asymmetric algorithms
- You need JWE (encrypted tokens) or the wider JOSE suite; PyJWT does signed tokens only, so use joserfc or Authlib for the rest
- You actually want login sessions or OAuth, not raw tokens; a framework-level auth library handles claims, refresh, and rotation with fewer footguns
- You expect the library to stop JWT misuse by itself; it makes the safe path available, but skipping audience checks or shipping a weak secret is still on you
Setup reality
pip install pyjwt covers HS256 end to end. The classic trip-ups: RS256 and friends raise at runtime until you install the pyjwt[crypto] extra, jwt.decode in 2.x demands an explicit algorithms list (code ported from the 1.x era breaks right there), and there is an unrelated package literally named jwt on PyPI that also imports as jwt, so one typo in requirements gets you a different library with a colliding module name.
Patterns
Sign a token with HS256encode-hs256
import jwt
token = jwt.encode({"sub": "user-123"}, "secret", algorithm="HS256")
# 'eyJhbGciOiJIUzI1NiIs...'encode returns a str in 2.x (it was bytes in 1.x); use a long random secret, not a password.
Decode and verify a tokendecode-verify
import jwt
payload = jwt.decode(token, "secret", algorithms=["HS256"])algorithms is mandatory in 2.x and must be a fixed allowlist; never derive it from the token header, that is the classic algorithm-confusion attack.
Issue and check expiring tokensexpiring-tokens
import jwt
from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc)
token = jwt.encode(
{"sub": "user-123", "iat": now, "exp": now + timedelta(minutes=15)},
"secret",
algorithm="HS256",
)
try:
jwt.decode(token, "secret", algorithms=["HS256"])
except jwt.ExpiredSignatureError:
... # force re-authdatetime objects are converted to epoch seconds for you; exp is only enforced if the claim is present in the token.
Verify audience and issueraudience-issuer
payload = jwt.decode(
token,
"secret",
algorithms=["HS256"],
audience="my-api",
issuer="https://auth.example.com",
)If the token carries an aud claim and you pass no audience, decode raises InvalidAudienceError rather than silently passing.
Sign and verify with RS256rs256-keypair
import jwt
with open("private.pem") as f:
private_key = f.read()
with open("public.pem") as f:
public_key = f.read()
token = jwt.encode({"sub": "user-123"}, private_key, algorithm="RS256")
payload = jwt.decode(token, public_key, algorithms=["RS256"])Requires pip install pyjwt[crypto]; without the extra you get an ImportError-flavored error only when an RSA algorithm is first used.
Verify against a JWKS endpoint (OIDC)jwks-verify
import jwt
from jwt import PyJWKClient
jwks_client = PyJWKClient("https://example.auth0.com/.well-known/jwks.json")
signing_key = jwks_client.get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience="my-api",
)Construct PyJWKClient once at module or app scope; it caches keys, and rebuilding it per request refetches the JWKS every time.
Tolerate clock skewclock-skew-leeway
payload = jwt.decode(
token,
"secret",
algorithms=["HS256"],
leeway=10, # seconds
)leeway applies to exp, nbf, and iat checks; a few seconds covers real-world clock drift between servers.
Require specific claims to existrequire-claims
payload = jwt.decode(
token,
"secret",
algorithms=["HS256"],
options={"require": ["exp", "sub"]},
)require only checks presence, not validity; a token with exp still needs the normal expiry check, which decode does anyway.
Read the header to pick a keyunverified-header
import jwt
header = jwt.get_unverified_header(token)
key = key_store[header["kid"]]
payload = jwt.decode(token, key, algorithms=["RS256"])The header is attacker-controlled until the signature is verified; use it only for key lookup, never for trust decisions.
Catch token errors broadlyhandle-errors
import jwt
try:
payload = jwt.decode(token, "secret", algorithms=["HS256"])
except jwt.ExpiredSignatureError:
... # expired: offer refresh
except jwt.InvalidTokenError:
... # anything else invalid: rejectInvalidTokenError is the base class for the whole family (signature, audience, issuer, decode errors), so order the except blocks specific-first.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| joserfc | PyPI | Full JOSE support including JWE, when signed-only tokens stop being enough |
| authlib | PyPI | OAuth and OIDC client/server flows with JOSE built in |
| python-jose | PyPI | A JOSE-shaped API some older stacks standardized on; check its maintenance status before adopting |