Skip to content

Security & Auth

Every backend you’ve built has needed auth. In Express you bolted on Passport.js middleware; in Go you hand-rolled a func(http.Handler) http.Handler wrapper that parsed the Authorization header. FastAPI takes a third route: auth is just dependency injection. The same Depends() you used for a database session (module 09) is how you require a logged-in user, check a scope, or validate a token from an external identity provider.

That’s the big idea of this module. There’s no separate “security framework” to learn — OAuth2PasswordBearer, Security, and HTTPBearer are dependencies that extract a token, and get_current_user is a dependency that turns that token into a user. Routes declare what they need in their signature; FastAPI wires it and returns the right 401/403 automatically.

We’ll cover password hashing with argon2, JWTs with pyjwt (access + refresh), the dependency-based current-user pattern, OAuth2/OIDC against an external provider, RBAC via scopes, and the cross-cutting essentials — CORS, secrets via pydantic-settings, rate-limiting auth endpoints, timing-safe comparison, and the pitfalls TS/Go devs hit.

The mental model: middleware vs dependencies

Section titled “The mental model: middleware vs dependencies”

Express and Go assemble auth as per-route middleware you wire by hand. FastAPI expresses the same flow as a dependency graph — and because dependencies can depend on other dependencies, “extract token → decode JWT → load user → check scope” is just a chain.

// Express: middleware functions, wired per route
import jwt from "jsonwebtoken";
function authenticate(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) return res.status(401).end();
try {
req.user = jwt.verify(header.slice(7), process.env.JWT_SECRET!);
next();
} catch {
res.status(401).end();
}
}
app.get("/me", authenticate, (req, res) => res.json(req.user));

Key differences:

AspectExpressGoFastAPI
Auth modelmiddleware functionsmiddleware functionsdependencies (Depends/Security)
Wiringper route, imperativeper route, imperativedeclarative in the signature
Token extractionread header by handread header by handOAuth2PasswordBearer / HTTPBearer
”Current user”mutate req.usercontext.WithValuea dependency that returns the user
401 vs 403you write res.status(...)you write http.Error(...)raise HTTPException; FastAPI maps it
Composabilitycall middleware inside middlewarewrap handlersdependencies depend on dependencies
OpenAPI integrationmanualmanualautomatic (security schemes in /docs)

The payoff: the security scheme shows up in the auto-generated OpenAPI docs, the Authorize button appears in Swagger UI, and a route’s auth requirements are visible in its signature instead of buried in a middleware stack.

Password hashing: argon2, not bcrypt-by-default

Section titled “Password hashing: argon2, not bcrypt-by-default”

Rule zero: never store passwords reversibly, and never hash them with a general-purpose digest. If you reach for hashlib.md5 or hashlib.sha256 on a password, stop — those are fast by design, which is exactly what an attacker with your database dump wants. You need a slow, salted, memory-hard function.

In 2026 the default is argon2id (the password-hashing competition winner) via argon2-cffi. You’ll still see bcrypt in older Python code (often through passlib); it’s fine, but passlib is effectively unmaintained, and argon2-cffi gives you a cleaner API and a stronger algorithm. Use argon2.

import argon2 from "argon2";
const hash = await argon2.hash("correct horse battery staple");
const ok = await argon2.verify(hash, "correct horse battery staple");

The argon2 hash string is self-describing: it embeds the algorithm, version, and parameters alongside the salt and digest. That’s what makes seamless upgrades possible — when you raise the cost factor next year, you don’t need a migration. Check on each successful login whether the stored hash used weaker parameters and silently re-hash:

def verify_and_maybe_rehash(user: User, raw: str) -> bool:
try:
ph.verify(user.password_hash, raw)
except VerifyMismatchError:
return False
# Same password, but the stored hash used old/weak parameters? Upgrade it.
if ph.check_needs_rehash(user.password_hash):
user.password_hash = ph.hash(raw) # persist this
return True

A JWT is a signed, base64url-encoded JSON blob: header.payload.signature. The server signs it on login; the client sends it back; the server verifies the signature and reads the claims. Signed, not encrypted — anyone can read the payload, so never put secrets in it. The library is pyjwt (the alternative, joserfc, is worth knowing if you need full JOSE/JWE; for plain JWT, pyjwt is the standard).

import jwt from "jsonwebtoken";
const token = jwt.sign({ sub: userId, scopes }, SECRET, {
algorithm: "HS256",
expiresIn: "15m",
});
const claims = jwt.verify(token, SECRET); // throws on bad sig / expiry

jwt.decode raises specific exceptions you’ll want to catch and map to clean errors: ExpiredSignatureError, InvalidAudienceError, InvalidIssuerError, and the catch-all InvalidTokenError.

ClaimMeaningWhy you care
subsubject — the user idwho the token is about
expexpiry (unix seconds)short-lived tokens limit blast radius
iatissued-ataudit / “issued before password change” checks
ississuerreject tokens minted elsewhere
audaudiencereject tokens meant for a different service
jtiunique token idthe hook for revocation lists
scopes / rolescustomRBAC (see below)
HS256 (symmetric)RS256 (asymmetric)
Keyone shared secretprivate key signs, public key verifies
Who can mintanyone with the secretonly the holder of the private key
Best fora single service signing its own tokensan IdP minting tokens many services verify
Verifier needsthe secret (a liability if leaked)only the public key (safe to distribute)
Key distributionout of bandpublished as a JWKS endpoint

Rule of thumb: HS256 when one service both issues and verifies (the JWT Auth API sub-project). RS256 (or ES256) when an identity provider issues tokens that other services verify — which is exactly the OAuth2/OIDC case below.

A single long-lived token is a liability: if it leaks, the attacker has access until it expires, and you can’t easily revoke it. The standard pattern splits responsibilities:

  • Access token — short-lived (5–15 min), sent on every request, holds the claims.
  • Refresh token — long-lived (days/weeks), sent only to a /refresh endpoint, exchanged for a fresh access token.
def issue_tokens(user: User) -> dict[str, str]:
now = datetime.now(UTC)
access = jwt.encode(
{"sub": user.id, "scopes": user.scopes, "type": "access",
"iat": now, "exp": now + timedelta(minutes=15)},
SECRET, algorithm="HS256",
)
refresh = jwt.encode(
{"sub": user.id, "type": "refresh", "jti": str(uuid4()),
"iat": now, "exp": now + timedelta(days=7)},
SECRET, algorithm="HS256",
)
return {"access_token": access, "refresh_token": refresh, "token_type": "bearer"}
@app.post("/auth/refresh")
async def refresh(body: RefreshRequest, users: UserRepoDep) -> TokenPair:
try:
claims = decode_token(body.refresh_token)
except jwt.InvalidTokenError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid refresh token")
if claims.get("type") != "refresh": # don't accept an access token here
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "wrong token type")
user = await users.get(claims["sub"])
return issue_tokens(user)

FastAPI security: dependencies, not middleware

Section titled “FastAPI security: dependencies, not middleware”

Extracting the token: OAuth2PasswordBearer

Section titled “Extracting the token: OAuth2PasswordBearer”

OAuth2PasswordBearer is a dependency that pulls the Bearer token out of the Authorization header (and tells OpenAPI which URL issues tokens, so Swagger UI’s Authorize button works). It returns the raw token string — you decode it yourself.

app/security.py
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
import jwt
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
users: UserRepoDep,
) -> User:
credentials_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
claims = decode_token(token)
except jwt.InvalidTokenError:
raise credentials_exc
if claims.get("type") != "access":
raise credentials_exc
user = await users.get(claims["sub"])
if user is None or user.disabled:
raise credentials_exc
return user
# Reusable type alias — annotate any route param with this to require a user.
CurrentUser = Annotated[User, Depends(get_current_user)]

Now any route that wants the logged-in user just asks for one:

@router.get("/me")
async def read_me(user: CurrentUser) -> UserResponse:
return UserResponse.model_validate(user)

The dependency chain — oauth2_schemeget_current_userUserRepoDep — resolves top-down before the route body runs. If any link raises HTTPException, the body never executes and the client gets the status you raised.

UtilityUse it when
OAuth2PasswordBeareryou issue tokens from a /login endpoint (password flow); want the Swagger Authorize flow
HTTPBeareryou just want “read a Bearer token”, no OAuth2 password-flow UI
Depends(...)wraps any dependency, including your get_current_user
Security(...)like Depends, plus it declares OAuth2 scopes for a route (RBAC)

HTTPBearer is the leaner option when tokens come from elsewhere (e.g. an external IdP) and you don’t want the password-flow UI:

from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
bearer = HTTPBearer()
async def current_user_from_bearer(
creds: Annotated[HTTPAuthorizationCredentials, Depends(bearer)],
) -> User:
token = creds.credentials # the part after "Bearer "
...

This trips up TS/Go devs because the names are misleading:

  • 401 Unauthorized actually means unauthenticated — “I don’t know who you are.” Missing/expired/invalid token. Include a WWW-Authenticate: Bearer header.
  • 403 Forbidden means authenticated but not allowed — “I know who you are, you just can’t do this.” A logged-in user hitting an admin route.

FastAPI’s security utilities raise 401 when the token is missing; your scope checks raise 403. Don’t return 403 for a missing token (leaks endpoint existence to unauthenticated clients) or 401 for a permission failure (tells the client to re-authenticate, which won’t help).

OAuth2 / OIDC: verifying an external provider’s tokens

Section titled “OAuth2 / OIDC: verifying an external provider’s tokens”

So far your service mints its own HS256 tokens. The other big pattern: an external identity provider (Keycloak, Auth0, Entra ID, Google) handles login via the authorization-code flow, and your API is a resource server that only verifies the tokens the IdP issued. You never see the password; you verify an RS256 JWT against the provider’s public keys.

OAuth2 / OIDC authorization-code flow
Rendering diagram…

The resource-server side: verify with JWKS

Section titled “The resource-server side: verify with JWKS”

The IdP publishes its public keys at a JWKS (JSON Web Key Set) URL, discoverable from /.well-known/openid-configuration. pyjwt has a PyJWKClient that fetches and caches them, picks the right key by the token’s kid header, and hands you a key to verify with:

app/oidc.py
import jwt
from jwt import PyJWKClient
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
OIDC_ISSUER = settings.oidc_issuer # e.g. https://keycloak.example.com/realms/app
OIDC_AUDIENCE = settings.oidc_audience # your client/audience id
# PyJWKClient caches keys in-process and refreshes on cache miss.
_jwks = PyJWKClient(f"{OIDC_ISSUER}/protocol/openid-connect/certs")
bearer = HTTPBearer()
async def verify_oidc_token(
creds: Annotated[HTTPAuthorizationCredentials, Depends(bearer)],
) -> dict:
token = creds.credentials
try:
signing_key = _jwks.get_signing_key_from_jwt(token) # picks key by `kid`
return jwt.decode(
token,
signing_key.key,
algorithms=["RS256"], # IdPs sign asymmetrically
audience=OIDC_AUDIENCE,
issuer=OIDC_ISSUER,
)
except jwt.InvalidTokenError as exc:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(exc))

You verify entirely offline after the first JWKS fetch — no callback to the IdP per request. That’s the whole point of asymmetric tokens.

If your own service initiates the login (a server-rendered app, not a SPA), authlib handles the authorization-code dance — redirect, callback, token exchange, PKCE:

app/auth_client.py
from authlib.integrations.starlette_client import OAuth
oauth = OAuth()
oauth.register(
name="keycloak",
server_metadata_url=f"{OIDC_ISSUER}/.well-known/openid-configuration",
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
client_kwargs={"scope": "openid profile email"},
)
@app.get("/login")
async def login(request: Request):
redirect_uri = request.url_for("callback")
return await oauth.keycloak.authorize_redirect(request, redirect_uri)
@app.get("/callback")
async def callback(request: Request):
token = await oauth.keycloak.authorize_access_token(request) # does the /token exchange
userinfo = token["userinfo"] # parsed id_token claims
... # create your own session or app token

Authentication answers “who are you”; authorization answers “what may you do.” The mental model: a user has a set of scopes (or roles — same idea, finer vs coarser grained), a token carries them, and a dependency checks the route’s required scopes against the token’s.

FastAPI builds this in via Security(...) + SecurityScopes. The scopes you declare on a route show up in OpenAPI, and the framework collects the union of required scopes down the dependency tree.

app/security.py
from fastapi import Depends, HTTPException, Security, status
from fastapi.security import OAuth2PasswordBearer, SecurityScopes
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="auth/login",
scopes={
"tasks:read": "Read tasks",
"tasks:write": "Create and edit tasks",
"admin": "Administer the system",
},
)
async def get_current_user(
security_scopes: SecurityScopes,
token: Annotated[str, Depends(oauth2_scheme)],
users: UserRepoDep,
) -> User:
authenticate_value = (
f'Bearer scope="{security_scopes.scope_str}"'
if security_scopes.scopes else "Bearer"
)
try:
claims = decode_token(token)
except jwt.InvalidTokenError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid token",
headers={"WWW-Authenticate": authenticate_value})
token_scopes = set(claims.get("scopes", []))
user = await users.get(claims["sub"])
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "unknown user")
# 403, not 401: the user IS authenticated, just lacks the scope.
for required in security_scopes.scopes:
if required not in token_scopes:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail=f"missing required scope: {required}",
headers={"WWW-Authenticate": authenticate_value},
)
return user

Declare requirements per route with Security(...) and its scopes=:

# Any authenticated user — no scopes required:
CurrentUser = Annotated[User, Depends(get_current_user)]
@router.get("/me")
async def me(user: CurrentUser) -> UserResponse: ...
# Requires the "tasks:write" scope:
@router.post("/tasks")
async def create_task(
body: TaskCreate,
user: Annotated[User, Security(get_current_user, scopes=["tasks:write"])],
) -> Task: ...
# Requires "admin":
@router.delete("/admin/users/{user_id}")
async def delete_user(
user_id: str,
admin: Annotated[User, Security(get_current_user, scopes=["admin"])],
) -> None: ...

Compare the per-route role guard across stacks — same intent, different ergonomics:

// Express: a middleware factory you wire per route
const requireScope = (scope: string) =>
(req: Request, res: Response, next: NextFunction) =>
req.user.scopes.includes(scope) ? next() : res.status(403).end();
app.post("/tasks", authenticate, requireScope("tasks:write"), createTask);

A browser will not let JS on https://app.example.com read a response from https://api.example.com unless the API opts in with CORS headers. CORS is a browser policy — it does nothing for server-to-server or curl — and it is not authentication. Configure it explicitly; never reflect * with credentials.

import cors from "cors";
app.use(cors({
origin: ["https://app.example.com"],
methods: ["GET", "POST", "PUT", "DELETE"],
credentials: true,
}));

Secrets via pydantic-settings — never hardcode

Section titled “Secrets via pydantic-settings — never hardcode”

The JWT secret, OIDC client secret, and DB password all come from the environment via pydantic-settings (module 04). Hardcoding a secret or committing it to git is the single most common real-world leak.

app/config.py
from functools import lru_cache
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_")
jwt_secret: SecretStr # SecretStr keeps it out of repr/logs
jwt_algorithm: str = "HS256"
access_token_ttl_minutes: int = 15
refresh_token_ttl_days: int = 7
cors_origins: list[str] = ["http://localhost:3000"]
oidc_issuer: str | None = None
@lru_cache
def get_settings() -> Settings:
return Settings() # raises at startup if a required secret is missing — fail fast

SecretStr is the key move: str(settings.jwt_secret) is '**********', so it can’t leak into a log line or stack trace by accident. Call .get_secret_value() only at the point you actually sign a token.

Login and refresh are credential-stuffing and brute-force targets. Rate-limit them with the Redis sliding-window dependency from module 10 — keyed by IP (and ideally by submitted username) so one attacker can’t grind through passwords:

@router.post(
"/login",
dependencies=[Depends(rate_limit(limit=5, window=60))], # 5/min from module 10
)
async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]) -> TokenPair:
...

Validation isn’t just UX — it’s your first line of defense, and in FastAPI it’s free. A Pydantic model on the request body (module 04) rejects malformed input with a 422 before your handler runs, shrinking the attack surface (oversized payloads, unexpected types, injection vectors in unvalidated fields).

Never build SQL by string-formatting user input. SQLAlchemy and asyncpg (module 09) parameterize for you when you pass bind parameters — the danger is only when you defeat them with f-strings:

# NEVER — f-string interpolation is an injection hole:
await conn.execute(f"SELECT * FROM users WHERE email = '{email}'")
# Always — bound parameters; the driver escapes/separates data from SQL:
await conn.execute(text("SELECT * FROM users WHERE email = :email"), {"email": email})
# asyncpg: await conn.fetch("SELECT * FROM users WHERE email = $1", email)

Tokens, passwords, and secrets must never reach your logs. With structlog (module 15), add a processor that redacts known sensitive keys, and rely on SecretStr so config values self-redact. Audit what your exception handlers serialize — a 500 that echoes the request body can dump a password into your log aggregator.

Authentication
[ ] Passwords hashed with argon2id (argon2-cffi), never md5/sha
[ ] check_needs_rehash on login to upgrade old hashes
[ ] Hashing offloaded from the event loop (def route or asyncio.to_thread)
[ ] Access tokens short-lived (5-15 min); refresh tokens separate + rotated
[ ] jwt.decode pins algorithms=[...]; iss/aud/exp validated
[ ] Login/refresh return 401 indistinguishably (don't leak which emails exist)
Authorization
[ ] 401 for unauthenticated, 403 for authenticated-but-forbidden
[ ] Scopes declared with Security(...); ownership checked in the handler
[ ] Default-deny: protected routes require a user explicitly
Transport & headers
[ ] HTTPS enforced in prod
[ ] CORS lists explicit origins (never * with credentials)
Secrets & input
[ ] Secrets via pydantic-settings + SecretStr; never hardcoded or logged
[ ] SQL uses bound parameters, never f-strings
[ ] Request bodies validated by Pydantic
[ ] Auth endpoints rate-limited
[ ] hmac.compare_digest for any manual secret comparison
ConcernExpress / NodeGoFastAPI (Python)
Password hashargon2argon2idargon2-cffi
JWTjsonwebtokengolang-jwtpyjwt (or joserfc)
Token extractionread headerread headerOAuth2PasswordBearer / HTTPBearer
Current userreq.usercontext.ValueDepends(get_current_user)
RBACmiddleware factorymiddleware factorySecurity(..., scopes=[...])
OIDC verifyexpress-oauth2-jwt-bearercoreos/go-oidcpyjwt + PyJWKClient
OAuth2 clientpassport-oauth2golang.org/x/oauth2authlib
CORScors packagemanual headersCORSMiddleware
Config / secretsdotenvenvconfigpydantic-settings + SecretStr

Key takeaways:

  • Auth in FastAPI is dependency injection, not middleware — get_current_user is a dependency, scopes ride on Security(...), and /docs documents it all for free.
  • Hash with argon2id; never md5/sha for passwords; offload hashing off the loop.
  • Pin algorithms on every jwt.decode. HS256 to sign your own tokens, RS256 + JWKS to verify an IdP’s.
  • Be honest about JWTs: stateless means unrevocable. Short access tokens + refresh rotation, or sessions for first-party web apps.
  • 401 = unauthenticated, 403 = forbidden. Get the codes right.

Put it together: a FastAPI auth service with argon2 hashing, access + refresh tokens, a get_current_user dependency, and an RBAC-protected admin route — all on uv.