mrkeyoor.com_
Thu 06 Aug 15:38 UTC
PyPISecurityupdated 06 Aug 2026

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.

Verdict

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.

API stability5/5jwt.encode and jwt.decode have not changed shape since 3.0 in 2018; the only breaks in recent releases were dropped Python versions and forbidding the previously accepted mistake of signing with a public key
Docs2/5The ReadTheDocs site is a thin autodoc dump and the README covers installation, backends, and one encode/decode example; JWE, JWK construction, and the options dict are documented only in docstrings, so most real answers come from reading jose/jwt.py
Maintenance1/5Three and a half years passed between 3.3.0 in June 2021 and 3.4.0 in February 2025, the April 2024 critical advisories waited ten months for a release, 95 issues are open (116 counting PRs), and the last push was April 2026
Ecosystem4/5About 10.6M weekly downloads and 1.8k stars, and it appears in a great deal of existing FastAPI and Flask auth code, but that install base is legacy momentum rather than growth now that FastAPI's tutorial points at PyJWT

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
Skip it if

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 fallback

The 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 bytes

jws.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

PackageRegistryPick it when
pyjwtPyPIYou only need to sign and verify JWTs; smaller, typed, actively maintained, and now the library FastAPI's own tutorial recommends
joserfcPyPIYou need the full JOSE set including JWE and JWK, with an active maintainer and explicit key objects
jwcryptoPyPIYou want a full JOSE implementation built directly on pyca/cryptography with no pure-Python fallback in the tree
authlibPyPIThe JWT work is part of a larger OAuth or OpenID Connect integration and you want the client and provider pieces too