Verify every bearer token with PyJWT's jwt.decode(token, key, algorithms=["RS256"], issuer=..., audience=..., leeway=30, options={"require": ["exp", "iss", "aud", "sub"]}), with the key from configuration. Never pass verify_signature False, and never silence InvalidAudienceError with verify_aud False: a token for another API or tenant must fail.
- Find every jwt.decode() call and any code that base64-decodes the middle segment of a token yourself. Look for options={"verify_signature": False}, verify=False (PyJWT 1.x), and options that turn off verify_aud, verify_iss or verify_exp.
- Call jwt.decode() on every request before using any claim, with the key from configuration: a PEM string or key object for one key, or jwt.PyJWKClient(jwks_uri).get_signing_key_from_jwt(token).key when the identity provider rotates keys.
- Pass algorithms=["RS256"] on every call; PyJWT insists on it unless the key is a PyJWK. Never pass jwt.algorithms.get_default_algorithms() or a list that mixes HS* and RS* algorithms: CVE-2022-29217 and PyJWT's 2026 key-confusion advisories all needed HS256 to be allowed alongside a public key.
- Pass issuer= and audience= with exact values from configuration. Without audience=, PyJWT raises InvalidAudienceError for any token that has an aud claim; the fix is to pass this API's audience, not verify_aud False.
- Add options={"require": ["exp", "iss", "aud", "sub"]}, because PyJWT checks exp only when the token has one, and keep leeway in seconds (30 here).
- Catch jwt.PyJWTError, the base class of everything decode() raises, answer 401 with WWW-Authenticate: Bearer error="invalid_token" (RFC 6750), and log the reason on the server only.
- If your access tokens follow RFC 9068, use jwt.decode_complete() and reject tokens whose header typ is not at+jwt; PyJWT has no typ option.
- Add regression tests like this card's: alg none, HS256 signed with the public key, an attacker's key in the jwk header, an edited payload, expired tokens, no exp, another API's aud and another tenant's iss.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def verify_access_token(token: str, config: Mapping) -> dict:
# "The API gateway checks tokens, we only need the user ID."
return jwt.decode(token, options={"verify_signature": False})With IMPL=vulnerable, 9 of 14 tests fail (9 exploit tests).
The fix
def verify_access_token(token: str, config: Mapping) -> dict:
return jwt.decode(
token,
# The key comes from configuration, never from the token. With an identity provider
# that rotates keys, look the key up with jwt.PyJWKClient(jwks_uri) instead.
key=config["JWT_PUBLIC_KEY"],
algorithms=["RS256"], # the issuer's algorithm only: never "none", never HS256 with a public key
issuer=config["JWT_ISSUER"], # "iss" must equal our issuer
audience=config["JWT_AUDIENCE"], # "aud" must include this API
leeway=30, # seconds of clock skew between the issuer and this server
# PyJWT checks "exp" only when the token has one; require it, and the claims we use.
options={"require": ["exp", "iss", "aud", "sub"]},
)With IMPL=fixed, 14 of 14 tests pass.
A fix that looks safe but is not
def verify_access_token(token: str, config: Mapping) -> dict:
# Signature and algorithm are checked; issuer, audience and a required "exp" are not.
return jwt.decode(
token,
key=config["JWT_PUBLIC_KEY"],
algorithms=["RS256"],
leeway=30,
options={"verify_aud": False},
)With IMPL=wrong, 3 of 14 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Exploit: every token below claims to be Alice. The API must answer 401 and reveal nothing of hers.
def assert_rejected(client: FlaskClient, token: str) -> None:
res = get_account(client, token)
body = res.get_data(as_text=True)
assert res.status_code == 401, f"the token was accepted: HTTP {res.status_code} {body}"
assert "alice@example.test" not in body
def test_exploit_an_unsigned_token_alg_none_is_rejected(client):
assert_rejected(client, unsecured({"alg": "none", "typ": "JWT"}, claims_for("alice")))
def test_exploit_an_hs256_token_whose_hmac_secret_is_the_public_key_is_rejected(client):
# The public key is public. A verifier that lets the token pick HS256 and passes it the
# RSA public key would check this HMAC with the very bytes the attacker used.
header = {"alg": "HS256", "typ": "JWT", "kid": SEED["keyId"]}
assert_rejected(client, sign_hs256(header, claims_for("alice"), ISSUER_KEYS.public_key_pem.encode()))
def test_exploit_a_token_signed_with_the_attackers_own_key_embedded_in_its_jwk_header_is_rejected(client):
header = {**HEADER, "jwk": ATTACKER_KEYS.public_jwk}
assert_rejected(client, sign_rs256(header, claims_for("alice"), ATTACKER_KEYS.private_key))
def test_exploit_mallorys_token_with_its_payload_changed_to_alices_is_rejected(client):
assert_rejected(client, with_payload(issue(claims_for("mallory")), claims_for("alice")))
def test_exploit_a_token_that_expired_an_hour_ago_is_rejected(client):
t = now()
assert_rejected(client, issue(claims_for("alice", iat=t - 3900, exp=t - 3600)))
def test_exploit_a_token_that_expired_two_minutes_ago_is_rejected_beyond_the_30_second_tolerance(client):
t = now()
assert_rejected(client, issue(claims_for("alice", iat=t - 420, exp=t - 120)))
def test_exploit_a_token_without_exp_is_rejected(client):
never_expires = claims_for("alice")
del never_expires["exp"]
assert_rejected(client, issue(never_expires))
def test_exploit_a_token_the_identity_provider_issued_for_another_api_aud_is_rejected(client):
assert_rejected(client, issue(claims_for("alice", aud=SEED["otherAudience"])))
def test_exploit_a_token_from_another_tenant_that_shares_the_signing_key_iss_is_rejected(client):
assert_rejected(client, issue(claims_for("alice", iss=SEED["otherIssuer"])))- options={"verify_signature": False} also switches off the exp, nbf, iat, aud and iss checks unless you set them back explicitly (PyJWT's _merge_options), so a decoded payload proves nothing.
- verify_aud False is the common answer to "InvalidAudienceError: Invalid audience". The error means the token names an audience and you did not say which one is yours; pass audience= instead.
- Without issuer=, PyJWT does not look at iss at all, even though verify_iss defaults to True.
- PyJWT's require list is empty by default: a token without exp never expires unless you require it. leeway applies to exp, nbf and iat alike.
- PyJWT refuses to use a public key as an HMAC secret, but that guard has been bypassed more than once: CVE-2022-29217 (before 2.4.0), and in 2026 public keys passed as a JWK (GHSA-xgmm-8j9v-c9wx, fixed in 2.13.0) or as DER or reformatted PEM (GHSA-p4g4-x82p-q773 and GHSA-ffc3-869f-jxw9, fixed in 2.14.0). The algorithm allowlist is the defence; the guard is a backstop.
- Take keys only from the configured issuer's JWKS. Never fetch keys from a URL in the token (jku, x5u) or trust a key it embeds (jwk); PyJWKClient follows no redirects since 2.14.0, but the URL must still come from configuration.
- Flask has no bearer-token support of its own: check the token in a decorator or before_request hook that every protected view uses, and fail closed with 401.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs jwt-validation/python-flask does this for you).
- The nine exploit tests build tokens byte by byte with hmac and cryptography, all claiming to be Alice: alg none, HS256 signed with the public key, the attacker's key in a jwk header, Mallory's token with an edited payload, expired an hour ago, expired two minutes ago, no exp, another API's aud and another tenant's iss. Each must get 401 without Alice's data. The functional tests keep valid tokens, 10 seconds of clock skew and a multi-audience aud working.
- Against your own API, replay a token the identity provider issued for a different API and one whose payload you edited, and expect 401 with WWW-Authenticate: Bearer error="invalid_token". Semgrep sees verify_signature False but not verify_aud False or a missing issuer=; the regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | python.jwt.security.unverified-jwt-decode.unverified-jwt-decode | 1 finding in impl/vulnerable.py (options={"verify_signature": False}); none in fixed.py or wrong.py. The rule only matches verify_signature set to False: impl/wrong.py's options={"verify_aud": False}, and a decode() without issuer=, are not reported (also checked on a probe file).Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/python.jwt.security.unverified-jwt-decode.unverified-jwt-decode <file> on each impl file and app.py |
| Semgrep | python.jwt.security.jwt-none-alg.jwt-python-none-alg | No findings, as expected: the rule flags jwt.encode(..., algorithm="none") and algorithms lists that contain "none", and no implementation here allows none. The vulnerable decode accepts alg none tokens without naming the algorithm at all.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | py/jwt-missing-verification | From the query and its PyJWT model (python/ql/src/experimental/semmle/python/libraries/PyJWT.qll): it reports a jwt.decode() call whose verify argument is False or whose options dict literal has any key containing "verify" set to False. That should flag impl/vulnerable.py and also impl/wrong.py, whose verify_aud False it would report as an unverified signature. It is experimental, so the standard suites do not run it.Checked: github/codeql main at b5b8165; not executed |
| Bandit | n/aNo JWT plugin | No issues reported for any implementation; Bandit has no check for JWT decoding options.Checked: Bandit 1.9.4, bandit -r impl app.py |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Flask 3.1.3 |
| flask | 3.1.3 |
| werkzeug | 3.1.9 |
| pyjwt | 2.15.0 |
| cryptography | 50.0.2 (PyJWT's RSA support; the tests also sign tokens with it) |
| pytest | 9.1.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the jwt-validation/python-flaskfolder.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs jwt-validation/python-flask runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python 3.14.8
- flask 3.1.3
- pyjwt 2.15.0
- cryptography 50.0.2
- pytest 9.1.1
- Matrix
- fixed: 14 of 14 tests pass; vulnerable: 9 of 14 tests fail (9 exploit tests); wrong: 3 of 14 tests fail (3 exploit tests)
- Test results
- fixed 14/14 pass; vulnerable 9/14 fail (all 9 exploit tests); wrong 3/14 fail (3 exploit tests: no exp, another API's aud, another tenant's iss), offline after install
- Dependency freshness
- requirements.txt pins all 17 packages with the hash of every file PyPI lists, so pip installs in hash-checking mode. PyJWT 2.15.0 (2026-09-23) is used rather than 2.15.1 (2026-09-28): 2.15.1 only relaxes Base64URL padding and is under 14 days old, while 2.14.0 and 2.15.0 are security releases. cryptography 50.0.2 (2026-09-30) is taken at 10 days old because its wheels bundle OpenSSL 4.0.3, a security release; werkzeug 3.1.9 (2026-09-27) likewise fixes GHSA-g6x2-hccm-hh4m. pycparser stays at 3.0 because 3.1 and 3.11 are days old. OSV listed no advisories for the 16 installed packages. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- RFC 8725: JSON Web Token Best Current Practices (2.1 weak signatures, 3.1 algorithm verification, 3.8 issuer, 3.9 audience)
- OWASP JSON Web Token Cheat Sheet
- PyJWT: usage examples (audience, issuer, required claims, leeway, PyJWKClient)
- PyJWT: API reference (jwt.decode options)
- PyJWT changelog (2.14.0 and 2.15.0 security fixes)
- GHSA-ffqj-6fqr-9h24 / CVE-2022-29217: key confusion in PyJWT before 2.4.0
- RFC 9068: JWT Profile for OAuth 2.0 Access Tokens (section 4, validating access tokens)
- RFC 6750: Bearer Token Usage (WWW-Authenticate and invalid_token)
Attack patterns: