Skip to content

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 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 → innermost
app.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 → outermost

Actual request execution order (outermost to innermost):

flowchart LR accTitle: Middleware request and response order accDescr: A request passes through metrics, request id, FX flags, security headers, rate limiting, body limit, CSRF, audit logging and CORS before reaching the route handler, and the response passes back through the same layers in reverse. Req["Request"] --> M["MetricsMiddleware"] M --> RID["RequestIdMiddleware"] RID --> FX["FxFlagMiddleware"] FX --> SH["SecurityHeadersMiddleware"] SH --> RL["RateLimitMiddleware"] RL --> BL["BodyLimitMiddleware"] BL --> CSRF["CSRFMiddleware"] CSRF --> Audit["AuditLogMiddleware"] Audit --> CORS["CORSMiddleware"] CORS --> Handler["Route Handler"] Handler --> CORS CORS --> Audit Audit --> CSRF CSRF --> BL BL --> RL RL --> SH SH --> FX FX --> RID RID --> M M --> Res["Response"]
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.

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_token cookie, bucketed by its sub).
  • Otherwise the client IP — X-Real-IP (non-spoofable, set by nginx) when trusted_proxy=True, else request.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 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.

All responses include:

  • X-RateLimit-Limit — maximum requests per window
  • X-RateLimit-Remaining — remaining requests in current window
  • X-RateLimit-Reset — Unix timestamp when the window resets

When rate-limited (429):

  • Retry-After — seconds until the oldest request expires from the window

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).

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.

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

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.

File: app/middleware/audit_log.py

Logs authentication events and data mutations to both stdout (structured logging) and the database (non-blocking, best-effort).

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.

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

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).

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_URL is 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-With and Idempotency-Key.
  • Exposed headers: X-Missing-Rates, X-Truncated.

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*.

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.

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.

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.

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.

File: app/errors.py

Four exception handlers are registered to standardize all error responses:

Custom exception with code, message, and optional detail. Used for business logic errors (e.g., “Account not found”, “Duplicate snapshot”).

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

Caught and returned as 422 with validation error details in the detail field.

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).

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).