Skip to content

security(auth): one rate-limit bucket for all auth routes locks users out, login timing reveals registered addresses, no per-account throttle #688

Description

@alex-dembele

Problem

The authentication throttle protects the wrong thing, in the wrong way.

  • Real users get locked out: someone typing a password on the sign-up screen can lock themselves out of signing up and signing in.
  • Real attackers are barely slowed: someone trying passwords against one account from many IPs is not slowed at all.
  • Login leaks which addresses have an account: the error message is identical, but the response time is not.

Reproduced (2026-09-15, local API against a fresh database)

1. One bucket for every auth route.

  • RateLimit keys on c.IP() alone (backend/internal/middleware/ratelimit.go).
  • The same Redis store (authLimiterStore, backend/cmd/server/main.go) serves /auth/login, /auth/register, /auth/password/forgot, /auth/password/reset and /auth/password/check, at 15 requests per 5 minutes.
  • The sign-up screen calls /auth/password/check (debounced) while the user types.
16 × POST /auth/password/check → 200 ×15, then 429
POST /auth/login    (valid credentials) → 429 {"error":true,"msg":"Rate limit exceeded"}
POST /auth/register (new address)       → 429
  • The screen hides it: AuthScreen.tsx maps any status other than 409/400 to the generic "registration failed", so the user is not told to wait.
  • Shared networks: behind an office NAT, the whole office shares those 15 requests.

2. Login timing reveals registered addresses. Wrong password, 4 attempts each:

unknown address : 401 in 5.8–7.1 ms
existing address: 401 in 38–58 ms
  • LoginUseCase.Execute returns before hashing when the address is unknown or the account is disabled, and runs bcrypt otherwise.
  • The body is the same {"error":"Authentication failed"} in both cases; the clock is the oracle.

3. No per-account protection. There is no failed-attempt counter, lockout or backoff anywhere in backend/internal or backend/pkg. Password guessing against one account is limited only per source IP.

4. Login is case-sensitive on the address. GormUserRepository.GetByEmail uses email = ?, while password reset normalises the address. With the registration defect in #687, Awa.Diallo@E2E.test and awa.diallo@e2e.test are two different accounts at login.

Acceptance criteria

  1. Separate buckets per purpose: /auth/password/check never consumes the login or sign-up budget. Login, sign-up and password reset each have their own key, prefixed by purpose. The existing PrefixedRateLimitBackend pattern is acceptable.
  2. Readable throttle answers: a throttled auth call answers 429 with Retry-After and a JSON code. The sign-in and sign-up screens tell the user how long to wait, in FR and EN.
  3. Per-account backoff: failed logins for one normalised address are limited across all source IPs. After N failures in a window, further attempts for that address are delayed or refused for a bounded time.
    • The limit must not let an attacker lock the real owner out indefinitely: bounded delay, and a successful password reset clears it.
    • Every failure is audited with the address hashed.
  4. Constant work at login: a login for an unknown or disabled address performs a password-hash comparison of the same cost as for a real account.
    • The test: for unknown and existing addresses, the median response times over 20 attempts differ by less than 10 ms, measured the same way as above.
  5. Case-insensitive login: the address is looked up normalised (domain.NormaliseEmail).
  6. Tests:
    • TestLogin_UnknownAddressCostsAHashComparison
    • TestLogin_EmailCaseInsensitive
    • TestAuthRateLimit_PasswordCheckDoesNotConsumeLoginBudget
    • TestLogin_PerAccountBackoff
    • a frontend test for the 429 message.

Related: #686 (duplicate-address sign-up) and #687 (registration defects).

Definition of Done

  • go build ./... && go vet ./... && go test ./... -race green, npm run build && npx vitest run green, with output pasted here.
  • Reproductions 1, 2 and 4 re-run after the fix, with output pasted here.
  • Progress comment in the CLAUDE.md format; PR with Closes this issue.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions