Security and authentication
The general material — how JWTs fail, what CORS actually protects, password hashing — lives in Security and Authentication. This is the part that is specifically FastAPI: how the security primitives plug into dependency injection, and what that buys.
Security schemes are dependencies
oauth2 = OAuth2PasswordBearer(tokenUrl="/auth/token")
async def current_user(
token: Annotated[str, Depends(oauth2)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> User:
try:
claims = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"],
audience=AUDIENCE, issuer=ISSUER)
except JWTError:
raise HTTPException(401, headers={"WWW-Authenticate": "Bearer"})
user = await session.get(User, claims["sub"])
if user is None or not user.active:
raise HTTPException(401, headers={"WWW-Authenticate": "Bearer"})
return userOAuth2PasswordBearer does two jobs: it extracts the bearer token, and it
declares the scheme in the OpenAPI schema, which is what makes the
Authorize button work in /docs. Reading the header yourself works and loses
the second half.
The explicit algorithms=, audience= and issuer= are not optional — the
reasons are in JWT pitfalls.
Scopes are first-class
FastAPI models OAuth2 scopes directly, and the payoff is that required scopes appear per endpoint in the schema:
oauth2 = OAuth2PasswordBearer(
tokenUrl="/auth/token",
scopes={"orders:read": "Read orders", "orders:write": "Change orders"},
)
async def require(security_scopes: SecurityScopes, token=Depends(oauth2)) -> User:
claims = decode(token)
held = set(claims.get("scope", "").split())
missing = set(security_scopes.scopes) - held
if missing:
raise HTTPException(
403,
detail=f"missing scope: {' '.join(sorted(missing))}",
headers={"WWW-Authenticate": f'Bearer scope="{security_scopes.scope_str}"'},
)
return await load(claims["sub"])
@app.delete("/orders/{id}")
async def delete(user: Annotated[User, Security(require, scopes=["orders:write"])]):
...Security rather than Depends is what carries the scope list into
SecurityScopes and into the schema. That is the distinction the question
usually turns on.
Securing a whole area
admin = APIRouter(prefix="/admin", dependencies=[Depends(require_admin)])Secure by default for the group, not by remembering per endpoint. A dependency listed on the router runs for every route on it and its return value is discarded — it exists to raise. Repeating the check in forty signatures is how one endpoint ends up unauthenticated.
The traps that are FastAPI’s own
A 401 without WWW-Authenticate. The spec requires it, and clients use it
to decide how to re-authenticate. FastAPI will not add it for you.
include_in_schema=False is not security. It hides the route from /docs;
the route still answers. Undocumented is not private.
Docs in production. Setting docs_url=None while leaving openapi_url on
hides the page and still serves the schema — see
OpenAPI and the generated docs.
Gotcha: returning the user object straight from a login endpoint leaks whatever the ORM model carries —
hashed_password, internal flags, other users’ ids on relationships. A separateresponse_modelis the fix, and response_model — Advanced Patterns is why it is a guarantee rather than a convention.
Rate limiting
FastAPI ships none. slowapi wraps limits and is the usual answer for a
single instance; anything multi-instance needs shared state, which means Redis
and the sliding-window script in
Redis from Python.
The senior point: rate limiting at the application is a last line, not the
first. It runs after TLS termination, routing and framework overhead. The
cheap place is the edge — nginx limit_req or the CDN — and the application
limit exists for what the edge cannot see, like per-tenant quotas.
File uploads
async def upload(file: Annotated[UploadFile, File()]) -> dict:
if file.content_type not in ALLOWED:
raise HTTPException(415)
# UploadFile spools to disk past a threshold; bytes would not.
head = await file.read(2048)
if not sniff_ok(head, file.content_type):
raise HTTPException(415, "content does not match its type")Use UploadFile, not bytes — bytes loads the whole upload into memory, so
a large file is a denial of service. And never trust content_type or the
filename: both are attacker-controlled. Sniff the leading bytes, generate your
own storage name, and never join the client’s filename onto a path.
Related
Interview angle 6
- “How do you do authentication in FastAPI?” - a security scheme such as
OAuth2PasswordBeareras a dependency. It extracts the token and declares the scheme in OpenAPI, which is what makes the Authorize button in/docswork. Reading the header by hand loses that half. - “
DependsorSecurity?” -Securitywhen scopes are involved: it carries the required scopes intoSecurityScopesand into the schema, so each endpoint documents what it needs.Dependsotherwise. - “How do you protect a whole area of the API?” -
APIRouter(dependencies=[Depends(require_admin)]). It runs for every route on the router and its return value is discarded; it exists to raise. Secure by default beats securing by remembering. - “What’s wrong with a bare 401?” - no
WWW-Authenticateheader. The spec requires it and clients use it to decide how to re-authenticate. FastAPI will not add it for you. - “How would you rate limit?” -
slowapifor one instance, Redis with a sliding-window Lua script for many. But say that the application is the last line: the edge — nginx or the CDN — is where cheap limiting belongs, and the app limit is for what the edge cannot see, like per-tenant quotas. - “What’s the file upload trap?” - taking
bytesinstead ofUploadFileloads the whole file into memory. Andcontent_typeand the filename are attacker-controlled, so sniff the leading bytes and generate your own storage name rather than joining theirs onto a path.