Skip to content

Sessions

Sessions are the boilerplate's default authentication mechanism. All built-in API routes use session auth.

Auth Architecture

Authentication is provided by the crudauth library. The composition root is a single singleton in infrastructure/auth/setup.py:

# infrastructure/auth/setup.py
auth = CRUDAuth(session=async_session, user_model=User, SECRET_KEY=settings.SECRET_KEY, ...)

Routers and dependencies reference auth at import time, and the app lifespan calls auth.initialize() on startup and auth.shutdown() on teardown (wired in app_factory) to open and close the session backend connections. Everything below — the dependencies, login flow, CSRF, lockout, and session storage — is this singleton in action; the boilerplate only supplies the wiring and route handlers.

Protecting Routes

Import the session dependencies and add them to your routes:

from typing import Annotated, Any
from fastapi import APIRouter, Depends

from ...infrastructure.auth.dependencies import get_current_user

router = APIRouter()


@router.get("/my-profile")
async def get_profile(
    current_user: Annotated[dict[str, Any], Depends(get_current_user)],
) -> dict[str, Any]:
    return {"user_id": current_user["id"], "email": current_user["email"]}

If the request doesn't have a valid session, the boilerplate returns 401 Unauthorized.

Available Dependencies

All from src/infrastructure/auth/dependencies.py. They wrap the crudauth auth singleton, so cookie validation, CSRF, and login lockout live in the library while your handlers keep working with plain user dicts.

get_current_user — Returns the authenticated user dict. Raises 401 if not authenticated.

@router.get("/dashboard")
async def dashboard(
    current_user: Annotated[dict[str, Any], Depends(get_current_user)],
) -> dict[str, Any]:
    return {"welcome": current_user["username"]}

get_current_superuser — Same as get_current_user, plus checks is_superuser=True. Raises 403 if not a superuser.

@router.delete("/users/{user_id}")
async def delete_user(
    user_id: int,
    current_user: Annotated[dict[str, Any], Depends(get_current_superuser)],
) -> None:
    # Only superusers reach this code
    ...

get_optional_user — Returns the user dict if authenticated, None otherwise. Never raises.

@router.get("/products")
async def list_products(
    current_user: Annotated[dict[str, Any] | None, Depends(get_optional_user)],
) -> list[dict[str, Any]]:
    if current_user:
        # Personalize for logged-in users
        ...

get_current_principal — Returns the crudauth Principal (session-validated, CSRF-enforced). Use it when you need the session id (principal.metadata["session_id"]) or the user_id directly rather than the full user dict. get_optional_principal is the never-raises variant.

get_current_permissions — Returns the permissions the caller effectively holds through the roles assigned to them, as a frozenset[str]. FastAPI caches it for the length of the request, so the lookup runs once however many guards ask for it. Superusers hold every registered permission.

@router.patch("/{username}")
async def update_user_profile(
    username: str,
    permissions: Annotated[frozenset[str], Depends(get_current_permissions)],
) -> dict[str, str]:
    if "user.update" in permissions:
        ...

require_permissions(*names) — Returns a dependency that raises 403 unless the caller holds every named permission. Superusers pass. It injects nothing, so it goes in the route's dependencies:

from ...infrastructure.auth.dependencies import require_permissions


@router.get("/", dependencies=[require_permissions("user.read")])
async def get_users(...) -> dict[str, Any]:
    ...

See Permissions for declaring the names these take.

Protecting Entire Routers

Apply auth to every route in a router:

router = APIRouter(
    prefix="/admin",
    dependencies=[Depends(get_current_superuser)],
)


@router.get("/stats")
async def stats() -> dict[str, Any]:
    # Already authenticated at the router level
    ...

Note: router-level dependencies don't inject values into handlers. If you need the user object inside the handler, also add Depends(get_current_user) to that specific route.

How Sessions Work

The login route delegates to the crudauth auth singleton (see Auth Architecture). When a user hits POST /api/v1/auth/login, crudauth:

  1. Applies its per-IP / per-identifier login lockout (returns 429 + Retry-After if tripped)
  2. Validates the credentials against the user row (soft-deleted users — is_active == False — are rejected)
  3. Writes a session record to the configured backend (Redis by default)
  4. Generates a CSRF token bound to the session
  5. Sets two cookies on the response:
    • session_id — HTTP-only, the session identifier
    • csrf_token — readable by JS, mirrors the CSRF token returned in the JSON body

On every subsequent request, the auth dependency (via crudauth):

  1. Reads session_id from cookies
  2. Looks it up in the configured backend; rejects expired or missing sessions
  3. For mutating requests (POST/PUT/DELETE/PATCH), validates the CSRF token if CSRF_ENABLED=true
  4. Hands back a Principal; get_current_user then re-loads the full user row (joined with the Tier relationship via lazy="selectin")

Logout (POST /api/v1/auth/logout) terminates the session record and clears the cookies. To end every session the user holds on all devices (e.g. after a suspected compromise), use POST /api/v1/auth/logout-all. See Logout All Sessions.

CSRF Protection

Session auth ships with CSRF protection. For non-GET requests, send the CSRF token via either:

  • The csrf_token cookie (browsers send it automatically), or
  • The X-CSRF-Token header (typical for JS clients)
const csrfToken = getCookie('csrf_token');

await fetch('/api/v1/users/', {
    method: 'POST',
    credentials: 'include',          // include cookies cross-origin
    headers: {
        'X-CSRF-Token': csrfToken,
        'Content-Type': 'application/json',
    },
    body: JSON.stringify(data),
});

Need a fresh token mid-session? Hit POST /api/v1/auth/refresh-csrf — it returns a new token and sets the cookie.

For dev/test environments where CSRF gets in the way, set CSRF_ENABLED=false.

Device Tracking

crudauth records session metadata (IP address, User-Agent, timestamps) internally as part of each session record. The boilerplate does not surface a device-listing route or a SessionData schema — that metadata lives inside the library's session store. If you need an "active sessions" UI, build it on crudauth's session APIs (auth.sessions) rather than expecting a ready-made dependency here.

Login Lockout

Failed login attempts are throttled by crudauth itself. It applies an escalating per-IP / per-identifier lockout and, once tripped, returns 429 Too Many Requests with a Retry-After header on /api/v1/auth/login. This happens automatically inside the login flow — there's nothing to wire up and no env vars to tune. Behind a reverse proxy, set TRUSTED_PROXY_HOPS so the lockout keys on the real client IP rather than the proxy's.

Session Limits

Per-user concurrent session count is capped by MAX_SESSIONS_PER_USER (default 5). When a user logs in beyond this cap, the oldest session is terminated.

Storage Backends

Sessions are stored server-side. Configure via SESSION_BACKEND:

Value When to use
redis (default) Production. Supports key expiration, pattern scans for cleanup, persists across restarts
memory Tests only. Cleared on restart, not safe for multi-process deploys

The backends ship inside the crudauth library, not the boilerplate — setup.py just selects redis or memory based on SESSION_BACKEND. (Memcached is no longer a session option; it remains available for the general cache and rate limiter.)

Configuration

# Backend
SESSION_BACKEND=redis                # redis | memory
SESSION_REDIS_DB=2                   # on the cache Redis; isolated from cache (0), rate limiter (1), and taskiq (3)
# SESSION_REDIS_URL=                 # optional dedicated session Redis, e.g. rediss://user:password@host:6380/0

# Lifetime
SESSION_TIMEOUT_MINUTES=30           # inactive sessions expire
SESSION_CLEANUP_INTERVAL_MINUTES=15  # how often the storage backend sweeps expired entries

# Per-user cap
MAX_SESSIONS_PER_USER=5

# Cookie security (HTTPS only)
SESSION_SECURE_COOKIES=true

# CSRF
CSRF_ENABLED=true

# Trusted reverse proxies in front of the app (real client IP for login lockout)
TRUSTED_PROXY_HOPS=0

For development you'll typically set SESSION_SECURE_COOKIES=false and CSRF_ENABLED=false so cookies work over plain HTTP and curl/Postman aren't blocked. Re-enable both for staging and production.

Login & Logout Flow

Login

curl -X POST http://localhost:8000/api/v1/auth/login \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "username=admin&password=your_admin_password" \
  -c cookies.txt

Response:

{ "id": 1, "username": "admin", "csrf_token": "..." }

The HTTP-only session_id cookie is now in cookies.txt. The CSRF token is also set as a cookie and returned in the body so JS clients can store it (browsers can't read HTTP-only cookies).

Add remember_me=true to the form to make the session cookie persistent; without it the cookie ends with the browser session. A login the browser marks Sec-Fetch-Site: cross-site is refused with 403, so another site can't sign a visitor into an account it controls. Clients that don't send the header, such as curl or a mobile app, are unaffected.

Authenticated Request

curl http://localhost:8000/api/v1/users/me -b cookies.txt

For mutating requests, add the CSRF header:

curl -X POST http://localhost:8000/api/v1/users/ \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: <token-from-login-response>" \
  -d '{"name": "...", "username": "...", "email": "...", "password": "..."}'

Refresh CSRF Token

curl -X POST http://localhost:8000/api/v1/auth/refresh-csrf -b cookies.txt

Logout

curl -X POST http://localhost:8000/api/v1/auth/logout -b cookies.txt

Terminates the session and clears the cookies.

Logout All Sessions

curl -X POST http://localhost:8000/api/v1/auth/logout-all \
  -b cookies.txt \
  -H "X-CSRF-Token: <token-from-login-response>"

Terminates every session for the current user across all devices, including this one, and clears the cookies:

{ "message": "All sessions terminated. Please log in again.", "terminated_count": 3 }

To sign out only the other devices and stay logged in here, pass keep_current=true:

curl -X POST "http://localhost:8000/api/v1/auth/logout-all?keep_current=true" \
  -b cookies.txt \
  -H "X-CSRF-Token: <token-from-login-response>"

No re-authentication step is required, because this is the action a user needs when they can't trust their current session. It's rate limited per user (crudauth's logout_all default: 10 per hour).

Key Files

Component Location
auth = CRUDAuth(...) singleton backend/src/infrastructure/auth/setup.py
Dependencies backend/src/infrastructure/auth/dependencies.py
OAuth configuration backend/src/infrastructure/auth/setup.py
Login/logout/logout-all/OAuth routes backend/src/infrastructure/auth/routes.py
HTTP exceptions (fastcrud re-export) backend/src/infrastructure/auth/http_exceptions.py
Auth settings backend/src/infrastructure/config/settings.py (AuthSettings)

Session storage, CSRF, and lockout themselves live in the crudauth library, not the boilerplate.


← Authentication Overview User Management →