Skip to content

ADR-0010 Rate limiting

Company standard app-level rate limiting — rate-limiter-flexible in Start's global request middleware, counters in Valkey.

Decision
rate-limiter-flexible + ValkeyStore & protect
Status
Accepted
Date
Scope
Company-wide default for all new apps

Apps need protection from brute force (sign-in, password reset), abuse of write endpoints, and API clients exceeding their share. Identity is settled — Better Auth (ADR-0009) — so limits can key on users and API keys, not just IPs.

Forces at play:

  • Entry points: SSR, server functions, and the server routes at /api/* (ADR-0036), all in one TanStack Start server (ADR-0002). Start’s global request middleware runs before all of them
  • Topology: one box by default (ADR-0003), but rolling deploys briefly run two processes, restarts wipe process memory, and a product may scale out
  • Better Auth ships its own limiter for /api/auth/*, in memory by default — a second limiter with its own config and store if we keep it
  • What an app limiter can’t do: stop a flood. The server still accepts every connection and runs middleware before rejecting it. Flood protection belongs in front of the app — the reverse proxy (ADR-0017)

Pros: idiomatic for the API layer; small. Cons: Hono never sees server functions or SSR — a server-function login form would be unprotected.

2. Each library’s own limiter (Better Auth built-in + Hono + …)

Section titled “2. Each library’s own limiter (Better Auth built-in + Hono + …)”

Pros: endpoint-specific defaults for free. Cons: several configs, several stores, several 429 formats; nobody owns the whole picture.

3. One limiter in Start’s global request middleware (rate-limiter-flexible)

Section titled “3. One limiter in Start’s global request middleware (rate-limiter-flexible)”

Pros

  • One choke point for every entry point: SSR, server functions, server routes, /api/*
  • rate-limiter-flexible: framework-agnostic, zero dependencies, ISC, actively maintained; stores for memory, Postgres, Drizzle, and Valkey
  • Memory fallback when the store is down (insuranceLimiter); keys already over their limit are blocked in memory without touching the store (inMemoryBlockOnConsumed)

Cons

  • We maintain the auth endpoint rules Better Auth would otherwise provide

Pros: zero infrastructure, fastest. Cons: per process — counters reset on every deploy and split once a second replica exists.

Pros: no new infrastructure; shared and durable. Cons: a write per limited request on our system of record; contention under attack lands on the database the app needs most.

Pros

  • Built for this: atomic in-memory counters with TTLs, microsecond-scale
  • Keeps rate-limit write load off Postgres (ADR-0007)
  • BSD-licensed Redis fork under the Linux Foundation; speaks the Redis protocol, so Redis clients work
  • Available to later ADRs (cache, queues, Better Auth secondary storage) once it exists

Cons

  • A new service per environment to run and monitor

rate-limiter-flexible in Start’s global request middleware, with counters in Valkey, is the company-standard rate limiting for all new apps.

Concretely:

  • One limiter for everything. Better Auth’s built-in limiter is turned off (rateLimit: { enabled: false }); our middleware covers /api/auth/* too

  • Scope: every non-GET/HEAD request (server-function writes, form posts), every /api/* request regardless of method, and every auth endpoint. Page loads and GET server functions are not limited in the app — floods are the proxy’s job

  • Keys: user ID when there is a session, API key for API clients, client IP otherwise. IPv6 is keyed per /64. The client IP comes only from the one trusted header the edge guarantees (CLIENT_IP_HEADER: X-Real-Ip, set by Traefik from the TCP connection — ADR-0017) — never a client-supplied X-Forwarded-For chain — and the app port is never reachable except through the edge

  • Credential endpoints (sign-in, sign-up, password reset, 2FA verification, magic-link and verification-email sends) get strict limits, keyed per IP and per normalized email so credential stuffing across many IPs still hits a wall. The path list lives in one config next to the auth setup and is reviewed on every Better Auth upgrade

  • Starting limits (tune per product):

    Scope Key Limit
    Credential endpoints IP 3 / 10 s
    Credential endpoints email 10 / 15 min
    Writes and /api/* IP 100 / 60 s
    Writes and /api/* user 300 / 60 s
    /api/* with an API key API key per product contract
  • Valkey: official valkey/valkey image, pinned major (9.x), as a sibling container like Postgres. It also serves as Better Auth’s session store (ADR-0009). AOF persistence and maxmemory-policy noeviction, because job queues live here too (ADR-0028); counters stay disposable, and sessions keep their durable copy in Postgres. (Amended 2026-11-12: this ADR first ran Valkey without persistence.) Password-protected and reachable only on the internal Docker network — it holds live session tokens

  • Client: one shared ioredis 5.x connection for both the limiter and Better Auth’s @better-auth/redis-storage (which requires ioredis ^5). Pure JS. Bun’s built-in Redis client is not an option here: rate-limiter-flexible and @better-auth/redis-storage accept only the clients they list, and one client for Valkey beats two (ADR-0035)

  • Failure mode: if Valkey is unreachable, the in-process insuranceLimiter takes over — limits stay on, per process, until Valkey returns. Session lookups fall back to Postgres

  • Response: 429 Too Many Requests with a Retry-After header (seconds), same shape on every entry point

The deciding factors: one choke point and one config for every entry point, counters that survive restarts and stay correct across replicas, and rate-limit write load kept off Postgres. We accept running Valkey and owning the auth endpoint rules in exchange.

Easier:

  • One place to read, tune, and test every limit in an app
  • Correct across deploys and replicas from day one — scaling out needs no limiter rework
  • Valkey is already running when later ADRs (cache, queues) want it; sessions already use it (ADR-0009)

Harder:

  • Valkey is one more container per environment: image upgrades, health checks, memory sizing
  • We own the credential-endpoint rules; a Better Auth upgrade that adds or renames auth endpoints must update the list, or the new endpoint falls back to the generic write limit
  • Reading the request body to key per email means cloning the request on credential endpoints — keep it to those paths only
  • Defining src/start.ts for the limiter turns off Start’s automatic CSRF middleware for server functions; register createCsrfMiddleware() there too (ADR-0013)
  • Correct client IPs depend on the edge (ADR-0017): Traefik must stay the first hop with no trusted forwarders configured, or the client-IP header can be forged

Given up: Better Auth’s maintained per-endpoint defaults, and zero-infrastructure in-memory counters. Accepted — one limiter with shared state beats two partial ones, and Valkey earns its container. Flood protection is explicitly out of scope here; the reverse proxy handles what a single host can (ADR-0017).