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
- 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.
- 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.
- 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.
- 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.
- Case-insensitive login: the address is looked up normalised (
domain.NormaliseEmail).
- 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
Problem
The authentication throttle protects the wrong thing, in the wrong way.
Reproduced (2026-09-15, local API against a fresh database)
1. One bucket for every auth route.
RateLimitkeys onc.IP()alone (backend/internal/middleware/ratelimit.go).authLimiterStore,backend/cmd/server/main.go) serves/auth/login,/auth/register,/auth/password/forgot,/auth/password/resetand/auth/password/check, at 15 requests per 5 minutes./auth/password/check(debounced) while the user types.AuthScreen.tsxmaps any status other than 409/400 to the generic "registration failed", so the user is not told to wait.2. Login timing reveals registered addresses. Wrong password, 4 attempts each:
LoginUseCase.Executereturns before hashing when the address is unknown or the account is disabled, and runs bcrypt otherwise.{"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/internalorbackend/pkg. Password guessing against one account is limited only per source IP.4. Login is case-sensitive on the address.
GormUserRepository.GetByEmailusesemail = ?, while password reset normalises the address. With the registration defect in #687,Awa.Diallo@E2E.testandawa.diallo@e2e.testare two different accounts at login.Acceptance criteria
/auth/password/checknever consumes the login or sign-up budget. Login, sign-up and password reset each have their own key, prefixed by purpose. The existingPrefixedRateLimitBackendpattern is acceptable.429withRetry-Afterand a JSONcode. The sign-in and sign-up screens tell the user how long to wait, in FR and EN.domain.NormaliseEmail).TestLogin_UnknownAddressCostsAHashComparisonTestLogin_EmailCaseInsensitiveTestAuthRateLimit_PasswordCheckDoesNotConsumeLoginBudgetTestLogin_PerAccountBackoffRelated: #686 (duplicate-address sign-up) and #687 (registration defects).
Definition of Done
go build ./... && go vet ./... && go test ./... -racegreen,npm run build && npx vitest rungreen, with output pasted here.Closesthis issue.