Skip to content

Rate-limit failed authentication attempts per client IP#69

Merged
imonroe merged 1 commit into
mainfrom
claude/wizardly-planck-pwvl93-i66
Jun 9, 2026
Merged

Rate-limit failed authentication attempts per client IP#69
imonroe merged 1 commit into
mainfrom
claude/wizardly-planck-pwvl93-i66

Conversation

@imonroe

@imonroe imonroe commented Jun 9, 2026

Copy link
Copy Markdown
Owner

Closes #66

What

Adds per-IP, fixed-window rate limiting of failed authentication attempts across all four auth surfaces:

Surface Counted failures Default limit
REST /api/v1/... 401 10 / 60s
MCP /mcp 401 10 / 60s
OAuth consent POST /oauth/authorize 401 (wrong API key) 5 / 300s
OAuth token POST /oauth/token 400/401 (guessed codes per RFC 6749) 10 / 60s

An IP over the limit gets 429 + Retry-After on that surface (even with valid credentials) until the window expires. Successful auth never counts. /healthz, /metrics, the .well-known metadata, and /oauth/register are never limited. Surfaces are limited independently.

How

  • New app/ratelimit.py: a ~60-line locked fixed-window counter (no new dependencies), client_ip() honoring the first X-Forwarded-For hop (configurable via TRUST_FORWARDED_FOR for proxy-less deployments), and a single middleware that classifies the surface and counts failed-auth response statuses. The middleware approach is what makes MCP coverage possible — the FastMCP token verifier never sees the request, so per-IP accounting has to happen at the HTTP layer.
  • Registered before log_requests so rate-limited 429s still get a request log line and latency metric.
  • New metrics auth_failures_total{surface} and rate_limited_requests_total{surface} give a brute-force signal independent of the limiter; each 429 and each auth failure also emits a structlog warning with the IP.
  • Limits are configurable via Settings (RATE_LIMIT_*); a *_FAILURES value of 0 disables a surface's limiter. State is in-process and per uvicorn worker by design (effective limit ≈ 2× configured with the default --workers 2) — documented in the user guide.

Tests

31 new tests in tests/test_ratelimit.py: limiter unit behavior (window expiry, key independence, prune cap, disable switch), client_ip trust handling, surface classification, middleware behavior on a stub app for every surface, and end-to-end lockout through the real app (bad bearer tokens → 429, /healthz unaffected). An autouse conftest fixture resets limiter state between tests. Full suite: 198 passed, ruff clean.

Review

Adversarial review (3 finder angles + verification) found no correctness bugs; flagged tradeoffs (shared-NAT lockout, status-code-based failure detection) are deliberate and documented in the guides.

https://claude.ai/code/session_01H2Dbh6kD8bseWZZEf7kGhx


Generated by Claude Code

Adds app/ratelimit.py: a fixed-window, per-IP failure counter applied as
middleware over the four auth surfaces (REST /api/v1, MCP /mcp, OAuth
consent POST /oauth/authorize, /oauth/token). Only failed attempts count;
an over-limit IP gets 429 + Retry-After until the window expires. New
auth_failures_total and rate_limited_requests_total metrics provide a
brute-force signal. Limits and X-Forwarded-For trust are configurable via
Settings; a *_FAILURES value of 0 disables a surface's limiter.

Closes #66

https://claude.ai/code/session_01H2Dbh6kD8bseWZZEf7kGhx
@imonroe imonroe merged commit 0059c93 into main Jun 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rate-limit authentication attempts (REST bearer, MCP, and OAuth endpoints)

2 participants