Protecting FastAPI Routes With OIDC Bearer Tokens
Scenario: your FastAPI service is a resource server. It does not log users in — it receives Authorization: Bearer <jwt> access tokens minted by an OIDC provider and must decide, per request, whether to trust them. This walkthrough is part of the Integrating OIDC With Web Frameworks guide, and it is the resource-server counterpart to the browser-facing login flows: here there is no redirect, no code_verifier, only token validation done right.
The danger is that JWT validation looks trivial — decode, read claims, done — while the secure path requires verifying the signature against the IdP’s rotating public keys, pinning the algorithm, and asserting iss, aud, and exp. Skip any of these and you accept forged or misrouted tokens.
Root Cause: A Bearer Token Is Only as Good as Its Verification
An OIDC access token issued as a JWT (RFC 7519) carries claims an attacker would love to control. The defenses, in order:
- Signature — proves the IdP minted the token. Verified against the IdP’s public keys published at its JWKS endpoint.
algallowlist — the JWT header names its own algorithm. If you trust the header blindly, an attacker can setalg: none(no signature) orHS256(HMAC) and use your public key as an HMAC secret — the classic algorithm-confusion attack. You must pinRS256/ES256explicitly.iss— the token came from your IdP, not another tenant or a look-alike.aud— the token was minted for this API, not some other service that shares the IdP. Per OAuth 2.0 (RFC 6749) and OpenID Connect, audience binding is what stops token redirection.exp— the token has not expired.
Only after all five hold do you consult scopes for authorization. This mirrors the verification baseline established when configuring identity providers for OIDC.
The diagram’s branching makes the status-code contract explicit: failures before the scope check are 401 (the caller is not authenticated), while a valid token lacking the required scope is 403 (authenticated, but not authorized).
Setup: Cache the JWKS
JWKS keys rotate, so fetch them from the discovery-advertised jwks_uri and cache with a TTL. PyJWT’s PyJWKClient handles fetching and kid-based key selection.
# auth/jwks.py
import jwt # PyJWT
from functools import lru_cache
OIDC_ISSUER = "https://idp.example.com"
JWKS_URI = f"{OIDC_ISSUER}/.well-known/jwks.json"
API_AUDIENCE = "https://api.example.com" # this resource server's identifier
ALLOWED_ALGORITHMS = ["RS256"] # explicit allowlist; never "none"/"HS256"
@lru_cache(maxsize=1)
def jwks_client() -> jwt.PyJWKClient:
# PyJWKClient caches keys and refreshes when an unknown kid appears.
return jwt.PyJWKClient(JWKS_URI, cache_keys=True, lifespan=3600)
The Validation Dependency
A FastAPI dependency extracts the bearer token, verifies it, and enforces every claim. PyJWT rejects alg: none and refuses HS256 when handed an RSA public key, but we still pass an explicit algorithms allowlist — defense in depth against algorithm confusion.
# auth/dependencies.py
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from auth.jwks import jwks_client, OIDC_ISSUER, API_AUDIENCE, ALLOWED_ALGORITHMS
bearer_scheme = HTTPBearer(auto_error=False)
# 401 carries WWW-Authenticate per RFC 6750; 403 does not.
def _unauthenticated(detail: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=detail,
headers={"WWW-Authenticate": "Bearer"},
)
async def verify_token(
creds: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> dict:
if creds is None or creds.scheme.lower() != "bearer":
raise _unauthenticated("Missing bearer token")
token = creds.credentials
try:
signing_key = jwks_client().get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=ALLOWED_ALGORITHMS, # RS256 only — blocks alg:none and HS256
issuer=OIDC_ISSUER, # enforces iss
audience=API_AUDIENCE, # enforces aud
options={
"require": ["exp", "iss", "aud"],
"verify_exp": True,
"verify_iss": True,
"verify_aud": True,
"verify_signature": True,
},
leeway=15, # 15s clock skew tolerance
)
except jwt.ExpiredSignatureError:
raise _unauthenticated("Token expired")
except jwt.InvalidAudienceError:
raise _unauthenticated("Token audience mismatch")
except jwt.InvalidIssuerError:
raise _unauthenticated("Token issuer mismatch")
except jwt.PyJWTError:
raise _unauthenticated("Invalid token")
return claims
Every signature, issuer, audience, and expiry failure becomes a 401: the caller has not presented a valid authentication credential.
Enforcing Scopes: 401 vs 403
Authorization is a separate gate. A require_scopes factory produces a dependency that checks the token’s space-delimited scope claim. A token that is valid but lacks the scope yields 403 — the caller is authenticated, just not permitted.
# auth/scopes.py
from fastapi import Depends, HTTPException, status
from auth.dependencies import verify_token
def require_scopes(*required: str):
async def checker(claims: dict = Depends(verify_token)) -> dict:
granted = set(str(claims.get("scope", "")).split())
# Some IdPs use the "scp" array claim instead of a space-delimited string.
if not granted and isinstance(claims.get("scp"), list):
granted = set(claims["scp"])
missing = set(required) - granted
if missing:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required scope(s): {', '.join(sorted(missing))}",
)
return claims
return checker
Wiring It to Routes
# main.py
from fastapi import FastAPI, Depends
from auth.dependencies import verify_token
from auth.scopes import require_scopes
app = FastAPI()
@app.get("/me")
async def read_me(claims: dict = Depends(verify_token)):
# Any authenticated caller. 401 if the token is absent/invalid.
return {"sub": claims["sub"]}
@app.post("/invoices")
async def create_invoice(claims: dict = Depends(require_scopes("invoices:write"))):
# 401 if unauthenticated, 403 if the token lacks invoices:write.
return {"created_by": claims["sub"]}
Validation & Testing
Exercise each branch of the pipeline with curl and a quick test:
# No token → 401 with a WWW-Authenticate challenge.
curl -i https://api.example.com/me
# Expect: HTTP/1.1 401 Unauthorized ... WWW-Authenticate: Bearer
# Valid token, missing scope → 403.
curl -i -X POST https://api.example.com/invoices -H "Authorization: Bearer $READ_ONLY_JWT"
# Expect: HTTP/1.1 403 Forbidden
Add an explicit negative test that a token forged with alg: none or HS256 (signed with the public key) is rejected — this is the single most important assertion in the suite.
def test_alg_none_is_rejected(client):
forged = jwt.encode({"sub": "attacker", "aud": API_AUDIENCE}, key="", algorithm="none")
r = client.get("/me", headers={"Authorization": f"Bearer {forged}"})
assert r.status_code == 401
Security Implications
- Algorithm allowlist is mandatory. Pinning
algorithms=["RS256"](or["ES256"]) is what defeats the algorithm-confusion attack where a public key is abused as an HMAC secret. Never trust the JWT header’salg. - Audience binding stops token redirection. Without
audenforcement, a token minted for another service that shares your IdP would be accepted here. This is required by OAuth 2.0 (RFC 6749) and OpenID Connect. - 401 and 403 are not interchangeable. Conflating them leaks whether a credential is valid and confuses clients’ retry logic. Authentication failures are 401 (with
WWW-Authenticate, per RFC 6750); authorization failures are 403. - JWTs cannot be revoked mid-life. A stolen access token is valid until
exp. Keep access-token lifetimes short and pair with the issuer’s token revocation endpoint for refresh tokens.
Prevention & Monitoring
- Log
sub,iss,aud,kid, and outcome per request. Alert onkidvalues absent from the current JWKS — a sign of forged or stale tokens. - Alert on bursts of
alg-mismatch or audience-mismatch rejections; both indicate active probing. - Track JWKS fetch failures. If key rotation outpaces your cache and fetches fail, valid tokens start 401-ing — page on it.
Frequently Asked Questions
Should I use python-jose or PyJWT?
Both work; PyJWT with PyJWKClient is the leaner choice for pure JWT-via-JWKS validation and is actively maintained. python-jose offers broader JOSE support (JWE, more algorithms) if you need it. Whichever you pick, always pass an explicit algorithms=["RS256"] allowlist — python-jose has historically been more permissive about the header alg, so the explicit allowlist is not optional.
Why validate the signature locally instead of calling the introspection endpoint?
Local JWKS validation is stateless and fast — no network hop per request. Token introspection (RFC 7662) gives you real-time revocation status but adds latency and a hard dependency on the IdP for every call. Use local validation for self-contained JWT access tokens with short lifetimes; reach for introspection only when you have opaque tokens or need immediate revocation checks.
My tokens validate locally but the IdP rotated keys and now everything 401s.
Your JWKS cache is stale and missing the new kid. PyJWKClient refetches when it encounters an unknown kid, but if you cache the client or keys too aggressively it can miss rotation. Use a bounded lifespan on the client and ensure an unknown-kid path triggers a refresh rather than an immediate failure.
How do I require multiple scopes or any-of-N scopes?
The require_scopes("a", "b") factory shown above is all-of: it computes required - granted and 403s if anything is missing. For any-of semantics, change the check to if not (set(required) & granted). Keep both as small factory variants so route declarations stay readable and the 401-vs-403 boundary remains in one place.
Related
- Integrating OIDC With Web Frameworks — where the access tokens this API validates are issued.
- Configuring Identity Providers for OIDC — discovery, JWKS, and the issuer/audience values to enforce.
- OAuth 2.0 Token Revocation Best Practices — handling revocation that stateless JWT validation cannot.