Authentication & Security¶
The boilerplate uses server-side sessions with HTTP-only cookies — not JWT. Sessions are stored in Redis (or memory/memcached, configurable), CSRF-protected, and rate-limited at the login endpoint.
For machine-to-machine clients, the boilerplate ships API keys with per-key permissions and usage tracking.
What You'll Learn¶
- Sessions - Server-side sessions, cookies, and CSRF protection
- User Management - Registration, login, profile operations
- Permissions - Role-based access control and resource ownership
Why Sessions, Not JWT¶
The original boilerplate used JWT with refresh tokens and a token blacklist. We replaced that with sessions because:
- Logout is trivial. Delete the session row, done. No blacklist to maintain.
- Rotating credentials is trivial. Update the session record. No need to wait for tokens to expire.
- CSRF is built in. Server-side sessions naturally pair with double-submit CSRF tokens.
- Storage is server-side. No risk of accidentally leaking long-lived tokens via XSS to client storage.
- Sessions match how most users actually want to think about authentication. "Is this person logged in?" is a database question, not a cryptographic one.
If you specifically need stateless tokens (e.g. for inter-service auth where you can't share a session store), use API keys — they're stateless from the client's perspective and authenticated server-side.
Authentication Mechanisms¶
The boilerplate supports three auth pathways. They coexist; you pick the right one per endpoint.
1. Sessions (Browser Clients)¶
# Log in — server sets the session cookie and returns a CSRF token
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
# → { "csrf_token": "..." }
# Subsequent requests — send the cookie back
curl http://localhost:8000/api/v1/users/me -b cookies.txt
# Log out
curl -X POST http://localhost:8000/api/v1/auth/logout -b cookies.txt
Routes use Depends(get_current_user) to require an authenticated session.
2. OAuth (Google)¶
For social sign-in — Google OAuth 2.0 with PKCE is wired up. The user is redirected to Google, signs in, and is bounced back to a callback that creates a session.
# Start the flow
curl http://localhost:8000/api/v1/auth/oauth/google
# → { "url": "https://accounts.google.com/...?state=..." }
# After the user signs in at Google, they hit the callback:
# GET /api/v1/auth/oauth/callback/google?code=...&state=...
# The server creates a session and either redirects or returns JSON.
A GitHub OAuth provider is scaffolded in infrastructure/auth/oauth/providers/github.py but no GitHub callback routes are wired yet. Wire those up in infrastructure/auth/routes.py if you need GitHub sign-in.
3. API Keys (Machine-to-Machine)¶
For server-to-server clients, programs, scripts, integrations:
# Create a key (requires an authenticated session)
curl -X POST "http://localhost:8000/api/v1/api-keys/" \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"name": "Integration Key", "permissions": {}, "usage_limits": {}}'
# → { "key": "shown ONCE — store securely", ... }
The full key is returned only on creation. Each key has its own permissions, usage limits, and audit trail (KeyUsage rows).
Key Features¶
Server-Side Sessions¶
- Session storage: Redis by default; memory/memcached available (
SESSION_BACKENDenv var) - HTTP-only cookies:
session_idcookie cannot be read by JavaScript - CSRF tokens: Returned on login, also set as a cookie, must be sent in
X-CSRF-Tokenfor state-changing requests - Configurable timeout:
SESSION_TIMEOUT_MINUTES,SESSION_COOKIE_MAX_AGE - Per-user limits:
MAX_SESSIONS_PER_USERcaps simultaneous sessions per account - Automatic cleanup:
SESSION_CLEANUP_INTERVAL_MINUTEScontrols expiry sweeps
User Management¶
- Username or email login (the same
/api/v1/auth/loginendpoint accepts either) - bcrypt password hashing
- Soft delete for user records — accounts are deactivated, not destroyed (toggle via
is_deleted) - GDPR/LGPD anonymization endpoint for hard-clearing PII (
DELETE /api/v1/users/db/{username}) - OAuth flag on the user model (
google_id,github_id,oauth_provider)
Permission System¶
- Superuser flag on
User.is_superuserfor admin-only routes - Tier-based access via the
Tiermodel — every user belongs to a tier, and rate limits are configured per tier path - Resource ownership checks live in services (the route doesn't decide who owns what)
Login Rate Limiting¶
The login endpoint tracks failed attempts per IP+username. Configurable:
When the limit is hit, POST /api/v1/auth/login returns 401 Unauthorized: Too many failed login attempts. Please try again later.
Authentication Patterns¶
All session deps live in src/infrastructure/auth/session/dependencies.py.
Required Authentication¶
from ...infrastructure.auth.session.dependencies import get_current_user
@router.get("/me", response_model=UserRead)
async def me(
current_user: Annotated[dict[str, Any], Depends(get_current_user)],
) -> dict[str, Any]:
return current_user
Returns 401 if the session cookie is missing or invalid.
Optional Authentication¶
from ...infrastructure.auth.session.dependencies import get_optional_user
@router.get("/")
async def list_things(
user: Annotated[dict[str, Any] | None, Depends(get_optional_user)],
):
# Logged-in users see extras; anonymous users still get a response
if user is not None:
return {"premium": True}
return {"premium": False}
Superuser Only¶
from ...infrastructure.auth.session.dependencies import get_current_superuser
@router.delete("/{username}/permanent")
async def gdpr_delete_user(
username: str,
db: Annotated[AsyncSession, Depends(async_session)],
user_service: Annotated[UserService, Depends(get_user_service)],
_: Annotated[dict[str, Any], Depends(get_current_superuser)],
) -> dict[str, str]:
...
The leading underscore is the codebase's convention for dependency-only parameters.
Resource Ownership¶
Ownership is checked in the service layer, not in the route:
# modules/user/service.py
async def verify_user_permission(
self,
current_user: dict[str, Any],
target_username: str,
action: str,
) -> None:
if current_user["username"] != target_username and not current_user["is_superuser"]:
raise PermissionDeniedError(f"Cannot {action} for another user")
The route delegates and the service raises PermissionDeniedError (which auto-maps to 403). See Exceptions for the mapping layer.
Security Features¶
Session Security¶
- HTTP-only
session_idcookie — JavaScript can't read it (XSS-safe) Securecookies in non-dev environments (SESSION_SECURE_COOKIES=true)- CSRF token validation for state-changing requests (
CSRF_ENABLED=true) - IP and user-agent recorded with each session
- Per-user session count cap (
MAX_SESSIONS_PER_USER)
Password Security¶
- bcrypt hashing with automatic salt
- Pydantic validation enforces minimum length and complexity at the schema level (
UserCreate.password) - Plaintext passwords are never stored or logged
- Login rate limiting prevents credential stuffing
Production Validator¶
When ENVIRONMENT=production and PRODUCTION_SECURITY_VALIDATION_ENABLED=true (both default), the app refuses to start if it finds insecure settings:
- Default
SECRET_KEYvalue DEBUG=trueCORS_ORIGINS=*
PRODUCTION_SECURITY_STRICT_MODE=true makes the validator stricter still.
Configuration¶
The full reference is in Environment Variables. The most relevant settings:
# Sessions
SESSION_TIMEOUT_MINUTES=30
SESSION_CLEANUP_INTERVAL_MINUTES=15
MAX_SESSIONS_PER_USER=5
SESSION_SECURE_COOKIES=true
SESSION_BACKEND=redis # redis | memory | memcached
SESSION_COOKIE_MAX_AGE=86400
# CSRF
CSRF_ENABLED=true # set false for dev/test
# Login rate limiting
LOGIN_MAX_ATTEMPTS=5
LOGIN_WINDOW_MINUTES=15
# OAuth
OAUTH_REDIRECT_BASE_URL=http://localhost:8000
OAUTH_GOOGLE_CLIENT_ID=
OAUTH_GOOGLE_CLIENT_SECRET=
OAUTH_GITHUB_CLIENT_ID= # provider scaffolded; routes not wired
OAUTH_GITHUB_CLIENT_SECRET=
# Security
SECRET_KEY=<openssl rand -hex 32>
PRODUCTION_SECURITY_VALIDATION_ENABLED=true
PRODUCTION_SECURITY_STRICT_MODE=false
Quick Examples¶
Frontend Login Flow (JavaScript)¶
class AuthClient {
async login(username, password) {
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
credentials: 'include', // important — accept cookies
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ username, password }),
});
if (!res.ok) throw new Error('login failed');
const { csrf_token } = await res.json();
// Store the CSRF token in memory; cookie is set automatically
this.csrfToken = csrf_token;
return csrf_token;
}
async post(url, body) {
return fetch(url, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfToken, // required for state-changing requests
},
body: JSON.stringify(body),
});
}
async logout() {
await fetch('/api/v1/auth/logout', {
method: 'POST',
credentials: 'include',
headers: { 'X-CSRF-Token': this.csrfToken },
});
this.csrfToken = null;
}
}
The credentials: 'include' flag is what makes the browser actually send cookies cross-origin. Pair this with proper CORS settings on the server side (CORS_ALLOW_CREDENTIALS=true).
Custom Tier-Based Dependency¶
You can combine the built-in deps to enforce tier checks:
from typing import Annotated, Any
from fastapi import Depends, HTTPException
from ...infrastructure.auth.session.dependencies import get_current_user
async def require_tier(
tier_name: str,
user: Annotated[dict[str, Any], Depends(get_current_user)],
) -> dict[str, Any]:
user_tier = user.get("tier") or {}
if user_tier.get("name") != tier_name:
raise HTTPException(status_code=403, detail=f"Requires {tier_name} tier")
return user
# Usage with a Pro tier
@router.get("/premium")
async def premium_feature(
user: Annotated[dict[str, Any], Depends(lambda u=Depends(get_current_user): require_tier("pro", u))],
):
return {"data": "premium content"}
In practice, prefer raising PermissionDeniedError from inside a service method so the mapping layer translates it consistently (see Exceptions).
Getting Started¶
- Sessions — How sessions work, cookie handling, CSRF
- User Management — Registration, login, profile
- Permissions — Role-based and resource-based access control
What's Next¶
- Environment Variables — All auth-related settings
- Exceptions — How
PermissionDeniedErrorand friends become HTTP 403/401 - API Endpoints — Patterns for protecting routes