Backend / Web frameworks / FastAPI / 05_security_and_authentication.md

Security and authentication

Updated 6 interview angles 4 min read source
On this page8
  1. Security schemes are dependencies
  2. Scopes are first-class
  3. Securing a whole area
  4. The traps that are FastAPI’s own
  5. Rate limiting
  6. File uploads
  7. Related
  8. Interview angle

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

python
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 user

OAuth2PasswordBearer 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:

python
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

python
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 separate response_model is 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

python
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 bytesbytes 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.

Interview angle 6

  • “How do you do authentication in FastAPI?” - a security scheme such as OAuth2PasswordBearer as a dependency. It extracts the token and declares the scheme in OpenAPI, which is what makes the Authorize button in /docs work. Reading the header by hand loses that half.
  • Depends or Security?” - Security when scopes are involved: it carries the required scopes into SecurityScopes and into the schema, so each endpoint documents what it needs. Depends otherwise.
  • “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-Authenticate header. 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?” - slowapi for 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 bytes instead of UploadFile loads the whole file into memory. And content_type and the filename are attacker-controlled, so sniff the leading bytes and generate your own storage name rather than joining theirs onto a path.