Skip to content

feat(auth): rate limit login attempts - #499

Open
rignaneseleo wants to merge 1 commit into
useplunk:nextfrom
rignaneseleo:feat/login-rate-limit
Open

rignaneseleo wants to merge 1 commit into
useplunk:nextfrom
rignaneseleo:feat/login-rate-limit

Conversation

@rignaneseleo

@rignaneseleo rignaneseleo commented Sep 27, 2026 •

Copy link
Copy Markdown

Description

POST /auth/login currently has no limit on password guesses. This adds a failed-attempt limiter using the same Redis counter pattern as request-verification, request-password-reset and the password-change endpoint, and reuses RateLimitError.

Behaviour

  • Limits are checked before the user lookup and before verifyCredentials, so an unknown email and a wrong password are counted the same way and get the same response (Incorrect email or password, or 429 once limited). No user enumeration through the limiter.
  • Once a counter reaches its limit, further attempts get a 429 (RATE_LIMIT_EXCEEDED) with a Retry-After header set to the remaining window. The web login page already shows the error message from a non-2xx response.
  • Per email (default 5 failures / 15 min): incremented atomically before verification (MULTI of SET NX EX + INCR), so a burst of concurrent guesses can't all pass a read-then-increment check while bcrypt runs. A successful login clears it. The key is lowercased to match the case-insensitive user lookup, so casing can't be used to get a fresh counter.
  • Per IP (default disabled): counts failures only, across all emails. It uses the client IP the codebase already logs (req.ip || req.socket.remoteAddress). The app doesn't set Express trust proxy, so behind a reverse proxy (including the nginx in the Docker image) req.ip is the proxy's address. Enabled by default, this would become one shared counter for every user of the instance, and anyone could lock out all password logins with a handful of bad attempts. So it ships off, and the docs say to enable it only when clients connect to the API directly. A configurable trust proxy setting would make it usable on proxied installs, but that changes req.ip app-wide and is better as a separate change. Because it's only bumped after verification, concurrent requests can overshoot it slightly; the per-email counter still bounds guesses per account exactly.
  • The window is fixed from the first attempt (SET NX EX), so continued attempts while limited don't extend the lockout.

Configuration (0 disables a counter; invalid values fail at startup, since a zero or non-numeric window would create counters that never expire). Documented in .env.self-host.example, apps/api/.env.example and the self-hosting environment variables page:

Variable Default
LOGIN_RATE_LIMIT_PER_EMAIL 5
LOGIN_RATE_LIMIT_PER_IP 0 (disabled)
LOGIN_RATE_LIMIT_WINDOW 900 seconds

Tradeoff to be aware of: like any per-account limit, the per-email counter lets someone who knows an address block that account's password login for one window. OAuth login and password reset are unaffected.

Type of Change

  • feat: New feature (MINOR version bump)
  • fix: Bug fix (PATCH version bump)
  • feat!: Breaking change - new feature (MAJOR version bump)
  • fix!: Breaking change - bug fix (MAJOR version bump)
  • docs: Documentation update (no version bump)
  • chore: Maintenance/dependencies (no version bump)
  • refactor: Code refactoring (no version bump)
  • test: Adding tests (no version bump)
  • perf: Performance improvement (PATCH version bump)

PR Title Format

feat(auth): rate limit login attempts

Testing

New apps/api/src/controllers/__tests__/Auth.login.test.ts, run against the worker's real Postgres and Redis like the existing controller and rate-limit tests:

  • under the limit: 4 failures, then the correct password still logs in
  • over the limit: after 5 failures the correct password gets a 429 with Retry-After, including with different email casing
  • an unknown email is limited exactly like a wrong password
  • 15 concurrent wrong guesses: exactly 5 are evaluated, 10 get a 429
  • the window is fixed: further attempts don't extend the key's TTL
  • reset on success: 4 failures, a success, then a full 5 more failures before the 429
  • per-IP: 20 failures across different emails blocks that IP, and another IP is unaffected
  • successful logins don't count against the IP

The limit tests fail without the controller change. The full test suite, yarn lint (no new warnings) and tsc --noEmit for apps/api pass locally.

Checklist

  • PR title follows conventional commits format
  • Code builds successfully
  • Tests pass locally
  • Documentation updated (if needed)

Related Issues

None.

Count failed password logins on POST /auth/login in Redis over a fixed
window and return 429 (RateLimitError, with Retry-After) once a counter
reaches its limit. Limits are checked before the user lookup, so an
unknown email and a wrong password are counted and answered the same way.

- Per email (default 5 per 15 min): incremented atomically before
  verification so concurrent guesses cannot all pass the check, cleared
  on successful login. Keyed case-insensitively to match the user lookup.
- Per IP (default disabled): incremented on failure only. It keys on
  req.ip, and Express is not configured to trust proxy headers, so on
  proxied installs (including the Docker image's bundled nginx) it would
  be one counter shared by every user.

Configurable via LOGIN_RATE_LIMIT_PER_EMAIL, LOGIN_RATE_LIMIT_PER_IP and
LOGIN_RATE_LIMIT_WINDOW; 0 disables a counter. Invalid values fail at
startup, since a zero or non-numeric window would create counters that
never expire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant