Keel

Docs / Developer tools / Rate limiting

Rate limiting

Rate limiting is opt-in, backed by Upstash Redis in lib/rate-limit.ts. It is not required to run Keel locally or even in a first production deploy, and it deliberately covers only Keel's own expensive routes, Better Auth already rate-limits its /api/auth/* endpoints out of the box once NODE_ENV=production.

Degrades to a no-op without config

Without UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN, getRatelimiter returns null and callers skip limiting rather than block:

ts
const limiter = getRatelimiter("ai-chat", "1 m", 20);
if (limiter) {
  const { success } = await limiter.limit(access.userId);
  if (!success) {
    return Response.json({ error: "Too many requests, slow down and try again shortly." }, { status: 429 });
  }
}

A missing limiter must skip, never block

The if (limiter) guard is load-bearing. Because getRatelimiter returns null when Upstash isn't configured, calling .limit() unconditionally would throw on every request in a fresh clone. Always gate on the returned value, the same "skip instead of break" convention lib/email.ts and lib/ai/provider.ts follow for their optional dependencies.

Independent buckets by prefix

getRatelimiter(prefix, window, max) caches one Ratelimit per prefix:window:max combination and namespaces it under keel:<prefix> in Redis. The prefix keeps callers in separate buckets even when they limit the same identifier, so the same team hitting two different expensive routes gets an independent allowance on each.

What's limited, and why there

The one wired call site is the AI chat route (app/api/teams/[teamId]/ai/chat), limited to 20 requests per minute per user. That is the endpoint that costs real provider money per request and isn't covered by Better Auth's limiter. It sits on top of the credit system as defense in depth: credits already stop sustained abuse (a fresh team starts at a zero balance), but nothing otherwise stopped a single funded team from firing a scripted burst as fast as the client allows.

To limit another route, call getRatelimiter with a fresh prefix, pick an identifier to key on (user id, team id, or IP), and apply the same if (limiter) guard.

Edit this page on GitHub →Last updated Jul 22, 2026