Rate Limit - Per-Operation Request Caps
Per-resource, per-operation rate limiting backed by Cloudflare's native Rate Limiting binding. Defaults apply to every resource automatically; override or opt out per-resource or project-wide.
Per-operation request caps for every CRUD endpoint. Backed by Cloudflare's native Rate Limiting binding — no extra storage, no third-party libraries.
Defaults
Every resource gets these limits automatically (per authenticated user, per minute):
| Operation | Default |
|---|---|
read | 1000 / 60s |
create | 200 / 60s |
update | 200 / 60s |
delete | 200 / 60s |
Numbers are loose enough that no legitimate user-driven traffic hits them, but tight enough to catch a runaway script before it burns the day's D1 budget. Override per-op or opt out wherever the defaults don't fit.
Override one operation
// features/claims/claims.ts
export default defineTable(claims, {
firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
rateLimit: {
delete: { limit: 5, period: 60 }, // tighter than the 200/60s default
},
// read / create / update keep shipped defaults
});Disable rate-limiting on a resource
// features/telemetry/events.ts
export default defineTable(telemetryEvents, {
rateLimit: false, // high-volume sink, skip enforcement
});Project-wide overrides
// quickback.config.ts
export default defineConfig({
// ...
rateLimit: {
read: { limit: 500, period: 60 }, // tighter project-wide read default
},
});Disable globally
// quickback.config.ts
export default defineConfig({
// ...
rateLimit: false, // e.g. when behind a WAF that handles request shaping
});Configuration shape
rateLimit?: {
read?: { limit: number, period: 10 | 60 };
create?: { limit: number, period: 10 | 60 };
update?: { limit: number, period: 10 | 60 };
delete?: { limit: number, period: 10 | 60 };
} | falseperiodmust be10or60(Cloudflare binding constraint — anything else is a compile-time error).limitis a positive integer.- Omit an op to inherit the project default (or shipped default).
- Set
falseat resource or project level to opt out.
Resolution order
- Project
rateLimit: false→ no enforcement anywhere - Resource
rateLimit: false→ no enforcement on this resource - Resource per-op override
- Project per-op default
- Quickback shipped defaults (1000 / 200 above)
Default-using resources collapse onto two shared bindings (RL_1000_60, RL_200_60), so a project that never declares rateLimit still ships exactly two rate-limit bindings — not one per resource.
How requests are keyed
Each handler composes a key as <resource>:<op>:<userId>. For routes that opt into PUBLIC access (no userId), the key falls back to the client's cf-connecting-ip header, then to the literal 'anon' if that header is absent too.
This means:
- A single user is rate-limited per resource per op — heavy
readtraffic onclaimsdoesn't slow theirupdatetraffic onapplications. - One noisy user can't starve another — keys are scoped by
userId. - Anonymous traffic hitting PUBLIC routes is keyed by IP, which works behind a CDN but is trivially bypassed by a botnet — pair PUBLIC routes with auth-required mutations or a Cloudflare WAF rule for hostile traffic.
How limits compile
The compiler walks every resource, collects unique (limit, period) tuples, and emits one Cloudflare binding per tuple to wrangler.toml:
[[unsafe.bindings]]
name = "RL_1000_60"
type = "ratelimit"
namespace_id = "60001000"
simple = { limit = 1000, period = 60 }
[[unsafe.bindings]]
name = "RL_200_60"
type = "ratelimit"
namespace_id = "60000200"
simple = { limit = 200, period = 60 }Each generated CRUD handler invokes c.env.RL_<L>_<P>.limit({ key }) between the auth gate and the access check. Hitting the limit returns:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
{ "error": "Rate limit exceeded", "code": "RATE_LIMITED", "retryAfter": 60 }Batch endpoints
Batch routes (POST /batch, PATCH /batch, DELETE /batch, PUT /batch) count as a single request against their op's limit, not N. A batch of 100 records consumes one token, not one hundred. This matches how most rate-limit configurations are actually written (caps on request volume, not row volume).
Actions
Custom actions (record-based and standalone) inherit rate limits without configuration:
- Record-based actions (e.g.
POST /api/v1/claims/:id/approve) inherit the resource'supdate(write) bucket. A user who hits the write limit onclaimsis also blocked fromapprove. - Standalone actions (e.g.
POST /api/v1/twilioIncoming) inherit the project-defaultupdatebucket — they don't belong to a resource.
Override either with the action's rateLimit field:
// features/claims/actions/approve.ts
export default defineAction({
description: 'Approve a claim',
input: z.object({ claimId: z.string() }),
access: { roles: ['admin'] },
rateLimit: { limit: 5, period: 60 }, // tighter than resource update bucket
execute: async ({ db, input }) => { /* ... */ },
});// features/integrations/actions/twilioIncoming.ts
export default defineAction({
description: 'Webhook from Twilio',
path: '/twilioIncoming',
method: 'POST',
input: z.object({ /* ... */ }),
access: { roles: ['PUBLIC'] },
rateLimit: { limit: 1000, period: 10 }, // tolerate burst from Twilio
execute: async ({ input }) => { /* ... */ },
});Set rateLimit: false to opt the action out of enforcement entirely.
Key composition — actions get their own keyspace so a generic resource bucket and an action's bucket never share counters:
| Action kind | Key shape |
|---|---|
| Record-based | <resource>:action:<actionName>:<userId|cf-connecting-ip|anon> |
| Standalone | standalone:<path>:<userId|cf-connecting-ip|anon> |
The :action: infix is what keeps update (resource bucket) and approve (action bucket) from sharing counters when they happen to land on the same binding name.
Cardinality — how many bindings ship
Cloudflare bindings are sized at compile time (limit + period are baked in), so the compiler emits one binding per unique (limit, period) tuple, not one per resource. A project of 50 resources that all use the defaults still ships exactly two rate-limit bindings.
| Configuration | Bindings emitted |
|---|---|
| All defaults | 2 (RL_1000_60 for reads, RL_200_60 for writes) |
One project-wide read override (read: { limit: 500, period: 60 }) | 3 (RL_500_60, RL_1000_60 ← only if some resource opts back into the default, RL_200_60) |
Five resources tightening delete to { limit: 5, period: 60 } | 3 (defaults + RL_5_60 shared) |
| Ten distinct per-action overrides at unique tuples | 12 (defaults + ten action buckets) |
Practical caps. Cloudflare workers have generous binding limits (hundreds), so this is unlikely to be a real ceiling — but it's the reason every distinct (limit, period) you write on a rateLimit config widens the binding set, even when the same resource declares the same tuple multiple times. Resource-level configs that share a tuple share a binding and simply produce different runtime keys.
Key cardinality is separate. Each binding holds an independent counter per key. A binding called via claims:read:user_123 and applications:read:user_123 consumes two separate counters in the same binding — fan-out cost lives in Cloudflare's distributed counter store, not in your binding count. The user-perceivable limit is "per (resource, op, user)", regardless of how many resources share the binding.
Runtime support
| Runtime | Rate limiting |
|---|---|
cloudflare | Backed by Cloudflare's native Rate Limiting binding |
bun | In-process fixed-window counter (src/lib/rate-limit-memory.ts). Same generated check code, same 429 response — single-instance only |
On Bun, the compiler emits src/lib/rate-limit-memory.ts and wires up a per-binding shim in the server's fetch handler. Each RL_<limit>_<period> shim delegates to a process-memory token bucket, so the same c.env.RL_*.limit({ key }) call site that targets the Cloudflare binding works unchanged.
Multi-instance Bun deployments need either a load balancer with sticky sessions (so the same user keeps hitting the same instance and its bucket) or the Cloudflare runtime (which has cross-instance counters out of the box). The in-memory shim does not coordinate across processes.
Compile-time validation
The compiler errors on:
periodother than10or60- Non-integer or non-positive
limit - Unknown ops (e.g.
listinstead ofread)
Disabling vs. tightening — when to use which
| Goal | Configuration |
|---|---|
One op needs a tighter cap (e.g. delete) | Resource override on that op only |
| One op needs a looser cap (high-volume reads) | Resource override raising the limit |
| One resource is a high-volume sink | rateLimit: false on the resource |
| Whole project is behind a WAF | rateLimit: false at project level |
| App-wide tighter default (e.g. expensive reads) | Project-level override of that op |
Masking - Field Redaction
Hide sensitive data from unauthorized users with automatic field masking. Secure PII and confidential fields while maintaining access for authorized roles.
Encryption
Field-level encryption in two tiers — .encrypted() (envelope, per-org keys, server-readable) and .sealed() (end-to-end, the server structurally cannot read it). A database breach, or a full Worker compromise, yields only ciphertext.