Authentication and Authorization

Published by Programming Line in FastAPI. Last updated .

Authentication answers “who is calling?” Authorization answers “what is that caller allowed to do here?” In FastAPI the practical outcome is a set of dependencies that turn an incoming credential into a user object, reject bad credentials with a 401 response, and reject insufficient permission with a 403 response before endpoint logic changes state.

This belongs in the Dependencies and Configuration section because FastAPI authentication is usually not coded inside each path operation. You define reusable dependency functions and security schemes, then attach them to routes with Depends or Security. That keeps identity, permission policy, and business logic distinct while still making the requirements visible in OpenAPI.

Bearer Tokens and Dependency Resolution

A request arrives with headers, method, path, body, and query parameters. Security helpers such as HTTPBearer or OAuth2PasswordBearer read the Authorization header and produce a credential value. Your dependency verifies that value against a database, signed token, or identity provider, then returns a typed user context. FastAPI caches a dependency result within one request, so if two downstream dependencies require current_user, the credential is normally verified once for that request.

The status codes matter. 401 Unauthorized means authentication failed or was missing; include a WWW-Authenticate header when appropriate so clients know how to retry. 403 Forbidden means the caller is authenticated but lacks the required scope, role, relationship, or policy decision. 404 Not Found can be appropriate for object-level authorization when revealing that a private object exists would leak information.

API Anatomy

HTTPBearer(auto_error=False) returns None instead of raising automatically, which lets you control the error body and headers. OAuth2PasswordBearer(tokenUrl="/token", scopes={...}) describes an OAuth2 password-flow style token endpoint for documentation and extracts bearer tokens from requests. Security is like Depends, but it can pass required scopes to the dependency through SecurityScopes. That is the key FastAPI mechanism for route-specific authorization requirements.

Real applications should verify cryptographic signatures, expiration, issuer, and audience for JWTs, or call an introspection/session store for opaque tokens. The examples below use in-memory token maps so the FastAPI mechanics are clear and the blocks can run without external services.

Example 1: Authenticate the Caller

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

app = FastAPI(title="Auth example")
bearer = HTTPBearer(auto_error=False)
TOKENS = {"reader-token": {"sub": "ada", "roles": ["reader"]}}

async def current_user(credentials: HTTPAuthorizationCredentials | None = Depends(bearer)):
    if credentials is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Missing bearer token",
            headers={"WWW-Authenticate": "Bearer"},
        )
    user = TOKENS.get(credentials.credentials)
    if user is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid bearer token",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return user

@app.get("/me")
async def read_me(user: dict = Depends(current_user)):
    return {"username": user["sub"], "roles": user["roles"]}

With no Authorization header, GET /me returns 401 and {"detail":"Missing bearer token"}. With Authorization: Bearer reader-token, it returns {"username":"ada","roles":["reader"]}. With any other token, it returns 401. The endpoint never parses headers itself; it receives a user context that has already passed authentication.

Example 2: Require a Scope

from fastapi import Depends, FastAPI, HTTPException, Security, status
from fastapi.security import OAuth2PasswordBearer, SecurityScopes

app = FastAPI(title="Scope example")
oauth2 = OAuth2PasswordBearer(
    tokenUrl="/token",
    scopes={"items:read": "Read items", "items:write": "Create or change items"},
)
TOKENS = {
    "read-token": {"sub": "ada", "scopes": {"items:read"}},
    "write-token": {"sub": "grace", "scopes": {"items:read", "items:write"}},
}

async def current_user(security_scopes: SecurityScopes, token: str = Depends(oauth2)):
    user = TOKENS.get(token)
    if user is None:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
    missing = [scope for scope in security_scopes.scopes if scope not in user["scopes"]]
    if missing:
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail={"missing_scopes": missing})
    return user

@app.post("/items")
async def create_item(user: dict = Security(current_user, scopes=["items:write"])):
    return {"created_by": user["sub"], "status": "created"}

This adds route-level authorization. A request to POST /items with Bearer read-token authenticates successfully but returns 403 with {"detail":{"missing_scopes":["items:write"]}}. With Bearer write-token, it returns {"created_by":"grace","status":"created"}. Scopes are useful when permissions describe capabilities that many routes share, such as read, write, administer, or bill.

Example 3: Enforce Object Ownership

from fastapi import Depends, FastAPI, HTTPException, Security, status
from fastapi.security import OAuth2PasswordBearer, SecurityScopes

app = FastAPI(title="Ownership example")
oauth2 = OAuth2PasswordBearer(tokenUrl="/token", scopes={"items:read": "Read items"})
TOKENS = {
    "ada-token": {"sub": "ada", "scopes": {"items:read"}},
    "grace-token": {"sub": "grace", "scopes": {"items:read"}},
}
ITEMS = {"one": {"owner": "ada", "name": "compiler notes"}}

async def current_user(security_scopes: SecurityScopes, token: str = Depends(oauth2)):
    user = TOKENS.get(token)
    if user is None:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
    missing = [scope for scope in security_scopes.scopes if scope not in user["scopes"]]
    if missing:
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail={"missing_scopes": missing})
    return user

async def visible_item(item_id: str, user: dict = Security(current_user, scopes=["items:read"])):
    item = ITEMS.get(item_id)
    if item is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found")
    if item["owner"] != user["sub"]:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found")
    return item

@app.get("/items/{item_id}")
async def read_item(item: dict = Depends(visible_item)):
    return {"name": item["name"]}

Scopes alone are not enough for multi-tenant data. Both Ada and Grace have items:read, but only Ada owns item one. Ada receives {"name":"compiler notes"}. Grace receives 404, the same result she would get for an item id that does not exist. This dependency composes authentication, scope checking, lookup, and ownership into one reusable guard.

Design Choices and Trade-offs

HTTPBearer is simple and appropriate when you already have a token format and do not need OAuth2 documentation. OAuth2PasswordBearer improves generated docs and supports scopes, but it does not implement a login system by itself. FastAPI extracts the token; your application still validates it.

Roles are coarse labels such as admin or support. Scopes are action-oriented permissions such as notes:write. Object policies decide whether this user may act on this particular record. Most services need all three at different layers. Keep coarse checks near the route, and keep object checks beside the data retrieval that proves ownership, membership, or tenancy.

JWTs allow local verification and reduce database reads, but revocation and permission changes are harder until the token expires. Opaque session tokens require a lookup but are easier to revoke immediately. Short-lived access tokens with refresh tokens are common because they limit stolen-token lifetime without forcing a login on every request.

Failure Modes and Troubleshooting

  • Symptom: every protected request returns 401. Cause: the client sends Token abc or a cookie while the dependency expects Authorization: Bearer abc. Diagnose: inspect request headers in a local client test and confirm the security helper in use. Correct: send the bearer header or change the scheme intentionally.
  • Symptom: a logged-in user receives 403 on one endpoint only. Cause: the route requires a scope that the token does not contain. Diagnose: log the user id and missing scope, not the token. Correct: update the permission grant or adjust the route requirement if the policy was wrong.
  • Symptom: users can read another tenant’s object by guessing ids. Cause: authentication was applied, but object ownership was checked after returning data or not checked at all. Diagnose: add tests where two users share the same scope but own different records. Correct: move tenant or ownership filtering into the dependency or repository query.
  • Symptom: generated docs show a lock icon, but runtime accepts invalid tokens. Cause: the app declared a security scheme but skipped real token validation. Diagnose: call the route with a random token. Correct: verify signature or session lookup before returning a user object.

Security, Performance, and Reliability

Never log raw bearer tokens. Treat tokens like passwords because possession often grants access. Use HTTPS, short expiration, least-privilege scopes, and server-side revocation for high-risk systems. Password verification, when you implement login, should use a slow password hash through a maintained library; do not compare stored plain text.

Token validation runs on protected requests, so cache carefully. Per-request dependency caching is safe for repeated dependencies inside one request. Cross-request caching of user permissions can improve latency, but it must respect revocation and role changes. For external identity providers, set timeouts and decide whether failure should deny access, which is the usual secure default.

Hands-on Lab

Prerequisites: Python with FastAPI installed, plus pytest or the ability to run a Python file directly. Create a scratch file named auth_lab.py and place the following code in it. It defines a protected read route, a write route, and assertions that verify unauthenticated, authenticated, forbidden, and cross-owner behavior.

from fastapi import Depends, FastAPI, HTTPException, Security, status
from fastapi.security import OAuth2PasswordBearer, SecurityScopes
from fastapi.testclient import TestClient

app = FastAPI(title="Lesson lab")
oauth2 = OAuth2PasswordBearer(
    tokenUrl="/token",
    scopes={"notes:read": "Read notes", "notes:write": "Create notes"},
)
TOKENS = {
    "reader": {"sub": "ada", "scopes": {"notes:read"}},
    "writer": {"sub": "grace", "scopes": {"notes:read", "notes:write"}},
}
NOTES = {"n1": {"owner": "ada", "text": "ship the API"}}

async def current_user(security_scopes: SecurityScopes, token: str = Depends(oauth2)):
    user = TOKENS.get(token)
    if user is None:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
    missing = [scope for scope in security_scopes.scopes if scope not in user["scopes"]]
    if missing:
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail={"missing_scopes": missing})
    return user

async def owned_note(note_id: str, user: dict = Security(current_user, scopes=["notes:read"])):
    note = NOTES.get(note_id)
    if note is None or note["owner"] != user["sub"]:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found")
    return note

@app.get("/notes/{note_id}")
async def read_note(note: dict = Depends(owned_note)):
    return {"text": note["text"]}

@app.post("/notes")
async def create_note(text: str, user: dict = Security(current_user, scopes=["notes:write"])):
    note_id = f"n{len(NOTES) + 1}"
    NOTES[note_id] = {"owner": user["sub"], "text": text}
    return {"id": note_id, "owner": user["sub"]}

client = TestClient(app)
assert client.get("/notes/n1").status_code == 401
assert client.get("/notes/n1", headers={"Authorization": "Bearer reader"}).json() == {"text": "ship the API"}
assert client.post("/notes", params={"text": "draft"}, headers={"Authorization": "Bearer reader"}).status_code == 403
assert client.get("/notes/n1", headers={"Authorization": "Bearer writer"}).status_code == 404
print("lab checks passed")

Run it with python auth_lab.py. Verification succeeds when the process prints lab checks passed. To experiment, change the reader token to include notes:write; the POST assertion should no longer expect 403. Cleanup is simply deleting the scratch file. There is no persistent database or external identity provider to roll back.

Assessment Exercises

  1. A user has a valid token and the notes:read scope but receives 404 for /notes/n1. What two different conditions could produce that same response, and why might that be desirable?
  2. Refactor the lab so note creation stores the authenticated user as owner. Which test proves that the writer can read the note she just created?
  3. Design scopes for a billing API with invoice read, invoice payment, and account administration. Which checks should be scopes, and which should be object-level policy?
  4. Given a JWT-based service that must revoke access within one minute after termination, what token lifetime or lookup strategy would you choose, and what latency cost might it add?

Summary

FastAPI authentication and authorization are dependency-driven. A security scheme extracts credentials, a dependency verifies identity, Security supplies route scopes, and object dependencies enforce record-level policy. Keep 401, 403, and privacy-preserving 404 responses distinct, test users with the same scope but different ownership, and make token validation real rather than only documented.