Skip to content

python-jose or PyJWT — and what the swap breaks

Severity: mediumBehaviour changed in FastAPI docs, PRApplies to: FastAPI 0.100+Applies to: python-jose 3.5.0Applies to: PyJWT 2.13.0Facts last verified 2026-08-16 against python-jose 3.5.0 · PyJWT 2.13.0 · ecdsa 0.19.2 · FastAPI 0.141.1

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:

settings on this page
python-joseoption

Not 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
Read in: PyPI JSON API — python-jose releases and requires_dist; install reproduced into a clean Python 3.14 environment
ecdsaoption

Carries 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
Read in: GitHub Advisory Database GHSA-wj6h-64fc-37mp; version observed after pip install python-jose[cryptography]
PyJWToption

The 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
Read in: PyPI JSON API; GitHub Advisory Database, ecosystem=pip affects=pyjwt

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.

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.

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:

if you see this
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 key
jwt.exceptions.InvalidSignatureError: Signature verification failed

PyJWT'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 key

FastAPI’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.

the fix — change the handler with the import
import jwt
from 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.

verify it workedRun this in: shell
Terminal window
# 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 swap
grep -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:

Terminal window
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.

before you ship this

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.

  • 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