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.
Set up
Section titled “Set up”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:
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 }))Options
Section titled “Options”| 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 RequestsRetry-After: 10Content-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.
In the CRM
Section titled “In the CRM”| 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 buildwrites the bindings intodist/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
userandorganization-ipcost 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
voidor the rest of@recranet/flare. It’s a Hono middleware, so it also works in a plain Hono app.