python-jose or PyJWT — and what the swap breaks
FastAPI’s own OAuth2-JWT tutorial installed python-jose for years. It now installs
PyJWT, and the change was made in the documentation without a migration note. If your
authentication code was scaffolded from that tutorial — which a great many FastAPI
applications were — you have python-jose in your requirements and nothing has told you
to look at it.
The change is worth understanding rather than copying, because the reason usually given for it is out of date, and the mechanical version of the swap introduces a bug.
The reason usually given is no longer true
Section titled “The reason usually given is no longer true”The common account is that python-jose was abandoned in 2021 and carries unpatched
vulnerabilities. That was accurate for a long time and is not accurate now.
| Release | Date |
|---|---|
| 3.3.0 | 2021-06-05 |
| (no releases) | 3.7 years |
| 3.4.0 | 2025-02-18 |
| 3.5.0 | 2025-05-28 |
The gap was real, and the discussions that produced the “abandoned” description were written inside it. But 3.4.0 landed in February 2025 and patched the advisories that description refers to — including CVE-2024-33663, the critical algorithm-confusion issue with OpenSSH ECDSA keys. As of 3.5.0 the package has no known unpatched advisory against it.
So “it is unmaintained and vulnerable” is not the argument. There is a better one.
The argument that does hold is a dependency you cannot patch
Section titled “The argument that does hold is a dependency you cannot patch”python-jose requires ecdsa unconditionally. That package carries
CVE-2024-23342, a Minerva timing attack on the P-256 curve, and the advisory is
unusual:
python-joseoptionNot abandoned, despite the common claim. Releases stopped after 3.3.0 in June 2021 and resumed with 3.4.0 in February 2025; all four of its known advisories are patched as of 3.4.0. The durable problem is a dependency rather than the package — it requires ecdsa unconditionally, and the [cryptography] extra adds a backend without removing that requirement.
- default
3.5.0, published 2025-05-28- set in
requirements.txt
ecdsaoptionCarries CVE-2024-23342, a high-severity Minerva timing attack on the P-256 curve. The advisory records the affected range as every version and no patched version, because the project treats side-channel attacks as out of scope. Signing, key generation and ECDH are affected; signature verification is not.
- default
0.19.2 — installed by python-jose whether or not you ask for it- set in
transitive dependency
PyJWToptionThe library FastAPI's tutorial now installs. Actively released, but not a decision you make once — five advisories were fixed in 2.13.0, including a high-severity case where a public-key JWK is accepted as an HMAC secret. Switching to PyJWT and pinning an older version trades one problem for another.
- default
2.13.0, published 2026-05-21- set in
requirements.txt
The affected range is every version and the patched version is none — the project considers side-channel attacks out of scope and has said there is no planned fix. This is not a version you can upgrade past.
Be precise about what that means for you, because the severity rating overstates it for most readers:
- Signature verification is unaffected. Only signing, key generation and ECDH touch the vulnerable path.
- An HS256 application never reaches that code at all. HS256 is symmetric, and it is what the FastAPI tutorial builds. The package is installed and unused.
- If you sign with ES256, this is a real exposure rather than an inventory finding, and it is the reason to move.
For everyone else it is a dependency-scanner finding that will not go away and cannot be resolved in place. That is a legitimate reason to prefer the other library. It is not the same as being exploitable, and a page that told you otherwise would be overselling it.
The [cryptography] extra does not help here. pip install "python-jose[cryptography]"
— the exact form the old tutorial specified — installs cryptography and ecdsa;
the extra adds a backend, it does not remove the requirement.
PyJWT is not a decision you make once
Section titled “PyJWT is not a decision you make once”Moving is defensible. Treating the move as finished is not. Five advisories were fixed in PyJWT 2.13.0, published 2026-05-21, including CVE-2026-48526 — a public-key JWK accepted as an HMAC secret, which allows forged HS256 tokens when key families are mixed.
An application that switched to PyJWT a year ago and pinned the version it switched to is now running a library with a known token-forgery issue. Whichever library you land on, the control is that it stays current — not which name is in the file.
What the swap actually changes
Section titled “What the swap actually changes”Reproduced on python-jose 3.5.0 and PyJWT 2.13.0, HS256, same key and claims:
| Same? | |
|---|---|
Call signature of encode / decode |
identical |
| Token produced | byte-for-byte identical |
Return type of encode |
str in both |
| Tokens already issued to your users | keep validating |
| Exception classes | completely disjoint |
The first four are why the swap looks free. The last one is the bug.
jose.exceptions.JWTError and jwt.exceptions.InvalidTokenError are unrelated classes.
Neither is a subclass of the other, so an except clause written for one catches nothing
from the other:
jose.exceptions.JWTError: Signature verification failed.What python-jose raises for a bad signature. Note the trailing period — PyJWT's equivalent message has none.
Read in: reproduced — python-jose 3.5.0, HS256, decoded with a modified keyjwt.exceptions.InvalidSignatureError: Signature verification failedPyJWT's equivalent. It is a subclass of InvalidTokenError and is unrelated to JWTError, so an except clause written for one does not catch the other.
Read in: reproduced — PyJWT 2.13.0, HS256, decoded with a modified keyFastAPI’s tutorial changed both halves together — from jose import JWTError, jwt became
import jwt plus from jwt.exceptions import InvalidTokenError, and every except JWTError: became except InvalidTokenError:. Changing only the import is the mistake,
and nothing reports it: the code imports, the tests that use valid tokens pass, and the
failure appears only on a token that is invalid or expired — where the exception is no
longer caught, escapes the dependency, and returns 500 instead of 401.
That inverts the endpoint’s behaviour on exactly the input the check exists for.
import jwtfrom jwt.exceptions import InvalidTokenError
async def get_current_user(token: str = Depends(oauth2_scheme)): try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) except InvalidTokenError: raise credentials_exception ...algorithms= is not optional in either library and should stay an explicit list — it is
what stops a token from choosing its own verification algorithm. Keep InvalidTokenError
as the caught class rather than a narrower subclass: expired, malformed and
bad-signature tokens all derive from it, and all three should end in the same 401.
# 1. is the old library still present, directly or transitively?pip show python-jose >/dev/null 2>&1 && echo "python-jose present" || echo "clean"pip show ecdsa >/dev/null 2>&1 && echo "ecdsa present" || echo "clean"
# 2. any except clause left behind after an import swapgrep -rn "JWTError" --include=*.py .The second command is the one that matters. A hit on JWTError in a codebase that
imports jwt rather than jose is the 500-instead-of-401 case, sitting there
compiling.
Then confirm the behaviour end to end, since that is the actual claim:
curl -s -o /dev/null -w '%{http_code}\n' \ https://api.example.com/users/me -H 'Authorization: Bearer not.a.token'401 is correct. 500 means the exception is escaping your handler.
The libraries are not drop-in for every algorithm. python-jose supports JWE — encrypted
tokens — and PyJWT does not. If you decrypt tokens rather than only verifying signed
ones, this is a rewrite and not a swap, and the tutorial path does not cover you.
For RSA or ECDSA signing, PyJWT needs its own extra: pip install "pyjwt[crypto]". A
migration that drops the old cryptographic backend without adding the new one fails at
the first asymmetric verification, not at import.
Tokens already in circulation keep working, so there is no forced logout and no need to
rotate keys for this change alone. That makes it safe to ship behind a normal deploy —
but it also means a partly-migrated service will not announce itself, since every valid
token still validates. The grep above is the check that finds it.
Related
Section titled “Related”- Session cookies — the other credential FastAPI apps carry, and why it is readable
- CSRF protection — which of these credentials needs it, and the packages that claim to provide it