Skip to content

rate-limit

Void middleware on a Workers rate-limiting binding. Counts requests per session, user, organization plus IP, IP or a key of your own, and answers 429 past the limit. examples/crm uses it.

Declare a binding per limit in void.config.ts. period is 10 or 60 seconds; namespace_id is any number, unique in your account.

export default defineConfig({
cloudflare: {
ratelimits: [{ name: "API_RATE_LIMIT", namespace_id: "2001", simple: { limit: 60, period: 10 } }],
},
})

Then one middleware file per limit:

middleware/00.rate-limit-api.ts
import { defineMiddleware } from "void"
import { rateLimit } from "@recranet/flare/rate-limit"
export default defineMiddleware(rateLimit({ binding: "API_RATE_LIMIT", key: "session", include: ["/api/*"], exclude: ["/api/auth/*"], retryAfter: 10 }))
Option Default What
binding The binding’s name; read from c.env per request
key "session" What a request is counted by (below)
include every path Paths to limit: exact, or a prefix ending in *
exclude none Paths never limited, even when included
methods every method e.g. ["POST"]
retryAfter 60 Seconds in the Retry-After header
key Counts by Passes without
"session" The Better Auth session cookie a session cookie
"user" c.var.user.id a signed-in user
"organization-ip" The session’s active organization plus cf-connecting-ip an IP
"ip" cf-connecting-ip an IP
(c) => string | null Whatever it returns, e.g. an organization from the path for a public form a key (null)

A request without a key passes; the route answers it (a 401 for an API call without a session). Keys are SHA-256 hashed before they reach the binding, so session tokens and IPs don’t leave the Worker.

Past the limit:

HTTP/1.1 429 Too Many Requests
Retry-After: 10
Content-Type: application/json
{"error":"Too many requests.","code":"RATE_LIMITED"}

The body is access’s { error, code }; RATE_LIMITED is exported for clients that branch on it.

Binding Limit Key Paths
API_RATE_LIMIT 60 per 10 s session /api/* but auth and the live stream
AUTH_RATE_LIMIT 10 per 60 s IP POST sign-in (email, magic link), sign-up, password reset

Better Auth has its own limiter (3 sign-ins per 10 seconds by default), but it keeps counts in the isolate’s memory, so requests spread over isolates each start from zero. The binding counts per Cloudflare location, across isolates. The middleware runs before Better Auth’s handler, so it counts the attempts Better Auth refuses too.

  • No binding (local vite dev, tests that don’t declare it) is no limit. vite build writes the bindings into dist/ssr/wrangler.json, and Wrangler’s test harness enforces them, so the CRM’s tests hit the limit for real.
  • Workers rate limits are per location and eventually consistent: a fixed window per period, so a burst across a window’s end gets through more than the limit. Use them against abuse, not for billing.
  • Void resolves the session before app middleware runs, so user and organization-ip cost no extra lookup. Studio limits before its session lookup to spare the database; under Void the lookup has already happened (cheap with Better Auth’s cookie cache).
  • The module imports nothing from void or the rest of @recranet/flare. It’s a Hono middleware, so it also works in a plain Hono app.