What happens when FastAPI's OAuth2PasswordBearer receives an expired JWT that still satisfies required scopes?
0 reputation · 25 Jun 2025, 09:59 UTC
0 reputation · 25 Jun 2025, 09:59 UTC
When protecting an endpoint with FastAPI's OAuth2PasswordBearer dependency, developers often rely on the token's scope claim to enforce least‑privilege access while also expecting the library to reject expired credentials via the exp claim. The exact point at which FastAPI validates scopes relative to expiration is not documented, leaving uncertainty about whether a token that satisfies all required scopes but carries an expired exp will trigger a scope‑based failure, an expiration‑based failure, or a combined response.
This ambiguity affects debugging and the design of custom error handling, especially when distinguishing between a merely expired token and one that lacks sufficient privileges.
What is the order of validation performed by OAuth2PasswordBearer for scope and expiration checks? Does a token with valid scopes but an expired exp produce a 401 Unauthorized, a 403 Forbidden, or a different status? Can the validation sequence be altered or extended without replacing the dependency?
31775 reputation · 25 Jun 2025, 17:08 UTC
OAuth2PasswordBearer performs no JWT validation at all — not expiration, not scopes, not even signature verification. It only extracts the Bearer token from the Authorization header and returns 401 if the header is missing or not a Bearer token. So the question of "which check runs first, scope or expiration?" is answered entirely by your dependency code, not by FastAPI.
In the conventional setup (FastAPI's documented security pattern), an expired token with valid scopes produces a 401 Unauthorized, because the JWT decode step — which raises on an expired exp — runs before the scope comparison ever executes.
OAuth2PasswordBearer(tokenUrl=...) reads the header. Failure here: 401.get_current_user-style dependency calls jwt.decode(...) (PyJWT or python-jose). An expired exp raises an expired-signature error, which the standard examples catch and convert to HTTPException(status_code=401) with WWW-Authenticate: Bearer.Security(..., scopes=["items:read"]), FastAPI passes SecurityScopes into your dependency. Your code compares the required scopes against the token's scope (space-delimited string) or scp (list) claim and raises 403 — often with WWW-Authenticate: Bearer scope="..." — when scopes are insufficient.Because step 2 raises before step 3 is reached, the expired-but-well-scoped token never gets a chance to produce a 403. The 403 path is reserved for tokens that are valid (unexpired, correctly signed) but under-privileged.
Confirmed: FastAPI does not automatically reject expired JWTs, and OAuth2PasswordBearer does not order scope-vs-expiry checks — it does neither check.
Likely explanation for observed behavior: any 401-vs-403 difference you see comes from your own decode/scope logic or a third-party auth library. If you (or a library) checked scopes before decoding, you could return 403 for an expired token — which is exactly why decode-first is the recommended order. Returning 403 for expired credentials confuses clients and refresh-token flows; the conventional mapping is 401 for invalid/expired credentials, 403 for insufficient scope.
Keep OAuth2PasswordBearer as the scheme and wrap it in your own dependency:
from fastapi import Depends, HTTPException, Security, status
from fastapi.security import OAuth2PasswordBearer, SecurityScopes
import jwt
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", scopes={"items:read": "Read items"})
async def get_current_user(
security_scopes: SecurityScopes,
token: str = Depends(oauth2_scheme),
):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM], leeway=10)
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired",
headers={"WWW-Authenticate": "Bearer"})
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"})
token_scopes = payload.get("scope", "").split()
missing = [s for s in security_scopes.scopes if s not in token_scopes]
if missing:
raise HTTPException(
status_code=403,
detail="Insufficient scope",
headers={"WWW-Authenticate": f'Bearer scope="{" ".join(security_scopes.scopes)}"'},
)
return payload["sub"]This gives you distinct, debuggable errors for expiry vs. privilege, and a small leeway absorbs clock skew between issuer and server.
exp in the past; call the endpoint and confirm you get 401, not 403 or 200.scope= attribute in WWW-Authenticate; expiry usually does not.scp (list) as scope (string), or vice versa, causes false 403s or false accepts.One caveat: if a managed auth library (rather than hand-written decode code) sits between the scheme and your endpoint, its internal check order may differ — inspect or log which branch raises before assuming the 401-first behavior described here.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.