Middleware Architecture
The backend uses nine middleware layers, each with a specific responsibility. They are registered in app/main.py in a specific order to ensure correct behavior.
Middleware Stack Order
Section titled “Middleware Stack Order”Middleware in Starlette is wrapped like an onion — the last middleware added is the outermost. Starlette’s add_middleware inserts at position 0, so we add in reverse of the desired order:
# Desired order (outermost first): Metrics → RequestId → FxFlag → SecurityHeaders# → RateLimit → BodyLimit → CSRF → AuditLog → CORS (innermost)# add_middleware inserts at position 0, so last added = outermost.app.add_middleware(CORSMiddleware, ...) # 1st added → innermostapp.add_middleware(AuditLogMiddleware)app.add_middleware(CSRFMiddleware)app.add_middleware(BodyLimitMiddleware)app.add_middleware(RateLimitMiddleware)app.add_middleware(SecurityHeadersMiddleware)app.add_middleware(FxFlagMiddleware)app.add_middleware(RequestIdMiddleware)app.add_middleware(MetricsMiddleware) # last added → outermostActual request execution order (outermost to innermost):
Why This Order
Section titled “Why This Order”| Position | Middleware | Rationale |
|---|---|---|
| Outermost | Metrics | Counts every request, including the 429, 403 and 413 answers of the layers below. |
| 2nd | Request ID | Gives every request an X-Request-ID so all inner logs and error bodies can be correlated. |
| 3rd | FX flags | Adds X-Missing-Rates to responses whose totals used a currency at par. |
| 4th | Security headers | Outside rate limiting and CSRF so their 429 and 403 answers still carry the headers. |
| 5th | Rate Limit | Rejects requests that exceed rate limits before any other processing. Rate-limited (429) responses never reach body limit, CSRF, audit, or CORS — minimizing wasted work. |
| 6th | Body limit | Rejects oversized bodies (413) before they are read by anything below. |
| 7th | CSRF | Rejects state-changing requests without X-Requested-With before audit logging or CORS processing. |
| 8th | Audit Log | Logs all requests after rate-limit and CSRF rejection but before CORS — captures authentication events and mutations that passed security checks. |
| Innermost | CORS | Adds CORS headers to all responses, including errors from route handlers. Without this as innermost, error responses from routes would lack CORS headers, causing the frontend to see opaque errors. |
RateLimitMiddleware
Section titled “RateLimitMiddleware”File: app/middleware/rate_limit.py
In-memory sliding-window rate limiter, bucketed by path-prefix and by actor. The actor is:
- The authenticated user when one can be verified synchronously (a valid local-JWT
access_tokencookie, bucketed by itssub). - Otherwise the client IP —
X-Real-IP(non-spoofable, set by nginx) whentrusted_proxy=True, elserequest.client.host.
This keeps users who share an IP (office network, VPN, CGNAT) from sharing — and exhausting — one budget, while API tokens, OIDC sessions, and the /auth/* endpoints themselves stay IP-based (see the docstrings in rate_limit.py for why each of those can’t be cheaply/safely verified at this layer).
Path-Specific Limits
Section titled “Path-Specific Limits”| Path prefix | Limit | Window |
|---|---|---|
/auth/ |
10 requests | 60s |
/api/v1/auth/ |
10 requests | 60s |
/api/v1/auth/me |
400 requests | 60s |
/api/v1/auth/totp/recovery-status |
400 requests | 60s |
/api/v1/auth/sessions |
400 requests | 60s (GET/HEAD only; revoking stays in the strict bucket) |
/api/v1/import/ |
5 requests | 60s |
/api/v1/ |
400 requests | 60s |
| default | 400 requests | 60s |
Limits are per-actor per-path-prefix (bucketed by prefix, not individual path). The /health endpoint is exempt.
IPv6 client addresses are bucketed by their /64 prefix rather than the full address. The strict /auth/ and /api/v1/auth/ buckets are always keyed by IP, even when a valid token is attached (/api/v1/auth/me keeps its own general bucket). Expired windows are evicted at most about once per second instead of on every request.
Response Headers
Section titled “Response Headers”All responses include:
X-RateLimit-Limit— maximum requests per windowX-RateLimit-Remaining— remaining requests in current windowX-RateLimit-Reset— Unix timestamp when the window resets
When rate-limited (429):
Retry-After— seconds until the oldest request expires from the window
Defense-in-Depth
Section titled “Defense-in-Depth”This middleware uses module-level state, meaning each uvicorn worker has its own rate limit buckets. With N workers, the effective rate limit is N× the configured limit.
The nginx limit_req_zone in the frontend container provides the authoritative per-IP rate limiting at the proxy layer (API: 10 r/s burst 20, auth: 5 r/s burst 10). This middleware is a defense-in-depth measure for single-worker deployments.
See git.desord.re/eofredj/patrimoine issue #1 for the scaling gaps still open at this layer (no shared store across workers/replicas).
CSRFMiddleware
Section titled “CSRFMiddleware”File: app/middleware/csrf.py
Requires the X-Requested-With header on all state-changing requests (POST, PATCH, PUT, DELETE). This prevents CSRF because plain HTML forms cannot set custom headers — only JavaScript (via fetch or XMLHttpRequest) can, and cross-origin requests require CORS preflight.
Exemptions
Section titled “Exemptions”| Condition | Reason |
|---|---|
| GET, HEAD, OPTIONS | Safe methods per RFC 7231 — no state change |
/health |
Health check endpoint — no auth, no mutation |
E2E test mode (e2e_test_mode=true) |
Playwright navigations don’t send custom headers |
Authorization: Bearer ptr_... |
API token auth — no cookie = no CSRF risk |
Login CSRF
Section titled “Login CSRF”All /auth/ paths are covered by CSRF protection, which prevents login CSRF — a forged cross-site POST to /auth/login that would make the victim’s browser authenticate as the attacker.
The /auth/ prefix is therefore not exempt. The OIDC flow uses only GET requests (already exempt as safe methods), so OIDC is unaffected.
AuditLogMiddleware
Section titled “AuditLogMiddleware”File: app/middleware/audit_log.py
Logs authentication events and data mutations to both stdout (structured logging) and the database (non-blocking, best-effort).
Logged Events
Section titled “Logged Events”| Event type | Condition |
|---|---|
auth |
Any request to /auth/*, and any non-GET/HEAD request to /api/v1/auth/*. GET/HEAD reads under /api/v1/auth/ (/me, /config, /sessions, polled on every window focus) are not logged. |
mutation |
POST, PATCH, PUT, or DELETE to /api/v1/* |
Health checks (/health) are excluded.
Log Fields
Section titled “Log Fields”Each audit log entry records:
| Field | Source | Example |
|---|---|---|
event |
auth or mutation |
auth |
method |
HTTP method | POST |
path |
Request path | /auth/login |
status |
Response status code | 200 |
ip |
Client IP (X-Real-IP or direct) | 203.0.113.42 |
user_id |
From request.state.user_id (set by get_current_user) |
1 |
duration_ms |
Request processing time | 42 |
DB Persistence
Section titled “DB Persistence”Database writes are non-blocking (fire-and-forget). If the DB write fails, the request still succeeds — the failure is logged at warning level (best effort).
The audit log is queryable via GET /api/v1/audit-logs (paginated). A background task cleans up entries older than AUDIT_LOG_RETENTION_DAYS (default 90 days).
CORSMiddleware
Section titled “CORSMiddleware”File: Configured in app/main.py (Starlette built-in)
app.add_middleware( CORSMiddleware, allow_origins=[settings.frontend_url], allow_credentials=True, allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"], allow_headers=["Content-Type", "Authorization", "X-Requested-With", "Idempotency-Key"], expose_headers=["X-Missing-Rates", "X-Truncated"],)- Origins: Only the configured
FRONTEND_URLis allowed (e.g.,https://app.example.com). No wildcard (*). - Credentials:
true— allows cookies (access_token, refresh_token) in cross-origin requests. Required because the frontend and backend are on different ports during local dev (5173 vs 8000). - Methods: GET, POST, PUT, PATCH, DELETE.
- Headers:
Content-Type,Authorization,X-Requested-WithandIdempotency-Key. - Exposed headers:
X-Missing-Rates,X-Truncated.
BodyLimitMiddleware
Section titled “BodyLimitMiddleware”File: app/middleware/body_limit.py
Rejects oversized request bodies with 413 request_too_large. The cap is max_request_body_bytes (2 MiB), raised to max_import_body_bytes (12 MiB) for /api/v1/import/ and /api/v1/budget/transactions/import*.
RequestIdMiddleware
Section titled “RequestIdMiddleware”File: app/middleware/request_id.py
Sets X-Request-ID on every response. An incoming value is kept if it matches ^[A-Za-z0-9._-]{1,64}$; otherwise a new UUID hex is generated.
FxFlagMiddleware
Section titled “FxFlagMiddleware”File: app/middleware/fx_flags.py
Adds the X-Missing-Rates response header when a total used a currency at par because no exchange rate was available.
SecurityHeadersMiddleware
Section titled “SecurityHeadersMiddleware”File: app/middleware/security_headers.py
Backstop for TRUSTED_PROXY=false (backend exposed without nginx): sets X-Content-Type-Options, X-Frame-Options and Referrer-Policy with setdefault, so a route can override them. It sets no CSP and no HSTS; nginx owns those.
MetricsMiddleware
Section titled “MetricsMiddleware”File: app/middleware/metrics.py
Records a request counter and latency histogram labelled by route template, served by GET /metrics. The /metrics endpoint itself is not counted.
Exception Handlers
Section titled “Exception Handlers”File: app/errors.py
Four exception handlers are registered to standardize all error responses:
APIError (application-level)
Section titled “APIError (application-level)”Custom exception with code, message, and optional detail. Used for business logic errors (e.g., “Account not found”, “Duplicate snapshot”).
HTTPException (FastAPI built-in)
Section titled “HTTPException (FastAPI built-in)”Standard FastAPI exceptions are caught and reformatted. Status codes are mapped to error codes:
| Status | Code |
|---|---|
| 400 | bad_request |
| 401 | unauthorized |
| 403 | forbidden |
| 404 | not_found |
| 405 | method_not_allowed |
| 409 | conflict |
| 422 | validation_error |
| 429 | rate_limited |
| 500 | internal_error |
RequestValidationError (Pydantic)
Section titled “RequestValidationError (Pydantic)”Caught and returned as 422 with validation error details in the detail field.
Unhandled exceptions (Exception)
Section titled “Unhandled exceptions (Exception)”Returns 500 {"error": {"code": "internal_error", "message": "Internal server error"}}, logs the traceback with the request id and sets X-Request-ID itself (it runs outside RequestIdMiddleware).
Standard Error Format
Section titled “Standard Error Format”All error responses follow the same structure:
{ "error": { "code": "not_found", "message": "Account not found" }}The detail field is omitted when empty; when present it provides additional context (e.g., validation errors, conflicting resource IDs).
See Also
Section titled “See Also”- Backend Architecture — Routers, models, services
- Security Overview — Security measures summary
- Cookies & CSRF — Cookie security and CSRF deep dive
- API Design Conventions — Error format and response patterns