python-jose
python-jose implements the JOSE family of standards in Python: JWS for signing, JWE for encryption, JWK for key representation, JWA for the algorithm registry, and a jwt module on top that most people use for exactly one thing, turning a dict of claims into a signed token and back. The API is two functions: jwt.encode(claims, key, algorithm) and jwt.decode(token, key, algorithms), with claim validation for exp, nbf, iat, aud, iss, sub, jti, and at_hash built into decode. It supports three interchangeable crypto backends chosen by install extra: pyca/cryptography (recommended), pycryptodome, or a pure-Python fallback built on the rsa and ecdsa packages. Its enormous download count comes mostly from having been the JWT library in FastAPI's security tutorial for years, which is no longer the case.
It works, it is widely deployed, and the API is pleasant, but a cryptography library with a three-and-a-half-year release gap and a ten-month turnaround on two critical advisories is not where new code should start. Use PyJWT for plain JWTs, joserfc or jwcrypto if you genuinely need JWE, and migrate existing python-jose code when you get the chance.
Use it if
- You need JWE encryption or general JWS over non-JSON payloads, not just signed JWTs, and want one library covering the whole JOSE set
- You are maintaining an existing codebase that already imports from jose and a rewrite is not worth the risk this quarter
- You want claim validation (exp, nbf, aud, iss, sub, jti, at_hash) and a require_* option set handled by decode rather than written by hand
- You verify tokens against a provider's JWKS document and like that decode accepts the raw JWK Set dict as the key without any conversion step
- You only sign and verify JWTs: PyJWT does that with a smaller surface and much faster security response, and FastAPI's own security tutorial now installs PyJWT instead of python-jose
- You care how quickly security holes get fixed: two critical advisories landed in April 2024 (algorithm confusion with OpenSSH ECDSA keys, and a JWE decompression bomb), and the release carrying both fixes, 3.4.0, did not ship until February 2025
- You look at release cadence: 3.3.0 shipped in June 2021 and the next release was 3.4.0 in February 2025, a gap of three and a half years on a cryptography library, and 95 issues are open (116 counting PRs)
- Your dependency scanner is strict: ecdsa, rsa, and pyasn1 are unconditional dependencies even when you install the cryptography extra, and python-ecdsa carries an unpatched high-severity Minerva timing advisory on P-256 that its maintainers do not intend to fix
- You want typing or modern packaging: there are no type hints, no py.typed marker, and the backend selection happens through import-order magic rather than explicit configuration
- You want a maintained full-JOSE implementation: joserfc and jwcrypto both cover JWS, JWE, and JWK with active maintainers and clearer key handling
Setup reality
Always install it as pip install 'python-jose[cryptography]'. The plain pip install python-jose leaves you on the pure-Python backend, which cannot process certificates and leans on rsa and python-ecdsa for real signature math. The catch is that the extra does not remove anything: rsa, ecdsa, and pyasn1 stay in your dependency tree unconditionally, which the README acknowledges and blames on setuptools, and it tells you to prune them yourself in production. Backend selection is implicit, decided by which packages happen to be importable, so a container that dropped cryptography silently degrades to the slower pure-Python path instead of failing. Beyond installation, the sharp edge is the API: the algorithms argument to decode is technically optional, and leaving it out lets the token's own header pick the algorithm, which is the classic JWT downgrade bug. There is no key rotation helper, no JWKS caching, and no clock-skew default beyond leeway=0, so a distributed system needs a few seconds of leeway added by hand.
Patterns
Install with the cryptography backendinstall-with-backend
# pyproject.toml / requirements.txt
python-jose[cryptography]==3.5.0
# verify which backend actually loaded
from jose.backends import RSAKey
print(RSAKey.__module__)
# jose.backends.cryptography_backend -> good
# jose.backends.rsa_backend -> pure Python fallbackThe extra adds pyca/cryptography but does not remove rsa, ecdsa, or pyasn1, which stay as hard dependencies. Print the backend in a startup check, because a missing cryptography package downgrades silently instead of raising.
Sign a token with an expiryencode-jwt
from datetime import datetime, timedelta, timezone
from jose import jwt
now = datetime.now(timezone.utc)
claims = {
"sub": "user-4213",
"scope": "orders:read",
"iss": "https://auth.example.com",
"aud": "orders-api",
"iat": now,
"nbf": now,
"exp": now + timedelta(minutes=15),
}
token = jwt.encode(claims, SECRET, algorithm="HS256")Datetime objects are converted to numeric timestamps for you, but only timezone-aware ones are safe; a naive datetime is interpreted as local time and produces tokens that expire at the wrong moment on a server in another zone.
Verify a token without allowing an algorithm downgradedecode-jwt-safely
from jose import jwt
claims = jwt.decode(
token,
SECRET,
algorithms=["HS256"], # never omit this
audience="orders-api",
issuer="https://auth.example.com",
)algorithms is the single most important argument in the library. Leaving it out lets the token's own alg header choose, which is how attackers turn an RS256 verifier into an HS256 one using the public key as the shared secret.
Tell expiry apart from a bad signaturehandle-jwt-errors
from jose import jwt
from jose.exceptions import ExpiredSignatureError, JWTClaimsError, JWTError
try:
claims = jwt.decode(token, key, algorithms=["RS256"], audience="orders-api")
except ExpiredSignatureError:
raise HTTPException(401, "token expired, refresh and retry")
except JWTClaimsError as exc:
raise HTTPException(403, f"claim rejected: {exc}")
except JWTError:
raise HTTPException(401, "invalid token")Order matters: ExpiredSignatureError and JWTClaimsError both subclass JWTError, so a bare except JWTError first swallows the specific cases. Never echo the exception text for the generic branch, it can leak details about your keys.
Allow for clock drift between servicesclock-skew-leeway
claims = jwt.decode(
token,
key,
algorithms=["RS256"],
audience="orders-api",
options={"leeway": 30}, # seconds, applies to exp, nbf, and iat
)leeway defaults to 0, so two machines a few seconds apart produce intermittent 'not yet valid' rejections that are almost impossible to reproduce locally. Thirty seconds is a common ceiling; more than that weakens short-lived tokens.
Reject tokens that omit claims you depend onrequire-claims
claims = jwt.decode(
token,
key,
algorithms=["RS256"],
audience="orders-api",
issuer="https://auth.example.com",
options={
"require_exp": True,
"require_iat": True,
"require_sub": True,
"require_aud": True,
},
)The verify_* options only check claims that are present; a token with no exp at all passes verification by default. Setting require_exp is what turns a missing expiry into a rejection, and it also forces the matching verify_ flag on.
Sign with a private key and verify with the public oners256-keypair
from jose import jwt
private_pem = open("jwt-signing.key", "rb").read()
public_pem = open("jwt-signing.pub", "rb").read()
token = jwt.encode({"sub": "svc-billing"}, private_pem, algorithm="RS256")
claims = jwt.decode(token, public_pem, algorithms=["RS256"])Since 3.4.0 signing with a public key raises instead of quietly producing a token, which was CVE-2024-33663. RS256 needs the cryptography or pycryptodome backend for anything involving certificates.
Verify against a provider's JWKS documentverify-with-jwks
import httpx
from jose import jwt
jwks = httpx.get("https://auth.example.com/.well-known/jwks.json").json()
claims = jwt.decode(
token,
jwks, # the whole {"keys": [...]} dict is accepted
algorithms=["RS256"],
audience="orders-api",
issuer="https://auth.example.com",
)python-jose matches the token's kid against the set for you, but it does no caching and no refresh, so fetching the JWKS per request will rate-limit you at the provider. Cache it with a TTL and refetch once on an unknown kid.
Inspect kid or alg before you have a keyread-unverified-header
from jose import jwt
header = jwt.get_unverified_header(token)
kid = header["kid"]
key = key_cache.get(kid) or refresh_keys()[kid]
claims = jwt.decode(token, key, algorithms=["RS256"])Everything from get_unverified_header and get_unverified_claims is attacker-controlled. Use it to route to a key, never to make an authorization decision, and never trust the alg value it reports.
Sign something that is not a JWT claim setjws-sign-arbitrary-payload
from jose import jws
signed = jws.sign(
b'{"webhook":"order.created","id":"ord_91"}',
SECRET,
algorithm="HS256",
headers={"kid": "webhook-2026-08"},
)
payload = jws.verify(signed, SECRET, algorithms=["HS256"]) # returns bytesjws.verify returns the raw payload bytes and performs no claim validation at all, so expiry and replay protection are yours to add. This is the layer jwt sits on, which is why the algorithms argument is mandatory here too.
Encrypt a payload with JWEjwe-encrypt
from jose import jwe
key = os.urandom(32) # 256-bit key for A256GCM with dir
token = jwe.encrypt(
b"card_token=tok_9f3",
key,
algorithm="dir",
encryption="A256GCM",
)
plaintext = jwe.decrypt(token, key)The zip='DEF' compression option was the subject of CVE-2024-33664, a decompression bomb; 3.4.0 caps decompressed output at 250 KiB. Leave compression off unless you have a reason, and remember JWE hides the payload but the header is still readable.
Build a key object from a JWK dictconstruct-jwk
from jose import jwk
from jose.utils import base64url_decode
key = jwk.construct(jwks["keys"][0], algorithm="RS256")
message, encoded_sig = token.rsplit(".", 1)
is_valid = key.verify(message.encode(), base64url_decode(encoded_sig.encode()))This lower-level path is only worth it when you are verifying signatures outside the JWT shape. Pass algorithm explicitly: inferring it from the JWK's alg field is exactly the confusion that CVE-2024-33663 exploited.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pyjwt | PyPI | You only need to sign and verify JWTs; smaller, typed, actively maintained, and now the library FastAPI's own tutorial recommends |
| joserfc | PyPI | You need the full JOSE set including JWE and JWK, with an active maintainer and explicit key objects |
| jwcrypto | PyPI | You want a full JOSE implementation built directly on pyca/cryptography with no pure-Python fallback in the tree |
| authlib | PyPI | The JWT work is part of a larger OAuth or OpenID Connect integration and you want the client and provider pieces too |