Skip to content

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:

  1. Cloudflare's rate-limiting binding, AUTH_RATE_LIMIT unless 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 in wrangler.jsonc(Licensees only) and counted per Cloudflare location.
  2. A Durable Object per scope and key, AuthThrottle(Licensees only), reserves the attempt atomically against the policy you pass. This is the real limit.
  3. 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:

  • scope names the counter, so two scopes never spend each other's budget.
  • key is who is limited: a user id, a lowercased email, or requestIp(event).
  • policy.max attempts are allowed per policy.windowMs. The max-th still goes through and locks the key. The next is refused until lockoutMs has passed, or until the window ends if that is later.
  • binding picks the per-location limiter, 'AUTH_RATE_LIMIT' or 'ANALYTICS_RATE_LIMIT'.
  • unavailableMessage is 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 only n reservations. 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

ScopeKeyed byLimitDefined in
totp, totp-recovery, reauthaccount5 per 15 minserver/auth/throttle.ts(Licensees only)
totp-dailyaccount20 per daysame
passwordIP and address10 per 15 minsame
passkeyIP30 per 15 minsame
registeraddress10 per hoursame
magic-link, password-reset, email-verifyaddress10 per hour, 1 min apartsame
magic-confirmaccount3 per hour, 1 min apartsame
email-changenew address10 per hour, 1 min apartsame
org-inviteinvited address10 per hour, 1 min apartserver/tenancy/invitations.ts(Licensees only)
org-invite-senderinviter50 per hoursame
admin-password-reset, admin-verification-email, admin-unlockstaff member30 per hour eachserver/auth/support.ts(Licensees only)
checkout:guestemail5 per hourserver/api/payments/checkout.post.ts(Licensees only)
checkout:couponaccount, or IP for a guest20 per hoursame
checkout:resolveaccount10 per 10 minserver/api/payments/pending.post.ts(Licensees only)
contact:message, contact:addressIP, IPv6 by /645 per 10 minserver/contact/limits.ts(Licensees only)
contact:message, key sitethe whole site30 per hoursame
example-paidaccount60 per minuteserver/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.

PlanRulesCounting periodBlock forCounts by
Free110 s10 sIP
Pro2up to 1 minup to 1 hourIP
Business5up to 10 minup to 1 dayIP, 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

KeyWhereDefaultWhat it does
ratelimits entry AUTH_RATE_LIMITwrangler.jsonc20 per 60 sThe per-location layer requireRateLimit uses.
ratelimits entry ANALYTICS_RATE_LIMITwrangler.jsonc60 per 60 sPer-IP limit on the analytics beacon route.
durable_objects binding AUTH_THROTTLEwrangler.jsoncAuthThrottleThe 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 windowMs longer than 24 hours resets after 24 hours unless it locked.
  • The source IP comes from requestIp(event), which reads cf-connecting-ip and never X-Forwarded-For, so a client behind Cloudflare can't forge it. Callers with no known IP share the source unknown.
  • 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.