---
title: "Rate limiting"
description: "Throttle OTP sends and checks, billing syncs, metered AI calls, agent sessions, admin actions, invitations, and your own endpoints with the rate-limiter component, pre-seeded with the package defaults."
canonical_url: "https://nuxt-backend.dev/docs/ship/production/rate-limits"
---
# Rate limiting

> Throttle OTP sends and checks, billing syncs, metered AI calls, agent sessions, admin actions, invitations, and your own endpoints with the rate-limiter component, pre-seeded with the package defaults.

`setupRateLimiter(components, limits?)` returns a `RateLimiter` pre-seeded with `DEFAULT_LIMITS` — `emailOtp` (OTP / verification sends, per email — 5/minute, keyed by a SHA-256 of the address), `emailOtpGlobal` (OTP sends across the whole deployment — a fixed window of 300/hour, the backstop against address rotation; raise it for a launch that legitimately signs in more), `emailOtpAttempt` (code checks, per email — 10/hour, keyed by a SHA-256 of the address the code went to, spent only while a code is waiting), `billingSync` (entitlement syncs, per billing entity), `ai` (metered AI calls, per billing entity), `aiBudget` (the per-entity credit budget behind `setupAi({ budget })` — a fixed window that counts credits spent, not calls, 10 000 a day by default; `budget: { units, period }` overrides it inline), `mcp` (agent token exchanges, per client + user), `admin` (administrator actions on the admin routes — 30/minute per administrator), `invitation` (workspace invitations — 30/hour per inviter) and `invitationGlobal` (invitations across the whole deployment — a fixed window of 200/hour, the ceiling the per-inviter limit cannot be; raise it for a launch that legitimately invites more). Your `limits` are merged on top and can override any of them.

```ts [backend/rateLimiter.ts]
import { setupRateLimiter } from 'nuxt-backend/rate-limit'
import { components } from './_generated/api'

export const rateLimiter = setupRateLimiter(components, {
  // your own limits, merged over DEFAULT_LIMITS
  sendMessage: { kind: 'token bucket', rate: 10, period: 60_000 },
})
```

Pass the limiter into `setupAuth` to throttle sending and checking codes — see [cross-component wiring](https://nuxt-backend.dev/docs/auth/setup#cross-component-wiring) and [Security](https://nuxt-backend.dev/docs/ship/production/security#passwordless-sign-in-and-its-rate-limits) — and into `setupAi`, whose metered actions use the `ai` limit unless you name another.

## Privileged routes

The same limiter, once it reaches `setupAuth`, also bounds the routes where one caller acts on *other people* or on an address they typed, and the one route anyone may call without a session:

<table>
<thead>
  <tr>
    <th>
      Route
    </th>
    
    <th>
      Limit
    </th>
    
    <th>
      Keyed by
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        /admin/*
      </code>
      
       — list, create, update, ban/unban, set role, impersonate, revoke sessions, remove user
    </td>
    
    <td>
      <code>
        admin
      </code>
      
       — 30/minute, burst 10
    </td>
    
    <td>
      the administrator's user id
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        /organization/invite-member
      </code>
    </td>
    
    <td>
      <code>
        invitation
      </code>
      
       — 30/hour, burst 10
    </td>
    
    <td>
      the inviter's user id
    </td>
  </tr>
  
  <tr>
    <td>
      …and the same route again
    </td>
    
    <td>
      <code>
        invitationGlobal
      </code>
      
       — 200/hour, fixed window
    </td>
    
    <td>
      nothing: one ceiling for the deployment
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        /email-otp/request-email-change
      </code>
      
       — the code to an email change's new address
    </td>
    
    <td>
      <code>
        emailChange
      </code>
      
       — 5/hour
    </td>
    
    <td>
      the user's id
    </td>
  </tr>
  
  <tr>
    <td>
      …and the same route again
    </td>
    
    <td>
      <code>
        emailOtpGlobal
      </code>
      
       — 300/hour, fixed window
    </td>
    
    <td>
      nothing: the ceiling every code send spends
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        /mcp/register
      </code>
      
       — agent client registration
    </td>
    
    <td>
      <code>
        agentRegistrationGlobal
      </code>
      
       — 100/hour, fixed window
    </td>
    
    <td>
      nothing: there is no caller to key on
    </td>
  </tr>
</tbody>
</table>

Better Auth already *authorizes* these routes; the limits bound how fast an authorized caller may go, so one compromised admin session cannot ban a thousand accounts. They are keyed by user id rather than by session, so opening more sessions does not multiply the budget. The caller is the session the route runs with, whether its token came in a cookie or as `Authorization: Bearer`; a request with no credential is left to the route, which answers 401 itself, and one whose credential matches no session is refused with 401 before it runs.

A per-caller limit bounds one account and nothing more — thirty invitations an hour each is still a lot of mail from a workspace of fifty. Invitations therefore also spend `invitationGlobal`, an unkeyed ceiling for the whole deployment, because the cost lands on a shared resource: your sending domain's reputation. It is consumed **after** the inviter's own bucket allowed the request, so one account's refused excess never eats everyone else's budget, and a deployment at its ceiling answers 429 with *"Invitations are temporarily unavailable"* rather than blaming the inviter. It is the counterpart of `emailOtpGlobal` on the package's other outbound-email path; admin routes have no such ceiling, because banning a user spends nothing anyone else shares.

`/admin/stop-impersonating` and `/admin/has-permission` are deliberately exempt — the first is the way *out* of an impersonated session and must never be throttled, the second is a read-only check an admin UI calls on render.

Better Auth's own per-path limiter is off by default: it keeps its counters in memory, which on Convex lasts one function call, so it would limit nothing. To throttle another route through it, turn it on — `enabled: true` — with durable storage. It counts per IP and takes its rules from your auth options:

```ts [backend/auth.ts]
setupAuth(components, query, {
  email,
  authOptions: {
    rateLimit: {
      enabled: true,
      storage: 'database',
      customRules: { '/organization/create': { window: 60, max: 5 } },
    },
  },
})
```

## What the user sees

Every 429 the package raises is an `APIError('TOO_MANY_REQUESTS')` whose body carries `code: 'rate_limited'`, the `limit` it spent and `retryAfter`, the milliseconds until the limit lets the caller through again. That covers the code limits (`emailOtp`, `emailOtpGlobal`, `emailOtpAttempt`, `emailChange`) and the privileged routes above. `useLoginFlow()` and `useEmailChange()` turn it into their `error` message, *"Too many attempts. Try again in N seconds."*, and exposes the wait as `retryAfter` in seconds (see [the authentication guide](https://nuxt-backend.dev/docs/auth/sign-in)).

Better Auth's own per-path limiter, once you turn it on, answers 429 too, but it sends the wait only as an `X-Retry-After` header. The auth client's `{ data, error }` result drops headers, so those refusals read *"Try again in a moment."* with `retryAfter` left `null`.

Outside the auth routes, the package's limits (`billingSync`, `ai`, `aiBudget`) throw the limiter's own error, and `getBackendError(error)` reads it, like the 429 above, as `rate_limited` with `limit` and `retryAfter` ([Handle errors](https://nuxt-backend.dev/docs/data/errors)).

<note>

There are no password limits (`signIn`, `signUp`, `passwordReset`): the package is passwordless. Guessing at one code is bounded by Better Auth's own `allowedAttempts` (three wrong guesses), guessing at one address by `emailOtpAttempt`. An app that turns passwords on gets the password-reset code routes under the code limits; for `/sign-in/email` and `/sign-up/email`, turn on Better Auth's limiter as above, or add named limits of your own. `DEFAULT_LIMITS` is also exported from `nuxt-backend/rate-limit` if you want to inspect or extend the defaults directly.

</note>

<page-keys>

- Try it live in the playground: [/playground/platform/rate-limit](https://nuxt-backend.dev/playground/platform/rate-limit)
- API reference: [/docs/reference/api/rate-limit](https://nuxt-backend.dev/docs/reference/api/rate-limit)
- Source: [https://github.com/qruto/nuxt-backend/blob/main/src/convex/integrations/rate-limit.ts](https://github.com/qruto/nuxt-backend/blob/main/src/convex/integrations/rate-limit.ts)

</page-keys>


## Sitemap

See the full [sitemap](https://nuxt-backend.dev/sitemap.md) for all pages.
