Files
reactbin/specs/009-login-rate-limiting/quickstart.md
agatha 7a835d3172 Feat: Rate-limit login endpoint to block brute-force attacks
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>
2026-05-06 21:01:37 +00:00

2.6 KiB

Quickstart: Login Brute-Force Protection

Prerequisites

  • API running (via docker compose up or locally with .env set)
  • curl available

Scenario 1: Trigger the rate limiter

Send 6 consecutive failed login attempts (default threshold is 5):

for i in $(seq 1 6); do
  echo "Attempt $i:"
  curl -s -o /dev/null -w "%{http_code}\n" \
    -X POST http://localhost:8000/api/v1/auth/token \
    -H "Content-Type: application/json" \
    -d '{"username": "wrong", "password": "wrong"}'
done

Expected output:

Attempt 1: 401
Attempt 2: 401
Attempt 3: 401
Attempt 4: 401
Attempt 5: 401
Attempt 6: 429

The 6th attempt returns 429. Inspect the headers:

curl -i -X POST http://localhost:8000/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{"username": "wrong", "password": "wrong"}'

Expected headers include:

HTTP/1.1 429 Too Many Requests
Retry-After: 900

Expected body:

{"detail": "Too many failed login attempts. Please try again later.", "code": "login_rate_limited"}

Scenario 2: Successful login resets the counter

Make some failed attempts, then log in with valid credentials:

# Fail twice
for i in 1 2; do
  curl -s -o /dev/null -w "fail $i: %{http_code}\n" \
    -X POST http://localhost:8000/api/v1/auth/token \
    -H "Content-Type: application/json" \
    -d '{"username": "wrong", "password": "wrong"}'
done

# Succeed — resets counter
curl -s -o /dev/null -w "success: %{http_code}\n" \
  -X POST http://localhost:8000/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{"username": "'"$OWNER_USERNAME"'", "password": "'"$OWNER_PASSWORD"'"}'

# Now fail 5 more times — counter was reset, so no 429 yet
for i in $(seq 1 5); do
  curl -s -o /dev/null -w "fail after reset $i: %{http_code}\n" \
    -X POST http://localhost:8000/api/v1/auth/token \
    -H "Content-Type: application/json" \
    -d '{"username": "wrong", "password": "wrong"}'
done

Expected: all "fail after reset" lines return 401 (not 429), confirming the counter was reset.


Scenario 3: Observe log output

While triggering the rate limiter (Scenario 1), watch API logs:

docker compose logs -f api

After the threshold is crossed you should see a line like:

WARNING  app.auth.rate_limiter:rate_limiter.py:NN Login blocked for 172.18.0.1 after 5 failures

Environment variable overrides

To test with a lower threshold without code changes:

LOGIN_MAX_FAILURES=2 LOGIN_WINDOW_SECONDS=60 LOGIN_COOLDOWN_SECONDS=30 \
  uvicorn app.main:app --reload

Then only 2 failures trigger the lockout, and it clears after 30 seconds.