Web
Rate limiting
requireRateLimit caps how often a caller can hit a route, counted per scope
and key, and locks the key out once the cap is reached. Auth, invitations,
checkout, the contact page and staff support actions already use it. Add it to
any route that checks a credential or costs money per call.
How it works
Each call to requireRateLimit passes two layers, and a third can sit on your
Cloudflare zone:
- Cloudflare's rate-limiting binding,
AUTH_RATE_LIMITunless you name another, counts the request twice: once by scope and key, once by scope and source IP. It sheds floods before any storage is touched. Its limit is set inwrangler.jsonc(Licensees only) and counted per Cloudflare location. - A Durable Object per scope and key,
AuthThrottle(Licensees only), reserves the attempt atomically against the policy you pass. This is the real limit. - WAF rate-limiting rules on the zone stop mass reloads before the Worker runs. You set those in the Cloudflare dashboard.
Limit a route
A report route that allows each account 60 calls a minute:
export default defineEventHandler(async (event) => {
const session = await requireVerifiedSession(event)
await requireRateLimit(event, {
scope: 'report',
key: String(session.user.id),
policy: { max: 60, windowMs: 60_000, lockoutMs: 60_000 },
})
return buildReport(event, session.user.id)
})
requireRateLimit lives in
server/utils/rate-limit.ts(Licensees only) and is
auto-imported. Its options:
scopenames the counter, so two scopes never spend each other's budget.keyis who is limited: a user id, a lowercased email, orrequestIp(event).policy.maxattempts are allowed perpolicy.windowMs. Themax-th still goes through and locks the key. The next is refused untillockoutMshas passed, or until the window ends if that is later.bindingpicks the per-location limiter,'AUTH_RATE_LIMIT'or'ANALYTICS_RATE_LIMIT'.unavailableMessageis the message of the 503 sent when the Durable Object namespace is missing.
Over the limit it throws a 429 with a Retry-After header,
data.retryAfterSeconds, and the message "Too many attempts. Try again in N
minute(s)." The per-location limiter refuses with "Too many attempts. Try again
shortly." and a 60-second Retry-After.
Call it before doing the work, and after the session and permission checks:
those decide whether the caller may, the limit decides how often.
server/api/example-paid.get.ts(Licensees only) limits
after its plan checks, because a paying customer can still run up your bill on
a route that calls a model.
Count only failures
On a credential check, call requireRateLimit before you check the credential,
so a caller who is already refused learns nothing from the answer. On success,
hand the attempt back:
import type { RateLimitOptions } from '#server/utils/rate-limit'
const limit: RateLimitOptions = {
scope: 'pin',
key: String(session.user.id),
policy: { max: 5, windowMs: 15 * 60_000, lockoutMs: 15 * 60_000 },
}
await requireRateLimit(event, limit)
if (!(await pinMatches(event, session.user.id, body.pin))) {
throw createError({ statusCode: 400, statusMessage: 'Wrong PIN.' })
}
await clearRateLimitFailures(event, limit)
A failure needs no call, because the attempt was counted when it was reserved.
clearRateLimitFailures(event, options)resets the key's count and lifts its lockout. Use it when the key names one subject.releaseRateLimitAttempts(event, options, n)hands back onlynreservations. Use it on a key strangers share, such as an IP, where a reset would wipe everyone else's failures.resetRateLimit(event, options)forgets the count, the lockout and the last send. It is for an operator unlocking someone else. A route that lets its caller reset their own counter has no limit.
For a public form, pair the limit with a Turnstile check,
assertHuman(event, token) from #server/auth/turnstile (Auth).
Space out sends
A route that mails whoever the caller names is abused by succeeding over and
over. Add minIntervalMs to the policy:
const limit: RateLimitOptions = {
scope: 'share-link',
key: body.email.toLowerCase(),
policy: {
max: 10,
windowMs: 60 * 60_000,
lockoutMs: 15 * 60_000,
minIntervalMs: 60_000,
},
}
await requireRateLimit(event, limit)
await sendShareLink(event, body.email)
await clearRateLimitFailures(event, limit)
With an interval, clearRateLimitFailures keeps the count as a send count and
stamps the time. A call within minIntervalMs of the last success is refused
with data.reason: 'too-soon' and "That was just sent. Try again in N
second(s)."
Shipped limits
| Scope | Keyed by | Limit | Defined in |
|---|---|---|---|
totp, totp-recovery, reauth | account | 5 per 15 min | server/auth/throttle.ts(Licensees only) |
totp-daily | account | 20 per day | same |
password | IP and address | 10 per 15 min | same |
passkey | IP | 30 per 15 min | same |
register | address | 10 per hour | same |
magic-link, password-reset, email-verify | address | 10 per hour, 1 min apart | same |
magic-confirm | account | 3 per hour, 1 min apart | same |
email-change | new address | 10 per hour, 1 min apart | same |
org-invite | invited address | 10 per hour, 1 min apart | server/tenancy/invitations.ts(Licensees only) |
org-invite-sender | inviter | 50 per hour | same |
admin-password-reset, admin-verification-email, admin-unlock | staff member | 30 per hour each | server/auth/support.ts(Licensees only) |
checkout:guest | 5 per hour | server/api/payments/checkout.post.ts(Licensees only) | |
checkout:coupon | account, or IP for a guest | 20 per hour | same |
checkout:resolve | account | 10 per 10 min | server/api/payments/pending.post.ts(Licensees only) |
contact:message, contact:address | IP, IPv6 by /64 | 5 per 10 min | server/contact/limits.ts(Licensees only) |
contact:message, key site | the whole site | 30 per hour | same |
example-paid | account | 60 per minute | server/api/example-paid.get.ts(Licensees only) |
The auth and org-invite lockouts last at least 15 minutes, and
magic-confirm's an hour. The other scopes lock for their whole window, and
the site-wide contact:message count until its hour is up. From auth code,
call assertNotThrottled(event, 'totp', subject) from
server/auth/throttle.ts(Licensees only) instead of restating a
policy.
Change the per-location limit
The limiters are ratelimits entries in wrangler.jsonc. Their limit is set
per binding, not per call, and applies to every route that uses the binding.
To give a route its own, add an entry:
{
"name": "REPORTS_RATE_LIMIT",
"namespace_id": "1003",
"simple": { "limit": 60, "period": 60 },
}
Cloudflare accepts a period of 10 or 60 seconds. Run pnpm cf-typegen, add
the name to RateLimitBinding in
server/types/platform-rate-limit.ts(Licensees only),
and pass binding: 'REPORTS_RATE_LIMIT'.
Protect the zone
A flood of page loads costs a Worker invocation each, even when the answer is cached. Stop it on the zone instead: Security > Security rules > Create rule > Rate limiting rules.
| Plan | Rules | Counting period | Block for | Counts by |
|---|---|---|---|---|
| Free | 1 | 10 s | 10 s | IP |
| Pro | 2 | up to 1 min | up to 1 hour | IP |
| Business | 5 | up to 10 min | up to 1 day | IP, IP + NAT |
The free rule can match only on the URI path and Verified Bot fields. Spend it on the paths that reach the Worker, counted per IP at 50 requests per 10 seconds, action Block:
(http.request.uri.path contains "/api/") or (http.request.uri.path contains "/blog") or (http.request.uri.path contains "/legal/")
Static assets never reach the Worker, so they need no rule.
Locally
Both layers are off under pnpm dev. Each call passes and logs:
[rate-limit] Durable Objects are unavailable under `nuxt dev`; report is unlimited here. Use `pnpm preview` to exercise the real limit.
pnpm preview runs the real limits. It needs a real Postgres
(Database).
Configuration
| Key | Where | Default | What it does |
|---|---|---|---|
ratelimits entry AUTH_RATE_LIMIT | wrangler.jsonc | 20 per 60 s | The per-location layer requireRateLimit uses. |
ratelimits entry ANALYTICS_RATE_LIMIT | wrangler.jsonc | 60 per 60 s | Per-IP limit on the analytics beacon route. |
durable_objects binding AUTH_THROTTLE | wrangler.jsonc | AuthThrottle | The counters. Missing outside dev, every limited route answers 503. |
There are no environment variables. Policies live in code, next to the route that spends them.
Good to know
- Every call also spends the per-location limiter: as shipped, 20 a minute for the same scope and key, and 20 for the same scope and IP. A policy above that, like the 60 a minute above, is held to 20 from one location unless the route passes a binding of its own.
- The per-location limiter resets in each Cloudflare location, so a caller spread across locations gets its limit from each. The Durable Object is the bound.
- Counter state is deleted a day after its window started, or when its lockout
ends if that is later. A count in a
windowMslonger than 24 hours resets after 24 hours unless it locked. - The source IP comes from
requestIp(event), which readscf-connecting-ipand neverX-Forwarded-For, so a client behind Cloudflare can't forge it. Callers with no known IP share the sourceunknown. - This is abuse control. A quota a customer paid for belongs in their plan (Payments), where going over is a 402, not a 429.
A link with a lock opens the kit’s private repository, which every licensee can read.
Security headers
The Content-Security-Policy and hardening headers on every response: allow another origin, keep tokens out of Referer, accept cross-site posts.
Analytics
Product analytics and ad conversions sent from the server with no browser script: add an event, connect a provider, and handle consent.