Authentication: OAuth2 password flow and JWT

FastAPI · Practical · 20 min

Almost every API needs to know who is calling, and this is FastAPI's documented pattern, integrated with /docs.

FastAPI's security utilities are dependencies that also describe themselves in OpenAPI, so /docs gets an Authorize button. The common setup is the OAuth2 password flow with JWT bearer tokens:

  • The client POSTs form fields username and password to /token; OAuth2PasswordRequestForm parses them (form parsing needs python-multipart, included in fastapi[standard]).
  • You verify the password against a stored hash, never plain text. FastAPI's docs use pwdlib with Argon2 via PasswordHash.recommended().
  • You return {"access_token": token, "token_type": "bearer"}, where the token is a JWT signed with a server secret using PyJWT.
  • Later requests send Authorization: Bearer <token>. OAuth2PasswordBearer(tokenUrl="token") is a dependency that extracts the token, or answers 401 if it's missing.
  • Your current_user dependency decodes it (jwt.decode verifies the signature and the exp claim), loads the user, and raises 401 with WWW-Authenticate: Bearer on any failure.

Endpoints then just declare user: Annotated[User, Depends(current_user)]. Layer extra checks as further dependencies: one that rejects disabled accounts, another that requires an admin role.

A JWT is signed, not encrypted: anyone holding it can read the claims, so put only identifiers in it. Keep access tokens short-lived (15 to 60 minutes), keep the secret in settings, and always pass algorithms=[...] to decode so the token can't choose its own algorithm. Serve it only over HTTPS. If users sign in through Google or a corporate identity provider, you validate that provider's tokens instead, but the dependency shape stays the same.

Example

from datetime import datetime, timedelta, timezone
from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pwdlib import PasswordHash

SECRET_KEY = "change-me: load a 32+ byte random secret from settings"
ALGORITHM = "HS256"
hasher = PasswordHash.recommended()
users = {"ann": hasher.hash("s3cret")}        # username -> password hash
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI()

@app.post("/token")
def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
    hashed = users.get(form.username)
    if not hashed or not hasher.verify(form.password, hashed):
        raise HTTPException(status_code=401, detail="Incorrect username or password")
    exp = datetime.now(timezone.utc) + timedelta(minutes=30)
    token = jwt.encode({"sub": form.username, "exp": exp}, SECRET_KEY, algorithm=ALGORITHM)
    return {"access_token": token, "token_type": "bearer"}

def current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> str:
    try:
        return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])["sub"]
    except jwt.InvalidTokenError:
        raise HTTPException(401, "Invalid token", headers={"WWW-Authenticate": "Bearer"})

@app.get("/me")
def me(user: Annotated[str, Depends(current_user)]):
    return {"user": user}

Remember

  • /token checks a password hash and returns a signed, expiring JWT
  • OAuth2PasswordBearer pulls the bearer token from the Authorization header
  • A current_user dependency verifies the token; endpoints just depend on it
  • JWTs are readable by anyone: sign, expire and keep secrets out of claims

Watch out for

  • Calling jwt.decode without algorithms=[...], or with verification options turned off.
  • Storing plain or fast-hashed (MD5, SHA-256) passwords instead of Argon2 or bcrypt.

Check yourself

  1. Why must jwt.decode be given algorithms=['HS256']?
  2. A request to a route protected by OAuth2PasswordBearer has no Authorization header. What happens?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: fastapi.tiangolo.com/tutorial/security/oauth2-jwt/

Builds on

Unlocks

Nothing builds on this yet.