After LOGIN_MAX_FAILURES consecutive failed attempts from the same source IP within LOGIN_WINDOW_SECONDS, POST /api/v1/auth/token returns HTTP 429 with a Retry-After header for LOGIN_COOLDOWN_SECONDS. A successful login resets the counter. Trusted upstream proxy IPs/CIDRs can be declared via LOGIN_TRUSTED_PROXY_IPS so X-Forwarded-For is honoured correctly behind nginx ingress or similar reverse proxies. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4.2 KiB
Research: Login Brute-Force Protection
Decision 1: Library vs. custom implementation
Decision: Custom in-memory failure tracker (no new library dependency)
Rationale: The requirement is to count failed login attempts specifically and reset on success — not to rate-limit all requests regardless of outcome. Popular libraries like slowapi count all requests to a route, which would break FR-004 (reset on success) without significant workarounds. A purpose-built 60-line class is simpler, more auditable, and has no dependency footprint.
Alternatives considered:
slowapi(built onlimits): Counts all requests, not failures. Requires patching the exception handler to decrement on success — fragile and non-obvious.slowapiwith a custom key function: Could be done, but the library's storage model doesn't expose a "reset this key" API in a clean way.- Redis-backed counter: Overkill for a single-user personal app with one instance. No new infrastructure justified.
Decision 2: Fixed window vs. sliding window
Decision: Fixed window with per-source reset on successful login
Rationale: Fixed window is simpler to implement correctly and sufficient for this use case. The main attack — rapid sequential guessing — is fully addressed. The known "burst at window boundary" weakness is irrelevant here because: (a) the cooldown period is separate from the counting window, and (b) a successful login resets the counter entirely.
Alternatives considered:
- Sliding window: More accurate, but adds complexity (requires storing timestamps of each request). The marginal security benefit doesn't justify the implementation cost for a personal single-user app.
Decision 3: In-memory backing store
Decision: Python dict keyed by source IP, protected by a threading Lock
Rationale: The application runs as a single process. In-memory storage means counters reset on restart — this is acceptable and matches the "fail open" assumption in the spec. No new infrastructure (Redis, database table) is required.
Alternatives considered:
- Database-backed counters: Persistent across restarts, but adds a DB round-trip to every login request (including successful ones). Not justified.
- Redis: Distributed-safe and persistent, but requires a new service dependency. Out of scope for a personal single-instance app.
Decision 4: Source identifier
Decision: request.client.host (the TCP peer address)
Rationale: The spec explicitly states not to trust X-Forwarded-For headers unless the app is known to be behind a trusted proxy. request.client.host in Starlette/FastAPI is the actual TCP peer IP — it cannot be spoofed by an attacker sending arbitrary headers.
Alternatives considered:
X-Forwarded-Forfirst value: Spoofable if the app is not behind a trusted proxy (attacker can set arbitrary header values).X-Real-IP: Same spoofing concern.
Decision 5: 429 response and Retry-After header
Decision: Return HTTP 429 with {"detail": "...", "code": "login_rate_limited"} and a Retry-After header set to the configured cooldown duration in seconds
Rationale: HTTP 429 is the standard "Too Many Requests" status. The Retry-After header is explicitly mentioned in the spec (US2 acceptance scenario) and is required by RFC 6585 for rate-limit responses. Setting it to the configured cooldown (not the exact remaining time) satisfies FR-005: it doesn't reveal precise expiry, just the maximum wait. The response body follows §3.3 of the constitution (error envelope with detail and code).
Decision 6: Default threshold values
Decision: LOGIN_MAX_FAILURES=5, LOGIN_WINDOW_SECONDS=300 (5 min), LOGIN_COOLDOWN_SECONDS=900 (15 min)
Rationale: Industry standard for web apps. 5 attempts is enough for legitimate typos but makes brute-force infeasible at human scale. A 5-minute counting window matches typical "I fat-fingered my password" retry patterns. A 15-minute cooldown is a meaningful deterrent without locking out a legitimate owner indefinitely.
Alternatives considered:
- 3 failures / 60 s window / 300 s cooldown: More aggressive, but too likely to lock out the legitimate owner on a bad day.
- 10 failures: Too permissive for a brute-force defense.