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_PROXYHeader trusted
cloudflarecf-connecting-ip
nginxx-real-ip
proxyx-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

OptionTypeDescription
windowMsnumberWindow duration in milliseconds
maxnumberMax 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 and is backed by per-user SQLite, so limits survive server restarts.

import { checkUserRateLimit } from '@/lib/db/user-rate-limiter';
import { logUserEvent } from '@/lib/db/user-events';
import { getUserDb, withWriteLock } 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 withWriteLock(user.id, () => {
    const db = getUserDb(user.id);
    if (checkUserRateLimit(db, 'item.created', { windowMs: 60_000, max: 20 }))
      return err(ERROR_CODES.RATE_LIMITED, 'Too many requests');

    // ... write logic ...
    logUserEvent(db, 'item.created');
    return ok({ id });
  });
});

Both checkUserRateLimit and logUserEvent must be called inside withWriteLock. 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:

ScenarioLimiter
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 usercheckUserRateLimit (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/background-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 withWriteLock.