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
Connections
Builds on
Context
Section titled “Context”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)
Options considered
Section titled “Options considered”Where it runs
Section titled “Where it runs”1. Hono middleware (hono-rate-limiter)
Section titled “1. Hono middleware (hono-rate-limiter)”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
What backs it
Section titled “What backs it”A. Process memory
Section titled “A. Process memory”Pros: zero infrastructure, fastest. Cons: per process — counters reset on every deploy and split once a second replica exists.
B. Postgres
Section titled “B. Postgres”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.
C. Valkey
Section titled “C. Valkey”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
Decision
Section titled “Decision”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/HEADrequest (server-function writes, form posts), every/api/*request regardless of method, and every auth endpoint. Page loads andGETserver 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-suppliedX-Forwarded-Forchain — 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 keyAPI key per product contract -
Valkey: official
valkey/valkeyimage, pinned major (9.x), as a sibling container like Postgres. It also serves as Better Auth’s session store (ADR-0009). AOF persistence andmaxmemory-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
ioredis5.x connection for both the limiter and Better Auth’s@better-auth/redis-storage(which requiresioredis^5). Pure JS. Bun’s built-in Redis client is not an option here:rate-limiter-flexibleand@better-auth/redis-storageaccept only the clients they list, and one client for Valkey beats two (ADR-0035) -
Failure mode: if Valkey is unreachable, the in-process
insuranceLimitertakes over — limits stay on, per process, until Valkey returns. Session lookups fall back to Postgres -
Response:
429 Too Many Requestswith aRetry-Afterheader (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.
Consequences
Section titled “Consequences”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.tsfor the limiter turns off Start’s automatic CSRF middleware for server functions; registercreateCsrfMiddleware()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).

