Safeguard
Regulatory Compliance

Common OAuth2 and JWT implementation mistakes in FastAPI ...

A practical walkthrough of the FastAPI JWT security mistakes that lead to broken authentication, plus concrete fixes for OAuth2 flows and token validation.

Aman Khan
AppSec Engineer
8 min read

Authentication bugs are among the most common—and most dangerous—vulnerabilities we find when auditing FastAPI services during software supply chain security reviews. FastAPI's dependency injection system makes OAuth2 and JWT wiring look deceptively simple: a few lines of code and you have a working /token endpoint. But that simplicity hides a long list of fastapi jwt security mistakes that slip past code review and even penetration tests, because the API behaves correctly for legitimate users while silently accepting forged, expired, or improperly scoped tokens. This guide walks through the mistakes we see most often in production FastAPI codebases — missing signature verification, algorithm confusion, unbounded token lifetimes, weak secret management, and broken scope enforcement — and shows the concrete configuration and code changes needed to close each one. By the end, you'll have a hardened OAuth2 FastAPI implementation and a checklist you can run against your own authentication layer.

1. Catalog the FastAPI JWT Security Mistakes Already in Your Auth Flow

Before changing code, inventory what you actually have. Pull every place your service issues, decodes, or refreshes a token and check it against this list of the most frequent offenders we see in audits:

  • Tokens signed with a hardcoded or weak secret checked into source control
  • jwt.decode() called without an explicit algorithms allowlist
  • No exp, iat, aud, or iss claim validation
  • Refresh tokens with no rotation or revocation path
  • Scopes defined but never enforced on protected routes
  • Password grant flow used for third-party/browser clients instead of authorization code + PKCE

Grep your codebase for jwt.decode, jwt.encode, OAuth2PasswordBearer, and SECRET_KEY to build this inventory quickly:

grep -rn "jwt.decode\|jwt.encode\|SECRET_KEY\|OAuth2PasswordBearer" ./app

Most teams find at least three of the six issues above on the first pass. Treat this list as your remediation backlog for the rest of this guide.

2. Fix Your OAuth2 FastAPI Implementation at the Token Endpoint

A correct oauth2 fastapi implementation starts with how you issue tokens, not just how you validate them. A common mistake is returning a token before verifying the password hash correctly, or using OAuth2PasswordBearer without ever calling Security() with scopes.

from datetime import datetime, timedelta, timezone
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from passlib.context import CryptContext
import jwt

app = FastAPI()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", scopes={"read": "Read access", "write": "Write access"})

SECRET_KEY = settings.jwt_secret  # loaded from a secrets manager, never hardcoded
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15

@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
    user = authenticate_user(form_data.username, form_data.password)
    if not user or not pwd_context.verify(form_data.password, user.hashed_password):
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Incorrect credentials")
    expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    payload = {"sub": user.username, "scopes": form_data.scopes, "exp": expire, "iss": "safeguard-api"}
    token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
    return {"access_token": token, "token_type": "bearer"}

Note two details teams routinely skip: verifying the password against the stored hash (not just checking the user exists), and setting an iss claim so downstream validation can confirm the token actually came from your issuer.

3. Eliminate JWT Validation Flaws in Your Decode Logic

This is where most exploitable jwt validation flaws live. A decode() call that doesn't pin the algorithm, audience, and issuer will accept tokens an attacker crafted, not just ones your server issued.

from jwt import PyJWTError, ExpiredSignatureError, InvalidAudienceError

def decode_access_token(token: str) -> dict:
    try:
        payload = jwt.decode(
            token,
            SECRET_KEY,
            algorithms=["HS256"],       # explicit allowlist, never derived from the token header
            audience="safeguard-api",
            issuer="safeguard-api",
            options={"require": ["exp", "iat", "sub"]},
        )
    except ExpiredSignatureError:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Token expired")
    except InvalidAudienceError:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid audience")
    except PyJWTError:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
    return payload

The algorithms parameter must always be a hardcoded list your server controls. Never read the algorithm from the incoming token's header and feed it back into decode() — that single line is responsible for a large share of the fastapi jwt security mistakes we see reported in bug bounty writeups.

4. Lock Down Signing Keys and Block Algorithm Confusion

Algorithm confusion attacks happen when a service configured for asymmetric signing (RS256) can also be tricked into accepting HS256 tokens signed with the public key treated as a shared secret. If you use RS256, verify with the public key only and never allow the library to fall back to symmetric algorithms.

PUBLIC_KEY = open("keys/jwt_public.pem").read()

payload = jwt.decode(
    token,
    PUBLIC_KEY,
    algorithms=["RS256"],   # excludes HS256 entirely
    audience="safeguard-api",
)

Store signing secrets and private keys in a secrets manager (AWS Secrets Manager, HashiCorp Vault, or your cloud provider's KMS), rotate them on a schedule, and confirm .env files with keys are excluded from version control:

git log --all --full-history -- "*.env" "*secret*"

If that command returns hits, treat every key that ever touched those files as compromised and rotate it.

5. Enforce Short-Lived Tokens and Real Refresh Rotation

Long-lived access tokens turn a single leaked token into a long-lived compromise. Keep access tokens short (5–15 minutes) and give refresh tokens rotation plus revocation, backed by a store like Redis keyed on the token's jti:

import uuid

def create_refresh_token(username: str) -> str:
    jti = str(uuid.uuid4())
    expire = datetime.now(timezone.utc) + timedelta(days=7)
    redis_client.setex(f"refresh:{jti}", timedelta(days=7), username)
    return jwt.encode({"sub": username, "jti": jti, "exp": expire, "type": "refresh"}, SECRET_KEY, algorithm=ALGORITHM)

@app.post("/refresh")
async def refresh(token: str):
    payload = decode_access_token(token)
    stored_user = redis_client.get(f"refresh:{payload['jti']}")
    if not stored_user:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Refresh token revoked or expired")
    redis_client.delete(f"refresh:{payload['jti']}")  # rotate: old refresh token is now dead
    return {"access_token": create_access_token(payload["sub"])}

Without this rotation step, a stolen refresh token remains valid for its entire lifetime even after the user changes their password — one of the more damaging fastapi jwt security mistakes because it's invisible until an incident forces a review.

6. Enforce Scopes and Dependency-Based Authorization

Defining scopes at the token endpoint means nothing if route handlers never check them. Use Security() with a scope list on every protected dependency, not just Depends():

from fastapi import Security
from fastapi.security import SecurityScopes

async def get_current_user(security_scopes: SecurityScopes, token: str = Depends(oauth2_scheme)):
    payload = decode_access_token(token)
    token_scopes = payload.get("scopes", [])
    for scope in security_scopes.scopes:
        if scope not in token_scopes:
            raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
    return payload["sub"]

@app.get("/admin/reports")
async def get_reports(user: str = Security(get_current_user, scopes=["write"])):
    return {"reports": [...]}

This closes the gap where any authenticated user — regardless of granted scope — could reach admin routes simply because the dependency only checked for a valid signature.

Troubleshooting and Verification

Once fixes are in place, verify them the way an attacker would, not just with happy-path tests.

Test the algorithm allowlist:

python -c "import jwt; print(jwt.encode({'sub':'attacker'}, '', algorithm='none'))"

Send that token to a protected route. A hardened endpoint must reject it — if it's accepted, your decode() call is still trusting the token's header.

Test expiry enforcement:

curl -H "Authorization: Bearer $EXPIRED_TOKEN" https://api.example.com/protected

Expect a 401, not a 200 with stale claims.

Test audience/issuer confusion: mint a token with aud set to a different service and confirm it's rejected rather than silently trusted because only the signature was checked.

Test scope escalation: authenticate with a read-only scope and call a write-scoped route; expect 403.

Common failure modes during remediation:

  • InvalidSignatureError after rotating keys usually means old tokens are still in circulation — expected during a rollout window, but confirm your rotation has a grace period, not an indefinite one.
  • Clock skew between services causes false ExpiredSignatureErrors; add leeway=30 to jwt.decode() if your infrastructure isn't NTP-synced.
  • If refresh rotation breaks legitimate clients, check that you're not deleting the Redis key before issuing the new token — order matters.

How Safeguard Helps

Fixing fastapi jwt security mistakes one code review at a time doesn't scale, and these bugs tend to reappear as new services and contributors are added. Safeguard closes that gap across your software supply chain rather than one repo at a time. Our SAST and dependency scanning pipeline flags risky patterns automatically — unpinned algorithms parameters in jwt.decode(), hardcoded secrets destined for a commit, and outdated python-jose or PyJWT versions carrying known CVEs — before they merge to your default branch. Secret scanning catches JWT signing keys and OAuth2 client secrets in code, config files, and CI logs, and policy-as-code gates in your pipeline can block a pull request that introduces a alg: none acceptance path or removes audience validation. For teams under SOC 2 or similar compliance obligations, Safeguard also generates the evidence trail — showing that authentication code changes were scanned, reviewed, and gated — that auditors expect to see tied to your change management process. Combined with the fixes above, this turns FastAPI authentication security from a one-time hardening exercise into a continuously enforced standard across every service your teams ship.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.