Rate-Limit Audit + `@nestjs/throttler` Implementation Plan
Rate-Limit Audit + @nestjs/throttler Implementation Plan
Section titled “Rate-Limit Audit + @nestjs/throttler Implementation Plan”Date: 2026-04-21
Status: Analysis done — implementation DEFERRED post-MVP0
Owner: Claude (BE)
Ecosystem rule: this plan picks @nestjs/throttler per our ecosystem-plugin-preference rule — NestJS project → reach for @nestjs/* first.
Why defer
Section titled “Why defer”MVP 0 is pre-revenue, closed alpha. Attack surface:
- Bot-registered Clerk accounts → Clerk handles bot detection (hCaptcha built-in)
- Scraping professional listings → data is public anyway (SEO goal post-MVP0)
- SMS pumping via
/send-otp→ Clerk handles SMS throttle server-side - Brute-force auth → Clerk handles (
verifyTokenrate-limits)
Actual MVP0 risk: none high enough to block launch. Rate-limit becomes load-bearing when:
- Public API endpoints (unauthenticated search, category listing) exposed to web crawlers
- Costs scale on request volume (Mapbox geocode, Novu notification triggers)
- DDoS surface grows with traffic
Plan this now, land post-demo when we have time for the BullMQ + Redis integration testing it deserves.
Scope of audit
Section titled “Scope of audit”Surface-level map — endpoints that WILL need limits
Section titled “Surface-level map — endpoints that WILL need limits”| Endpoint | Current limit | Needed limit | Reason |
|---|---|---|---|
POST /auth/login (proxied Clerk) | Clerk default | OK — Clerk handles | Brute-force, handled upstream |
POST /auth/signup | Clerk default | OK — Clerk handles | Bot signup, handled upstream |
POST /bookings | none | 10/min per user | Spam bookings, trolling pros |
POST /sos/request | none | 3/hour per user | False emergency abuse |
POST /messages | none | 60/min per user | Chat spam, harassment |
POST /reviews | none | 5/day per user | Review bombing |
POST /credentials/me/:id/upload-url | 10/day | ✅ already via Redis | Already implemented — MIGRATE to throttler |
POST /webhooks/clerk, /webhooks/stripe | none | none — webhooks are IP-filtered | svix + Stripe sign, source IP verified |
GET /search, GET /professionals | Redis cache 30s | Cache already buffers; add 60/min per IP | Scraping |
POST /ai/parse-job (LangGraph) | none | 20/hour per user | LLM token cost |
POST /media/presign | none | 20/hour per user | Upload flood → R2 cost |
GET /geocode/* (Mapbox proxy) | none | 30/min per user | Mapbox req cost (100K/mo free) |
Total new policies: ~10 endpoints, tiered by sensitivity.
Tiering — 4 throttle classes
Section titled “Tiering — 4 throttle classes”| Class | Rule | Endpoints |
|---|---|---|
authStrict | 5/min per user | login adjacent, password-reset |
userWrite | 10-20/min per user | booking, review, credential actions |
userRead | 60/min per IP | search, listings |
costly | 20/hour per user | AI parse, upload presign, geocode |
Implementation plan — @nestjs/throttler v7+
Section titled “Implementation plan — @nestjs/throttler v7+”Step 1 — install + bootstrap
Section titled “Step 1 — install + bootstrap”pnpm --filter @ideony/api add @nestjs/throttlerStep 2 — AppModule wiring
Section titled “Step 2 — AppModule wiring”import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';import { APP_GUARD } from '@nestjs/core';
@Module({ imports: [ ThrottlerModule.forRoot([ { name: 'authStrict', ttl: 60_000, limit: 5 }, { name: 'userWrite', ttl: 60_000, limit: 20 }, { name: 'userRead', ttl: 60_000, limit: 60 }, { name: 'costly', ttl: 3_600_000, limit: 20 }, ]), // ... ], providers: [ { provide: APP_GUARD, useClass: ThrottlerGuard }, // ... ],})Step 3 — custom storage (Redis-backed)
Section titled “Step 3 — custom storage (Redis-backed)”Default in-memory storage won’t work behind multi-replica deploy. Use @nest-lab/throttler-storage-redis (Redis-backed official companion).
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
ThrottlerModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService) => ({ throttlers: [ /* classes above */ ], storage: new ThrottlerStorageRedisService(config.get('REDIS_URL')), }),});Step 4 — tracker override (user-ID based, fall back to IP)
Section titled “Step 4 — tracker override (user-ID based, fall back to IP)”Default tracker = IP. We want user-ID tracker post-auth, IP pre-auth.
@Injectable()export class AppThrottlerGuard extends ThrottlerGuard { protected async getTracker(req: FastifyRequest): Promise<string> { const userId = (req as any).user?.id; return userId ?? req.ip; // user when authed, IP for anon }}Wire via APP_GUARD instead of the default ThrottlerGuard.
Step 5 — decorator usage per endpoint
Section titled “Step 5 — decorator usage per endpoint”@Controller('bookings')export class BookingsController { @Post() @Throttle({ userWrite: { limit: 10, ttl: 60_000 } }) async create() { /* ... */ }}
@Controller('sos')export class SosController { @Post('request') @Throttle({ userWrite: { limit: 3, ttl: 3_600_000 } }) async request() { /* ... */ }}Step 6 — skip webhooks + health
Section titled “Step 6 — skip webhooks + health”@Controller('webhooks/clerk')@SkipThrottle()export class ClerkWebhookController { /* ... */ }
@Controller('health')@SkipThrottle()export class HealthController { /* ... */ }Step 7 — custom exception + i18n
Section titled “Step 7 — custom exception + i18n”Default ThrottlerException returns 429 w/ plain message. Wrap to:
- Return
Retry-Afterheader (ms until reset) - Translate message via
nestjs-i18n→t('errors.rate_limited', 'Too many requests. Try again later.') - Log to Sentry with
tag: rate_limit_hit
protected async throwThrottlingException( ctx: ExecutionContext, throttlerLimitDetail: ThrottlerLimitDetail,): Promise<void> { const req = ctx.switchToHttp().getRequest(); const res = ctx.switchToHttp().getResponse(); res.header('Retry-After', Math.ceil(throttlerLimitDetail.timeToExpire / 1000)); throw new ThrottlerException(t('errors.rate_limited'));}Step 8 — migrate existing credential-upload limiter
Section titled “Step 8 — migrate existing credential-upload limiter”CredentialsService currently hand-rolls Redis incr + expire for 10/day limit. Replace with @Throttle({ costly: { limit: 10, ttl: 86_400_000 } }) decorator on the endpoint. Delete the manual implementation in the service — the throttler guard runs before the service, so this is pure cleanup.
Step 9 — tests
Section titled “Step 9 — tests”| Test | Layer | Location |
|---|---|---|
AppThrottlerGuard tracker logic | Unit | apps/api/src/common/guards/app-throttler.guard.spec.ts |
| 429 response shape + Retry-After header | API integ | apps/api/test/integration/rate-limit.spec.ts |
| Per-endpoint limits (booking, SOS, upload) | API integ | Same file |
| Redis storage key isolation (tenant/user) | API integ | Same file |
| Skip list (webhooks, health) | API integ | Same file |
Plus E2E coverage — see docs/specs/2026-04-21-e2e-strategy.md §5 rate-limit row (GAP → closes with this work).
Step 10 — observability
Section titled “Step 10 — observability”Add Sentry breadcrumb on 429:
Sentry.addBreadcrumb({ category: 'rate_limit', message: `429 ${req.method} ${req.url}`, data: { tracker, class: throttlerClass, limit, ttl },});Dokploy Grafana board — add rate_limit_hit counter metric via @willsoto/nestjs-prometheus once monitoring stack lands.
Effort estimate
Section titled “Effort estimate”~0.5 dev day — mostly decorators + tests. Redis storage + config ~2h, test matrix ~2h.
Landing order
Section titled “Landing order”- Post-cofounder-demo (MVP0 + 1-2 weeks)
- After
@nestjs/throttlerv8 check on Context7 (lib evolves fast) - Land in same sprint as webhook + i18n E2E closure (M3 of E2E spec) — all three are API-integ test additions
- ADR:
docs/decisions/00XX-nestjs-throttler-rate-limit.mddocumenting tiering choices
References
Section titled “References”@nestjs/throttlerdocs@nest-lab/throttler-storage-redisdocs/specs/2026-04-21-e2e-strategy.md(§5 rate-limit row)- ADR 0025 (LTS deps policy — throttler v7.x stable confirmed)