Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix JWT validation in Python and FastAPI

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Put verification in one dependency that every protected route declares: HTTPBearer() reads the token, then jwt.decode(token, key, algorithms=["RS256"], issuer=..., audience=..., leeway=30, options={"require": ["exp", "iss", "aud", "sub"]}) with the key from configuration, and any PyJWTError becomes 401. Never turn off verify_signature or verify_aud.

Stack
  • Language Python
  • Runtime CPython 3.14.8
  • Framework FastAPI 0.142.2 (Starlette 1.7.0) with an HTTPBearer dependency
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks6 cards
Download the project ZIP, 21 KB
On this page

Steps

  1. Find every jwt.decode() call and any code that base64-decodes the middle segment of a token yourself. Look for options={"verify_signature": False} and options that turn off verify_aud, verify_iss or verify_exp.
  2. Write one dependency that takes Annotated[HTTPAuthorizationCredentials, Depends(HTTPBearer())], verifies credentials.credentials and returns the claims, and declare it on every protected path operation (or on the router with dependencies=[...]).
  3. In it, call jwt.decode() with the key from configuration (or jwt.PyJWKClient(jwks_uri).get_signing_key_from_jwt(token).key when the identity provider rotates keys), algorithms=["RS256"], issuer=, audience=, leeway in seconds and options={"require": ["exp", "iss", "aud", "sub"]}.
  4. Catch jwt.PyJWTError and raise HTTPException(status_code=401, headers={"WWW-Authenticate": 'Bearer error="invalid_token"'}) (RFC 6750); log the reason on the server only.
  5. 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.
  6. Test with real signed tokens sent through TestClient, like this card's nine exploit tokens; do not replace the verifying dependency with dependency_overrides in these tests.

Vulnerable, fixed and wrong

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

Vulnerableimpl/vulnerable.pyPython
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

Fixedimpl/fixed.pyPython
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.

The fix: the bearer dependency every route declares

Fixedapp.pyPython
def token_claims(credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer)]) -> dict:
    """Every route that declares this dependency gets verified claims or a 401."""
    try:
        return impl.verify_access_token(credentials.credentials, config)
    except jwt.PyJWTError as err:
        # The reason goes to the server log, not to the caller.
        log.warning("rejected bearer token: %s: %s", type(err).__name__, err)
        raise HTTPException(
            status_code=401,
            detail="invalid token",
            headers={"WWW-Authenticate": 'Bearer error="invalid_token"'},
        ) from err

@app.get("/api/account")
def account(claims: Annotated[dict, Depends(token_claims)]) -> dict:
    found = by_subject.get(claims.get("sub"))
    if found is None:
        raise HTTPException(status_code=404, detail="no such account")
    return found

With IMPL=fixed, 14 of 14 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.pyPython
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 regression test

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.

Testtests/test_jwt.pyPython
# Exploit: every token below claims to be Alice. The API must answer 401 and reveal nothing of hers.


def assert_rejected(client: TestClient, token: str) -> None:
    res = get_account(client, token)
    body = res.text
    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"])))

Pitfalls

  • HTTPBearer, like OAuth2PasswordBearer, only extracts the token string from the Authorization header; it verifies nothing. The security comes from what your dependency does with that string.
  • FastAPI 0.122.0 (2025-11-24) changed its security classes to answer a missing or non-bearer Authorization header with 401 and WWW-Authenticate; earlier versions answered 403, so clients and tests written against them may expect the wrong status.
  • options={"verify_signature": False} also switches off the exp, nbf, iat, aud and iss checks unless you set them back explicitly, 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, and its require list is empty by default: a token without exp never expires unless you require it.
  • Pass algorithms=["RS256"] on every call and never a list that mixes HS* and RS* algorithms: PyJWT's public-key-as-HMAC-secret guard has been bypassed before (CVE-2022-29217, and three 2026 advisories fixed in 2.13.0 and 2.14.0).
  • A dependency that verifies tokens is easy to forget on one route. Declare it on the APIRouter (dependencies=[Depends(token_claims)]) when every route needs it, and test a route without it.
  • 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).

Verify the fix

  1. 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-fastapi does this for you).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix JWT validation in Python and FastAPI
ToolRuleWhat it found here
Semgreppython.jwt.security.unverified-jwt-decode.unverified-jwt-decode1 finding in impl/vulnerable.py (options={"verify_signature": False}); none in fixed.py or wrong.py. It does not report wrong.py's verify_aud False or the missing issuer=.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/<id> app.py impl
Semgreppython.jwt.security.jwt-none-alg.jwt-python-none-algNo findings, as expected: no implementation names the none algorithm; the vulnerable decode accepts alg none tokens without naming any algorithm.Checked: Semgrep 1.180.0 (CE)
Semgreppython.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure1 finding in app.py, a false positive: the log message contains the word token, but the call logs only the PyJWT exception's class and message, never the token.Checked: Semgrep 1.180.0 (CE)
CodeQLpy/jwt-missing-verificationFrom 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
Banditn/aNo JWT pluginNo issues reported for any implementation; Bandit has no check for JWT decoding options.Checked: Bandit 1.9.4, bandit -r impl app.py

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFastAPI 0.142.2 (Starlette 1.7.0) with an HTTPBearer dependency
fastapi (HTTPBearer)0.142.2
pyjwt2.15.1
cryptography (PyJWT's RSA support; the tests also sign tokens with it)50.0.2
httpx2 (TestClient transport, test only)2.13.1
pytest9.1.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the jwt-validation/python-fastapifolder.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
Shell
# 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 pytest

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs jwt-validation/python-fastapi runs all three modes and checks the expected results.

Download the project ZIP, 21 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • python CPython 3.14.8
  • fastapi 0.142.2
  • starlette 1.7.0
  • pyjwt 2.15.1
  • cryptography 50.0.2
  • pytest 9.1.1
  • semgrep 1.180.0
  • bandit 1.9.4
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
PyJWT 2.15.1 (2026-09-28) is the newest release older than 7 days; it only relaxes Base64URL padding over 2.15.0, a security release (the Flask card keeps 2.15.0 under its 14-day rule). cryptography 50.0.2 dates from 2026-09-30. FastAPI 0.142.2 (2026-09-30) and Pydantic 2.13.5 rather than 0.143.0 and 2.14.0 (2026-10-08), which fix no security issue; iniconfig 2.3.0 and pycparser 3.0 because their newer releases are under 7 days old. OSV reported no known vulnerabilities for any of the 24 pinned packages.
Notes
impl/*.py is the Flask card's verification code unchanged; what is FastAPI-specific is the HTTPBearer dependency in app.py.

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

  1. RFC 8725: JSON Web Token Best Current Practices
  2. OWASP JSON Web Token Cheat Sheet
  3. PyJWT: usage examples (audience, issuer, required claims, leeway, PyJWKClient)
  4. PyJWT: API reference (jwt.decode options)
  5. FastAPI reference: security tools (HTTPBearer)
  6. FastAPI tutorial: OAuth2 with password, bearer with JWT tokens
  7. FastAPI release notes 0.122.0: 401 status code in security classes when credentials are missing
  8. GHSA-ffqj-6fqr-9h24 / CVE-2022-29217: key confusion in PyJWT before 2.4.0
  9. RFC 6750: Bearer Token Usage (WWW-Authenticate and invalid_token)
  10. RFC 9068: JWT Profile for OAuth 2.0 Access Tokens

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.