---
title: "Security"
description: "The Convex-aware Content Security Policy, the fail-closed webhook policy, secret rotation, and what OAuth consent lets an agent do."
canonical_url: "https://nuxt-backend.dev/docs/ship/production/security"
---
# Security

> The Convex-aware Content Security Policy, the fail-closed webhook policy, secret rotation, and what OAuth consent lets an agent do.

## Content Security Policy

`nuxt-security` is a dependency of this package, and the module installs it in every app (ahead of `nuxt-convex-module`, which then finds it registered), so every app gets its security headers — and a CSP the Convex integration tightens for you: a Nitro plugin hooked into `nuxt-security:routeRules` extends **connect-src** (production only), **img-src**, and **media-src** with the deployment origins at runtime, on top of whatever the app configured, regardless of module order. Configure the rest through nuxt-security's own `security` key:

```ts [nuxt.config.ts]
export default defineNuxtConfig({
  modules: ['nuxt-backend'],
  security: {
    headers: {
      contentSecurityPolicy: {
        // extend, e.g. an analytics origin; the Convex origins are added for you
        'connect-src': ['\'self\'', 'https://analytics.example.com'],
      },
    },
  },
})
```

Two route groups carry their own rules, merged over the global config (your `routeRules` can override them in turn):

- **The auth proxy** (`/api/auth/**`): `xssValidator: false` — the validator HTML-escapes JSON bodies and rejects anything that changed, which would break a display name containing `<`; these are opaque credentials forwarded to Better Auth, never rendered HTML. `allowedMethodsRestricter` limits it to `GET`, `HEAD`, `POST`, `OPTIONS`.
- **The agent surface** (`/mcp`, `/mcp/**`): nuxt-security's IP `rateLimiter`, `xssValidator`, and `corsHandler` are off — long agent sessions would trip the limiter, tool args can legitimately look like markup, and the MCP surface has its own guards (OAuth + the deployment's `mcp` limit). The `/.well-known/oauth-*` documents only relax `corsHandler` (they are fetched cross-origin by browser-based clients and answer with their own CORS headers).

`convex.security: false` opts out of the Convex CSP additions; `security: false` disables nuxt-security entirely (not recommended).

## Webhooks: fail closed

Both inbound webhooks (`/billing/events`, `/email/events`) share one policy, enforced before any handler runs:

<table>
<thead>
  <tr>
    <th>
      Condition
    </th>
    
    <th>
      Status
    </th>
    
    <th>
      Logged outcome
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Secret env missing / empty
    </td>
    
    <td>
      <code>
        503
      </code>
    </td>
    
    <td>
      <code>
        missing_secret
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Invalid or stale signature (±5 min replay tolerance)
    </td>
    
    <td>
      <code>
        403
      </code>
    </td>
    
    <td>
      <code>
        invalid_signature
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Body over 1 MiB
    </td>
    
    <td>
      <code>
        413
      </code>
    </td>
    
    <td>
      <code>
        oversized
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Authentic, type unknown to this package
    </td>
    
    <td>
      <code>
        202
      </code>
    </td>
    
    <td>
      <code>
        unknown_type
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Handler threw
    </td>
    
    <td>
      <code>
        500
      </code>
      
       (provider retries)
    </td>
    
    <td>
      <code>
        handler_error
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Redelivery of a fully-processed id
    </td>
    
    <td>
      <code>
        200
      </code>
    </td>
    
    <td>
      <code>
        duplicate
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Success
    </td>
    
    <td>
      <code>
        2xx
      </code>
    </td>
    
    <td>
      <code>
        ok
      </code>
    </td>
  </tr>
</tbody>
</table>

Throw ⇒ 5xx ⇒ the provider retries against idempotent built-ins; dedupe short-circuits only `ok` outcomes; nothing is silently swallowed. There is deliberately **no rate limiting** on these routes — signature verification is the gate, and a limiter would drop the legitimate retry bursts after an incident. Every outcome lands in the component's delivery log, which `getWebhookDeliveries` serves to admins only (DevTools reads it through it): a verified delivery as its own row among the newest 200, a request refused before verification as a count per kind, so a flood of forged requests can't push a real delivery out. `doctor` probes both routes and fails on `404` (not mounted) and `503` (mounted, unconfigured).

## Passwordless sign-in and its rate limits

There are no passwords: sign-in is an email **OTP** (Better Auth's `emailOTP` plugin) or a **passkey**, so there is no password to brute-force. The surface is the code itself — asking for one, and checking one — and these rules shape it:

- **Accounts are created with OTP only.** A passkey is added from an authenticated session (the `add-passkey` step of [`useLoginFlow`](https://nuxt-backend.dev/docs/auth/sign-in)), never as the first credential — minting an account from a WebAuthn ceremony over an unverified, client-typed email would let an attacker plant a passkey on a victim's address before the victim ever signs up.
- **A code is a live credential.** The database keeps an HMAC-SHA-256 of it, keyed by `AUTH_SECRET`. A six-digit code has a million values, so an unkeyed hash, Better Auth's own `storeOTP: 'hashed'`, is inverted in under a second by anyone who can read the table; keyed, the stored value yields nothing without the secret. A code is never logged unless `NUXT_BACKEND_LOG_OTP=1` is set on the deployment (dev without an email transport).
- **Guessing has two bounds.** One code is a 6-digit number, valid for 5 minutes and refused after 3 wrong guesses (`TOO_MANY_ATTEMPTS`), and a new code replaces the last one sent to the same address for the same purpose. One address gets 10 guesses an hour across all its codes (`emailOtpAttempt`, below), so asking for new codes does not buy more. Pass your own `emailOTP(..)` in `authOptions.plugins` to change the per-code bounds — but know what it costs: a plugin you pass replaces the package's `emailOTP` entirely, and with it the keyed hash, the email change by code and the delivery through your `email` instance. The request-level limits and the sign-in gate stay, since they run on the request.
- **No link proves a mailbox.** Verifying an address and both halves of an email change are codes too (a code from the current address, then one from the new address), so a forwarded email or a mail scanner that opens every link changes nothing. Better Auth's link routes are off — `/send-verification-email`, `/verify-email`, `/change-email` answer `404` — so a link sent by an earlier version stops working. The account-deletion confirmation is the one link left.
- **Sessions and agent tokens are stored as they are.** Better Auth keeps session tokens, and the OAuth access and refresh tokens agents hold, in plain form: read access to the deployment's data is access to every signed-in account. Guard the Convex dashboard and deploy keys as you would the accounts themselves.

The package also turns off the code routes it never uses, merged into your own `disabledPaths` and refused by the route's own path, so no spelling of the URL reaches them:

- `/email-otp/check-verification-otp`, which checks a code without using it up, under an attempt counter concurrent guesses outrun.
- Unless `emailAndPassword.enabled` is on: the password-reset code routes `/email-otp/request-password-reset`, `/forget-password/email-otp` and `/email-otp/reset-password`, which would email reset codes to any registered address outside every limit and set a password on the account. A `forget-password` code from `/email-otp/send-verification-otp` is refused with `400`. With passwords on, the reset routes run under the same limits as the other code routes.

Codes are throttled by four limits from `DEFAULT_LIMITS`, applied once the limiter is passed to `setupAuth` (`rateLimiter` — the scaffold does):

<table>
<thead>
  <tr>
    <th>
      Limit
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Keyed by
    </th>
    
    <th>
      Where it runs
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        emailOtp
      </code>
    </td>
    
    <td>
      Token bucket, 5 per minute per address, burst 5
    </td>
    
    <td>
      A SHA-256 of the address — the address itself never enters the limiter's table
    </td>
    
    <td>
      In a request hook on <code>
        POST /email-otp/send-verification-otp
      </code>
      
       (and the reset-code routes, with passwords on), after the sign-in gate, so an address the gate refuses spends none of its quota
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        emailOtpGlobal
      </code>
    </td>
    
    <td>
      Fixed window, 300 per hour, deployment-wide
    </td>
    
    <td>
      Nothing
    </td>
    
    <td>
      In the same hook, before the gate, and on <code>
        POST /email-otp/request-email-change
      </code>
      
       — before a code is minted: Better Auth swallows what the send callback throws and still answers success, so every limit has to run on the request itself
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        emailOtpAttempt
      </code>
    </td>
    
    <td>
      Token bucket, 10 per hour per address
    </td>
    
    <td>
      A SHA-256 of the address the code went to
    </td>
    
    <td>
      In a request hook on every route that checks a code — <code>
        /sign-in/email-otp
      </code>
      
      , <code>
        /email-otp/verify-email
      </code>
      
      , both steps of an email change and, with passwords on, <code>
        /email-otp/reset-password
      </code>
      
       — before the code is read, and only while a code is waiting
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        emailChange
      </code>
    </td>
    
    <td>
      Token bucket, 5 per hour per account
    </td>
    
    <td>
      The signed-in user
    </td>
    
    <td>
      In a request hook on <code>
        POST /email-otp/request-email-change
      </code>
      
      , which sends the code to the new address the caller chose
    </td>
  </tr>
</tbody>
</table>

A closed limit answers `429` (`TOO_MANY_REQUESTS`) and the sign-in UI shows it. `emailOtpGlobal` is the ceiling an attacker rotating addresses hits — raise it for a launch that legitimately signs in more than 300 people an hour. `emailOtpAttempt` is what a guess loop meets: without it, five new codes a minute at three guesses each would let one address be guessed at for as long as the loop ran. Like any limit keyed by an address, someone who spends it can keep that address from signing in by code for the hour — never without codes going to it, since a check with no code waiting spends nothing — and a passkey still signs it in. The invite-only gate (`canSignIn`) runs on the same request hook for sign-in codes and again before any account or session is created — see [auth setup](https://nuxt-backend.dev/docs/auth/setup#cross-component-wiring) and [Rate limiting](https://nuxt-backend.dev/docs/ship/production/rate-limits).

### The caller is the one the route sees

Every limit and refusal keyed on the caller reads the session the route will run with. That includes a request authenticated with `Authorization: Bearer <session token>`, which the Convex plugin accepts in place of the cookie. A credential that resolves to no session is refused with `401` before the route runs.

### An administrator acting as someone else

While an administrator impersonates an account, its session carries `impersonatedBy`, and the account cannot be changed in ways that outlast the visit. The administrator cannot add, rename or remove a passkey, start or finish an email change, delete the account, invite anyone to its workspaces or change a member's role there, link another sign-in method, or approve an agent. Each is refused with `403`; reading, and `/admin/stop-impersonating`, stay open. Apps that add plugins which issue credentials (API keys, one-time tokens) should refuse them the same way, on `impersonatedBy`.

## Secret rotation

- **Webhook secrets** — `BILLING_WEBHOOK_SECRET` and `EMAIL_WEBHOOK_SECRET` accept a comma-separated accept-list (`whsec_new,whsec_old`; first entry primary). Add the new secret in front, rotate it in the provider dashboard, then drop the old one — zero downtime.
- **AUTH_SECRET** — signs sessions, encrypts the JWKS private keys and keys the hash of every one-time code. Rotating it invalidates every session, breaks the stored signing keys and voids the codes in flight; treat it as an incident-response step, not routine, and follow [Troubleshooting](https://nuxt-backend.dev/docs/ship/operate/troubleshooting#sessions-or-agent-tokens-fail-after-rotating-auth_secret).
- **Provider tokens** (`BILLING_ACCESS_TOKEN`, `EMAIL_API_KEY`) — rotate in the dashboard, set the new value, redeploy nothing: the functions read env at call time.

`env push` never prints values, and hands each one to `convex env set` on its stdin: never as an argument, where another process could read it, and never quoted in an error. It compares deployment **names**, never values, so it leaves what is set alone unless you name it: rotation is explicit — `npx nuxt-backend env push --force BILLING_WEBHOOK_SECRET` (or `npx convex env set` directly).

## Privileged operations stay internal

The scaffold ships `send`, `createDiscount` and the marketing actions (`createSegment`, `addContact`, `createBroadcast`, `sendBroadcast`) as **internalActions**. As public actions, they would be an open relay on your verified domain or a mint for 100%-off coupons. Expose one to an admin UI only by re-declaring it with the `admin.action` builder from [`functions.ts`](https://nuxt-backend.dev/docs/auth/access/roles), the way `sendAsAdmin` sends to an address someone types. `test/unit/scaffold-relay.test.ts` fails if a function a browser can call ever takes a recipient again. `setUserRole` is an `internalMutation` for the same reason.

Identity-gated reads: `getEmailStatus` (recipient and error detail) and every billing read return `null` for anonymous callers. `getWebhookDeliveries` (every account's event types, provider ids and handler errors) returns `null` for anyone but an admin.

## OAuth consent: what an agent can do

Everything the built-in tools expose is already reachable through the product UI under the same user auth, and OAuth adds **per-client consent** on top: every authorization stops at the [consent page](https://nuxt-backend.dev/docs/agents/mcp/oauth-and-consent#consent), PKCE is required, and a code waiting for consent cannot be traded for a token. The scopes are minimal by design: identity, the display name, billing reads, checkout and portal **links** (never a payment), and workspace reads. [Scope semantics](https://nuxt-backend.dev/docs/agents/mcp/oauth-and-consent#scope-semantics) says exactly what each one grants.

Beyond scopes: a tool is hidden without its scope and refused on a direct call; the minted Convex JWT lives 5 minutes and the Nitro cache expires with it, so revoking an agent's OAuth token cuts Convex access within minutes with no distributed invalidation; the exchange is throttled per client + user (`DEFAULT_LIMITS.mcp`); a deleted or banned account fails the exchange like an invalid token. Details in [OAuth & consent](https://nuxt-backend.dev/docs/agents/mcp/oauth-and-consent).

## Origins and dev flags

`SITE_URL` is the only trusted browser origin for auth, and the only origin a checkout, a gift checkout or the billing portal sends a buyer back to, besides any listed in `setupBilling({ trustedOrigins })` (see [What the server decides](https://nuxt-backend.dev/docs/billing/subscriptions/checkout#what-the-server-decides)). `AUTH_TRUST_LOCAL_ORIGINS=1` additionally trusts loopback origins on any port, over `http` or `https`: `localhost`, `127.0.0.1` and `[::1]` — `env push` forwards it to dev deployments only, and `doctor` warns (fails with `--prod`) when it is present on a non-dev deployment. Nothing in the DevTools panel carries env values: only presence booleans cross the RPC.

## Reporting a vulnerability

Privately, through [GitHub Security Advisories](https://github.com/qruto/nuxt-backend/security/advisories/new) — not a public issue.


## Sitemap

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