Rate Limiting
Rate Limiting
Warpkit includes an IP-based, in-memory rate limiter for server functions and API routes. It is disabled outside production: dev and test run unthrottled.
Usage in a server function
import { ERROR_CODES } from '@/lib/constants';
import { createRateLimiter } from '@/server/rate-limit';
const limiter = createRateLimiter({ windowMs: 60_000, max: 20 });
export const myFn = createServerFn({ method: 'POST' }).handler(async () => {
if (limiter.check())
return err(ERROR_CODES.RATE_LIMITED, 'Too many requests');
// ...
});
Create one limiter per endpoint: each has its own store.
Usage in a server route
import { getClientIP } from '@/server/rate-limit';
const ip = getClientIP({
'cf-connecting-ip': request.headers.get('cf-connecting-ip') ?? undefined,
'x-real-ip': request.headers.get('x-real-ip') ?? undefined,
'x-forwarded-for': request.headers.get('x-forwarded-for') ?? undefined,
});
if (limiter.check(ip ?? undefined)) {
return Response.json({ error: 'Too many requests' }, { status: 429 });
}
IP detection: TRUST_PROXY
getClientIP does NOT walk a priority chain. It reads exactly ONE header,
selected by the TRUST_PROXY env var, because trusting a header your proxy
does not actually set lets clients spoof their IP and bypass rate limits:
TRUST_PROXY | Header trusted |
|---|---|
cloudflare | cf-connecting-ip |
nginx | x-real-ip |
proxy | x-forwarded-for (first IP) |
none (default) | no header, every request keys to 'unknown' |
The default is none, not cloudflare, deliberately, since most of
this template's own deploy recipes (Docker, Fly.io, Railway) don't put
Cloudflare in front by default. Set it to match your actual deployment
topology (see .env.example's TRUST_PROXY section for the full spoofing
warning), if your app is proxied through Cloudflare, set TRUST_PROXY=cloudflare
or every request collapses onto the same 'unknown' key.
failClosedOnUnknownIp
const limiter = createRateLimiter({
windowMs: 60_000,
max: 20,
failClosedOnUnknownIp: true
});
When true, a request with no resolvable client IP (getClientIP() returns
null, no explicit key passed) is blocked outright instead of falling
into the shared 'unknown' bucket. Without this, a TRUST_PROXY
misconfiguration (or the none default) collapses every caller into one
global budget, fine for a low-stakes limiter, dangerous for anything
auth-adjacent, where one attacker exhausting the shared bucket locks out
every real user. Used today by email-validation.mutations.ts and
admin.mutations.ts.
This flag lives on createRateLimiter (in-memory) only. The DB-backed
shared-rate-limiter.ts below doesn't have it, code using that limiter
implements the same fail-closed check manually at the call site (see
login-otp.ts/signup-otp.ts below), gated to production only so it
doesn't also block local dev, where TRUST_PROXY is unset by default and
getClientIP() is always null.
Config options
| Option | Type | Description |
|---|---|---|
windowMs | number | Window duration in milliseconds |
max | number | Max requests allowed per window |
Memory management
The store auto-cleans expired entries every 5 minutes. There is no persistent storage: limits reset on server restart.
Per-user DB-backed rate limiting
For authenticated mutations, use checkUserRateLimit from @/lib/db/user-rate-limiter. This counts user_events rows for a given event type within a sliding window against the shared Postgres database, scoped by user_id, so limits survive server restarts.
import { checkUserRateLimit } from '@/lib/db/user-rate-limiter';
import { logUserEvent } from '@/lib/db/user-events';
import { withUserTransaction } from '@/lib/db/user-db';
export const createItem = createServerFn({ method: 'POST' }).handler(async () => {
const user = await requireUser();
if (!user) return err(ERROR_CODES.UNAUTHORIZED, 'Not authenticated');
return withUserTransaction(user.id, async (tx, userId) => {
if (await checkUserRateLimit(tx, userId, 'item.created', { windowMs: 60_000, max: 20 }))
return err(ERROR_CODES.RATE_LIMITED, 'Too many requests');
// ... write logic ...
await logUserEvent(tx, userId, 'item.created', { id });
return ok({ id });
});
});
Both checkUserRateLimit and logUserEvent must be called inside withUserTransaction, using the tx handle. checkUserRateLimit reads event count and is a no-op outside NODE_ENV=production (so local dev/test never throttles). logUserEvent always runs regardless of NODE_ENV, it's the audit trail future checks (and future rate-limit windows) depend on, not just a rate-limit input.
When to use which limiter:
| Scenario | Limiter |
|---|---|
| Unauthenticated endpoint, single-worker-safe (public API, lead capture) | createRateLimiter (in-memory, IP-based) |
Unauthenticated endpoint that must hold across cluster.ts worker processes (auth-adjacent: OTP send, magic-link) | shared-rate-limiter.ts (DB-backed, IP-based) |
| Authenticated mutation by signed-in user | checkUserRateLimit (per-user DB) |
| Both (defense in depth) | Use both: IP gate before lock, user gate inside lock |
Cross-process IP-based limiting: shared-rate-limiter.ts
createRateLimiter's in-memory Map only tracks state within a single
cluster.ts worker process, if the app runs availableParallelism()
worker processes (the default production entrypoint, see
docs/warpkit/features/jobs.md), an attacker round-robins
across workers behind the same reverse proxy and gets ~N× the intended
budget before any single worker's counter trips. For endpoints where the
rate limit is a load-bearing defense, not just noise reduction, that gap
matters. src/lib/db/shared-rate-limiter.ts fixes it by backing the
counter with the shared meta DB (rate_limit_events table) instead of a
process-local Map:
import {
checkSharedRateLimit,
recordSharedRateLimitEvent,
isActive
} from '@/lib/db/shared-rate-limiter';
import { getClientIP } from '@/server/rate-limit';
const RATE_LIMIT_CONFIG = { windowMs: 60 * 1000, max: 3 };
export const myFn = createServerFn({ method: 'POST' }).handler(async () => {
const ip = getClientIP();
// Fail closed in production only - see failClosedOnUnknownIp above for
// why this must not run in dev, where getClientIP() is always null.
if (!ip && isActive()) {
return err(ERROR_CODES.RATE_LIMITED, 'Too many requests. Please try again later.');
}
const key = ip ?? 'unknown';
if (await checkSharedRateLimit(key, RATE_LIMIT_CONFIG)) {
return err(ERROR_CODES.RATE_LIMITED, 'Too many requests. Please try again later.');
}
await recordSharedRateLimitEvent(key);
// ... handler logic ...
});
checkSharedRateLimit/recordSharedRateLimitEvent/isActive are the same
production-only-gated shape as createRateLimiter and checkUserRateLimit
, no-op outside NODE_ENV=production. Unlike createRateLimiter, this
limiter has no failClosedOnUnknownIp option built in; callers implement
the fail-closed check themselves at the top of the handler (as above) so
they control the exact error returned.
Real usage: src/server/functions/login-otp.ts and signup-otp.ts.
Both wrap auth.api.sendVerificationOTP directly instead of going through
authClient's real HTTP endpoint, which means better-auth's own router-level
rateLimit.customRules (configured in src/server/auth.ts) never fires for
these calls, auth.api.* bypasses the router entirely. The DB-backed
limiter here is what actually protects the OTP-send endpoint; it isn't
redundant with the customRules config in auth.ts, despite both targeting
the same nominal path.
Two-tier pattern (IP then user)
The most common shape in this codebase, used at 10+ call sites (account.mutations.ts, account.queries.ts, admin.mutations.ts, etc.): an IP-based limiter gates before requireUser() runs, so an unauthenticated caller can't force a session lookup, and a second per-user limiter gates after requireUser() succeeds, so users behind a shared NAT/proxy don't share one IP-keyed budget:
const ipLimiter = createRateLimiter({ windowMs: 60_000, max: 5 });
const userLimiter = createRateLimiter({ windowMs: 60_000, max: 5 });
export const myFn = createServerFn({ method: 'POST' }).handler(async () => {
if (ipLimiter.check()) return err(ERROR_CODES.RATE_LIMITED, 'Too many requests');
const user = await requireUser();
if (!user) return err(ERROR_CODES.UNAUTHORIZED, 'Not authenticated');
if (userLimiter.check(user.id))
return err(ERROR_CODES.RATE_LIMITED, 'Too many requests');
// ...
});
Both limiters here are the IP-based createRateLimiter, just keyed differently (no key vs. user.id), this is distinct from combining createRateLimiter with checkUserRateLimit, which is its own defense-in-depth combination (see table above).
See also: Gate ordering for correct placement of rate-limit checks relative to withUserTransaction.