Repository navigation
feat: attempt limits on the auth routes, keyed by the browser's real IP (LUI-141) - #8
Conversation
…IP (LUI-141) Login, registration, verification resend and "forgot password" now count attempts per e-mail and per IP in PostgreSQL and answer rate_limited until the window closes, so guessing a password or flooding a mailbox stops being free. The web forwards the browser's IP with a shared secret, so one abuser does not lock everyone out behind the web server's address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RgE8sJLFxENgwV47XrwDGP
There was a problem hiding this comment.
🟡 Changes recommended
ClientIpGuard is not registered or imported in AuthModule, preventing the affected routes from reliably applying rate limits.
1 open finding
What changed in this PR
Adds PostgreSQL-backed per-email and per-IP rate limits to authentication routes, with trusted client-IP forwarding and configuration updates.
Changes:
- Adds configurable counters, 429 responses, migration, and API tests.
- Forwards and canonicalizes browser IPs between web and API.
- Updates authentication services, configuration, documentation, and local environments.
| File | Summary |
|---|---|
docs/lld.md |
Documents rate limits, IP forwarding, and configuration. |
compose.dev.yaml |
Adds the local shared secret. |
apps/web/lib/api/client.ts |
Sends client-IP headers with API requests. |
apps/web/lib/api/client-ip.ts |
Extracts the forwarded browser IP; web-level automated coverage is missing. |
apps/web/e2e/session.spec.ts |
Tests the rate-limit banner. |
apps/web/AGENTS.md |
Documents API client IP behavior. |
apps/api/test/support/setup.ts |
Adjusts default test ceilings. |
apps/api/test/auth-rate-limit.e2e-spec.ts |
Tests rate-limit behavior. |
apps/api/src/modules/auth/sessions.service.ts |
Applies limits to sign-in attempts. |
apps/api/src/modules/auth/registration.service.ts |
Applies limits to registration and resend attempts. |
apps/api/src/modules/auth/password-recovery.service.ts |
Applies limits to password recovery. |
apps/api/src/modules/auth/auth.module.ts |
Uses ClientIpGuard, but does not register or import its provider, blocking correct rate-limit execution. |
apps/api/src/modules/auth/auth.controller.ts |
Reads client IPs on authentication routes. |
apps/api/src/modules/auth/auth-attempts.service.ts |
Builds authentication rate-limit keys. |
apps/api/src/config/rate-limit.config.ts |
Loads rate-limit configuration. |
apps/api/src/config/env.schema.ts |
Validates new environment variables. |
apps/api/src/config/config.module.ts |
Registers configuration providers. |
apps/api/src/config/config.module.int-spec.ts |
Tests configuration validation. |
apps/api/src/config/app.config.ts |
Exposes the internal secret configuration. |
apps/api/src/common/rate-limit/rate-limited.error.ts |
Defines the rate-limit error. |
apps/api/src/common/rate-limit/rate-limit.service.ts |
Coordinates counter updates. |
apps/api/src/common/rate-limit/rate-limit.repository.ts |
Atomically updates PostgreSQL counters. |
apps/api/src/common/rate-limit/rate-limit.module.ts |
Provides rate-limit dependencies. |
apps/api/src/common/client-ip/client-ip.guard.ts |
Validates and selects client IPs. |
apps/api/src/common/client-ip/client-ip.guard.spec.ts |
Tests client-IP selection and logging. |
apps/api/src/common/client-ip/client-ip.decorator.ts |
Exposes the selected IP to controllers. |
apps/api/src/common/client-ip/canonical-ip.ts |
Normalizes IP representations. |
apps/api/src/common/client-ip/canonical-ip.spec.ts |
Tests IP canonicalization. |
apps/api/prisma/schema.prisma |
Adds the rate-limit model. |
apps/api/prisma/migrations/20261009100010_rate_limits/migration.sql |
Creates the counters table. |
apps/api/AGENTS.md |
Documents testing and architectural conventions. |
apps/api/.env.example |
Adds secrets and local rate-limit settings. |
🧠 Review effort: Lite
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
|
|
||
| @Module({ | ||
| imports: [UsersModule, MailModule, AccessTokensModule], | ||
| imports: [UsersModule, MailModule, AccessTokensModule, RateLimitModule], |
There was a problem hiding this comment.
This does not apply, so the module stays as it is.
Nest instantiates a guard referenced by @UseGuards() in the context of the controller's module, without it being listed in providers. The only dependency of ClientIpGuard is the app config, which is global, so it resolves.
The suites show the guard running:
test/auth-rate-limit.e2e-spec.tsboots the wholeAppModuleand passes. The cases with the forwarded IP, with and without the secret, only pass if the guard sets the IP.@ClientIp()throws when the guard has not run, so a guard that Nest could not resolve would turn every call to these four routes into a 500. All 123 e2e tests pass.
AuthGuard is listed in AccessTokensModule because other modules import it from there. The explicit entry for ClientIpGuard existed in an earlier version and was removed as redundant.

Summary
The fifth vertical slice of feature 01 (LUI-141): sign-in, registration, verification resend and "forgot password" now block excess attempts. Whoever goes over the limit sees, on the same screen, a message in Portuguese saying they have to wait. The per-IP limit uses the IP of the person's browser, not the web server's, so one abuser does not lock everyone else out.
What changed
common/rate-limit): counters per key and window in the newrate_limitstable, bumped by a single upsert that runs on the database clock. The window opens on the first attempt and is not stretched by the following ones.rate_limited.common/client-ip):ClientIpGuardtakes the IP fromX-Client-Iponly whenX-Internal-SecretmatchesINTERNAL_API_SECRET; otherwise the connection IP is used. The controller reads it with@ClientIp().INTERNAL_API_SECRET(required, at least 32 characters) and six variables for the ceilings and windows, with the production values as defaults.apps/api/.env.exampleand in thewebservice ofcompose.dev.yaml.docs/lld.md(sections 1, 2, 4.7 and 6) and both projectAGENTS.mdfiles.Decisions worth a look
X-Forwarded-For, which is the one Cloud Run appends. If a load balancer goes in front of the web, the right address is no longer the last one. Without a proxy in front (local), the browser can choose that value..env.example. The person's browser and the Playwright suite leave from the same IP, and the production ceiling would stop the suite halfway. The per-e-mail ceilings stay at the production values.test/support/setup.ts, and each rate-limit test lowers the one it exercises withvi.stubEnv.compose.dev.yaml, next toAPI_URL.Verification
In the containers, on this branch:
pnpm test28 passed (21 new),pnpm test:int57 passed (10 new),pnpm test:e2e123 passed (22 new).pnpm exec tsc --noEmit,pnpm lintand Prettier clean.npx playwright test54 passed (1 new), after the build.pnpm lint,pnpm exec tsc --noEmitandpnpm buildclean. They ran before the last review round, which changed API files only.Acceptance criteria, all covered:
rate_limited, with or without a User (HTTP).rate_limited(HTTP).rate_limited(HTTP).Not exercised:
playwrightcontainer, not thewebone.X-Forwarded-Foraddress" rule has no test at any level; the web has no unit test runner.Review findings
A
/code-reviewpass raised ten findings. Fixed here: the silent fallback when the secret does not match, the window using each instance's clock, and equivalent IPv6 spellings getting separate counters.Left as they are:
X-Forwarded-Foraddress is trusted as is. The alternative is a variable with the number of trusted proxies.rate_limitsonly grows. Cleaning expired counters is a worker task in the LLD, and the worker does not exist yet..envkeeping the per-e-mail ceiling at 5.hashOpaqueToken, and lowercases in JavaScript rather than in the database.From the
/simplifypass, also left: giving each test its own IP instead of raising the per-IP ceilings (it would put.env.exampleback at the production values and cover the forwarding), and forwarding the IP only on the four auth routes.Known gaps
🤖 Generated with Claude Code
https://claude.ai/code/session_01RgE8sJLFxENgwV47XrwDGP