---
title: "Security"
description: "The Convex-aware CSP, the hardened Better Auth proxy, auth tokens in SSR payloads, safe redirects, cross-domain tokens, and a production checklist."
canonical_url: "https://nuxt-convex-module.dev/getting-started/security"
---
# Security

> The Convex-aware CSP, the hardened Better Auth proxy, auth tokens in SSR payloads, safe redirects, cross-domain tokens, and a production checklist.

This page collects everything the module does for your app's security, everything it deliberately leaves to you, and the sharp edges worth knowing before you deploy.

The short version: install [`nuxt-security`](https://nuxt-security.vercel.app) and the module wires it up with Convex-aware defaults. Without it, the module still hardens its own surfaces — the auth proxy, SSR payloads, cross-domain tokens — but your app ships without security headers.

<pm-install packages="nuxt-security">

```bash [pnpm]
pnpm add nuxt-security
```

```bash [npm]
npm i nuxt-security
```

```bash [yarn]
yarn add nuxt-security
```

```bash [bun]
bun add nuxt-security
```

</pm-install>

## What ships hardened

<table>
<thead>
  <tr>
    <th>
      Surface
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Opt out
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <a href="#content-security-policy">
        Content Security Policy
      </a>
    </td>
    
    <td>
      Convex origins added to <code>
        connect-src
      </code>
      
       (production), <code>
        img-src
      </code>
      
      , <code>
        media-src
      </code>
    </td>
    
    <td>
      <code>
        convex.security: false
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <a href="#the-better-auth-proxy">
        Auth proxy caching
      </a>
    </td>
    
    <td>
      <code>
        cache: false
      </code>
      
      , <code>
        prerender: false
      </code>
    </td>
    
    <td>
      app <code>
        routeRules
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <a href="#why-the-xss-validator-is-off-there">
        Auth proxy XSS validator
      </a>
    </td>
    
    <td>
      off for that route — it rejects valid credentials
    </td>
    
    <td>
      app <code>
        routeRules
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <a href="#the-better-auth-proxy">
        Auth proxy methods
      </a>
    </td>
    
    <td>
      <code>
        GET
      </code>
      
      , <code>
        HEAD
      </code>
      
      , <code>
        POST
      </code>
      
      , <code>
        OPTIONS
      </code>
      
       only
    </td>
    
    <td>
      — (the handler enforces it)
    </td>
  </tr>
  
  <tr>
    <td>
      <a href="#auth-tokens-in-ssr-payloads">
        Authenticated SSR responses
      </a>
    </td>
    
    <td>
      <code>
        Cache-Control: private, no-store
      </code>
    </td>
    
    <td>
      set the header yourself later in the request
    </td>
  </tr>
  
  <tr>
    <td>
      <a href="#post-sign-in-redirects">
        Post-sign-in redirects
      </a>
    </td>
    
    <td>
      <code>
        resolveAuthRedirect()
      </code>
      
       helper (you must use it)
    </td>
    
    <td>
      —
    </td>
  </tr>
  
  <tr>
    <td>
      <a href="#cross-domain-one-time-tokens">
        Cross-domain one-time tokens
      </a>
    </td>
    
    <td>
      unrestricted, matching upstream
    </td>
    
    <td>
      <code>
        betterAuth.crossDomainCallbackRoute
      </code>
      
       opts <strong>
        in
      </strong>
      
       to the guard
    </td>
  </tr>
</tbody>
</table>

Everything above is applied through standard Nuxt mechanisms — route rules and the `security` config key — so anything you set yourself is kept; the module appends its Convex origins to your CSP rather than replacing it, and they go away only when you [opt out](#opting-out) — `convex.security: false`, or nuxt-security's CSP turned off.

## Content Security Policy

A CSP tells the browser which origins a page may load from and talk to. `nuxt-security` ships a strong baseline; what it cannot know is where *your* Convex deployment lives. The module fills that in.

When `nuxt-security` is installed, the module registers it as a module dependency — you don't add a `modules` entry, and if you do list it yourself, Nuxt dedupes — then extends its CSP:

<table>
<thead>
  <tr>
    <th>
      Directive
    </th>
    
    <th>
      Added
    </th>
    
    <th>
      When
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        connect-src
      </code>
    </td>
    
    <td>
      deployment origin over <code>
        https:
      </code>
      
       and <code>
        wss:
      </code>
      
      , plus the <code>
        .site
      </code>
      
       origin when <code>
        siteUrl
      </code>
      
       is set
    </td>
    
    <td>
      production only
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        img-src
      </code>
    </td>
    
    <td>
      deployment and <code>
        .site
      </code>
      
       origins
    </td>
    
    <td>
      always
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        media-src
      </code>
    </td>
    
    <td>
      deployment and <code>
        .site
      </code>
      
       origins
    </td>
    
    <td>
      always
    </td>
  </tr>
</tbody>
</table>

`connect-src` covers the realtime WebSocket, one-shot HTTP queries, and the direct-to-Convex file uploads. `img-src` / `media-src` cover files served from Convex storage through `useStorageUrl()`.

The origins are appended at **runtime**, from runtime config — the deployment origin from `runtimeConfig.public.convex.url`, the `.site` origin from `runtimeConfig.convex.siteUrl` with `runtimeConfig.public.convex.siteUrl` as the fallback — through nuxt-security's `nuxt-security:routeRules` hook. Two consequences worth having: the deployment URL can come from the environment at deploy time rather than being baked into the build, and the position of `nuxt-security` in `modules` doesn't matter.

### Why `connect-src` is production-only

In development the module leaves `connect-src` untouched. Vite's HMR socket and the Nuxt DevTools channel run on their own ports, which `'self'` does not cover — materializing the directive would break both. `nuxt-security`'s own default leaves it unset, so dev keeps that behaviour and production gets the tightened one.

The practical consequence: **a CSP mistake surfaces only after nuxt build.** Check a production preview before you ship.

### Adding your own origins

This is the part most apps need. The module *creates* `connect-src` where nuxt-security left it open, so anything else your browser code talks to must be named — an auth provider, analytics, error reporting, your own API. Your entries are preserved and the Convex origins are appended to them:

```ts [nuxt.config.ts]
export default defineNuxtConfig({
  modules: ['nuxt-convex-module'],
  security: {
    headers: {
      contentSecurityPolicy: {
        // The Convex origins are appended to whatever you put here.
        'connect-src': ['\'self\'', 'https://your-tenant.auth0.com'],
      },
    },
  },
})
```

<note>

[Better Auth](https://nuxt-convex-module.dev/components/better-auth) needs nothing here: its traffic goes through the same-origin `/api/auth/**` proxy, so `'self'` already covers it. [Clerk](https://nuxt-convex-module.dev/components/clerk) and [Auth0](https://nuxt-convex-module.dev/components/auth0) call their provider directly from the browser, so their origins must be listed. Use the exact frontend-API or tenant domain from your provider's dashboard — including custom domains.

</note>

### Opting out

`convex: { security: false }` leaves nuxt-security's CSP entirely alone. Disabling nuxt-security itself (`security: false`) or just its CSP (`security.headers.contentSecurityPolicy: false`) also skips the Convex additions. In every case the rest of the module keeps working — you simply own the policy.

## The Better Auth proxy

With the [Better Auth](https://nuxt-convex-module.dev/components/better-auth) integration enabled, the module mounts a proxy at `authRoute` (default `/api/auth/**`) that forwards to your Convex `.site` URL. The design is deliberate: the browser only ever talks to **your own origin**, so session cookies stay first-party and no CORS relaxation is needed anywhere.

Before forwarding anything, the proxy refuses a request whose path changes under URL normalisation (`400`): Nitro routes the raw path, so `/api/auth/../../x` matches the route, and forwarded as `/x` it would reach any HTTP action on the site origin through your own. On the way out the proxy strips hop-by-hop headers (`transfer-encoding`, `content-length`, `connection`), pins `host` to the Convex site URL, and stamps the forwarding headers Better Auth uses to reconstruct the original request (`x-forwarded-host`, `x-forwarded-proto`, and their `x-better-auth-*` counterparts) — those carry the incoming `Host`, so set `baseURL` and `trustedOrigins` in your Convex-side Better Auth config, as Better Auth's docs require. On the way back it drops `content-encoding` / `content-length` — `fetch` has already decoded the body — and relays everything else verbatim, including each `Set-Cookie` and any `Location`.

The route carries these rules:

```ts
{
  cache: false,        // live session traffic is never cached
  prerender: false,    // and never visited by a prerender pass
  security: {
    xssValidator: false,
    allowedMethodsRestricter: { methods: ['GET', 'HEAD', 'POST', 'OPTIONS'] },
  },
}
```

Better Auth serves `GET` and `POST` only, so anything else is a probe. The proxy handler refuses it with a `405` before it reaches your deployment — with nuxt-security installed, its route rule answers first with the same status — which also means it costs you nothing.

### Why the XSS validator is off there

This one is a correctness fix, not a preference. `nuxt-security`'s XSS validator HTML-escapes the JSON request body and answers **400 Bad Request** if escaping changed anything. Applied to auth traffic that rejects legitimate input:

```json
{ "email": "a@b.com", "password": "p<ssw0rd!" }   // 400 — the `<` was escaped
{ "name": "O<Brien" }                              // 400
```

A password is an opaque secret forwarded to an auth server; it is never HTML this app renders, so escaping it protects nothing and breaks sign-in and sign-up for anyone whose password contains `<` or `>`. The validator stays on for every other route.

<caution>

If you mount your own auth-adjacent server routes that accept passwords or free text, they need the same exemption — `routeRules: { '/api/my-route': { security: { xssValidator: false } } }`. Otherwise strong passwords are silently rejected with a `400`.

</caution>

### Rate limiting

`nuxt-security` rate-limits **every** route by default — 150 requests per 5 minutes per IP — and that includes the auth proxy. The module does not override it: any limit tight enough to meaningfully slow credential stuffing is also tight enough to break session polling for users behind a shared IP, and only you know your traffic. Tune it per route if you want something different:

```ts [nuxt.config.ts]
export default defineNuxtConfig({
  routeRules: {
    '/api/auth/**': {
      security: { rateLimiter: { tokensPerInterval: 60, interval: 60_000 } },
    },
  },
})
```

<caution>

The limiter identifies clients by `X-Forwarded-For` when present, and trusts it unconditionally. Behind a proxy or CDN that **overwrites** the header this is correct; exposed directly to the internet, any client can spoof the header and bypass the limit. Terminate traffic behind a proxy you control.

</caution>

Better Auth applies its own rate limiting on the Convex side. The Nitro-side limit is defence in depth, and protects your deployment's usage bill from being run up through the proxy.

## Auth tokens in SSR payloads

To avoid an auth round-trip on first paint, the server plugin prefetches the Convex JWT and passes it to the client through Nuxt state — mirroring how the React integration hands `initialToken` to its provider. That token, and any data from `preloadAuthQuery`, is serialized into the HTML as `window.__NUXT__`.

**The rendered page therefore contains a per-user bearer token.** The module sets

```text
Cache-Control: private, no-store
```

on any response where a token was prefetched, so a CDN or shared proxy can never replay one user's page to another. It is set early enough that a route with different needs can still override it.

What this means for you:

- Don't relax that header on authenticated routes, and don't put authenticated pages behind a shared cache or ISR/SWR rule.
- Prerendering an authenticated page is a mistake — the token would be baked into a static file.
- Treat the payload as user-visible. Return only what the page renders from `preloadAuthQuery`; anything else is disclosed to the browser.

## Post-sign-in redirects

The `convex-auth` route middleware sends unauthenticated visitors to your login page with the original destination attached:

```text
/login?redirect=/dashboard
```

The middleware only ever writes a router path there, but that query is part of a URL **anyone can craft**. A login page that navigates to it verbatim is an open redirect — a phishing primitive on your own domain:

```vue
<!-- Vulnerable: /login?redirect=https://evil.example sends the user off-site -->
await navigateTo(route.query.redirect as string)
```

Use `resolveAuthRedirect()`, auto-imported alongside `useBetterAuth` (or imported from `nuxt-convex-module/better-auth/client` under a restricted [`autoImports`](https://nuxt-convex-module.dev/getting-started/configuration#module-options-convex)). It returns the destination only when it is a same-origin path, and the fallback otherwise:

```vue [pages/login.vue]
<script setup lang="ts">
const route = useRoute()
const { client } = useBetterAuth()
const email = ref('')
const password = ref('')

async function onSubmit() {
  await client.signIn.email({ email: email.value, password: password.value })
  // '/dashboard' survives; 'https://evil.example' becomes '/'.
  await navigateTo(resolveAuthRedirect(route.query.redirect))
}
</script>
```

It rejects absolute URLs, scheme-relative `//evil.example`, backslash forms like `/\evil.example`, non-HTTP schemes, and a repeated `?redirect=` (which arrives as an array — ambiguous, and an attacker picks the entry you didn't mean). It also rejects paths that *normalize* into a protocol-relative URL: `/..//evil.example` starts with a single slash but resolves to `//evil.example`, so the guard validates the serialized result, not just the input. Pass your own fallback as the second argument: `resolveAuthRedirect(route.query.redirect, '/home')`.

## Cross-domain one-time tokens

Only relevant if your auth client installs `crossDomainClient()`.

When sign-in completes on a different origin, Better Auth redirects back with a `?ott=` one-time token that the client exchanges for a session. The protocol **cannot bind that token to the browser that started the flow** — magic-link flows legitimately finish in another browsing context — so a live token completes sign-in on whatever page receives it.

By default the token is consumed wherever it lands. This module lets you restrict it to the one route your sign-in `callbackURL`s point at:

```ts [nuxt.config.ts]
export default defineNuxtConfig({
  convex: {
    betterAuth: { crossDomainCallbackRoute: '/auth/callback' },
  },
})
```

With it set, a token arriving anywhere else is stripped from the URL but never exchanged. It is **off by default** to preserve upstream parity — turn it on if you use cross-domain auth.

## File storage

Uploads from `useUpload()` / `useUploadQueue()` go straight from the browser to the one-time URL your `generateUploadUrl` mutation returns. They never pass through Nitro, which has two consequences:

- `nuxt-security`'s request-size limiter (2 MB, or 8 MB for multipart) does **not** apply to them. Enforce size and content-type limits in your Convex function, and validate the stored file server-side rather than trusting the browser.
- The upload origin must be allowed by `connect-src` — the module's CSP already covers it.

Files read back through `useStorageUrl()` are served from the deployment origin, which is why `img-src` and `media-src` include it. Those URLs are signed and time-limited but otherwise unauthenticated: anyone holding one can fetch the file. Gate the *query* that hands out the URL, and don't embed storage URLs in anything cacheable by a shared proxy.

## What `nuxt-security` gives you beyond the CSP

Installing it brings a good deal the module doesn't have to configure. Defaults worth knowing:

<table>
<thead>
  <tr>
    <th>
      Protection
    </th>
    
    <th>
      Default
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        Strict-Transport-Security
      </code>
    </td>
    
    <td>
      180 days, <code>
        includeSubdomains
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        X-Content-Type-Options
      </code>
    </td>
    
    <td>
      <code>
        nosniff
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        X-Frame-Options
      </code>
      
       / <code>
        frame-ancestors
      </code>
    </td>
    
    <td>
      <code>
        SAMEORIGIN
      </code>
      
       / <code>
        'self'
      </code>
      
       — clickjacking cover for your login page
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        Referrer-Policy
      </code>
    </td>
    
    <td>
      <code>
        no-referrer
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        Cross-Origin-Opener-Policy
      </code>
    </td>
    
    <td>
      <code>
        same-origin
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        Cross-Origin-Embedder-Policy
      </code>
    </td>
    
    <td>
      <code>
        credentialless
      </code>
      
       in production
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        Permissions-Policy
      </code>
    </td>
    
    <td>
      camera, microphone, geolocation, display-capture, fullscreen all denied
    </td>
  </tr>
  
  <tr>
    <td>
      Request size limit
    </td>
    
    <td>
      2 MB (8 MB multipart)
    </td>
  </tr>
  
  <tr>
    <td>
      Rate limit
    </td>
    
    <td>
      150 requests / 5 min / IP
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        X-Powered-By
      </code>
    </td>
    
    <td>
      removed
    </td>
  </tr>
  
  <tr>
    <td>
      Subresource integrity
    </td>
    
    <td>
      on for bundled assets
    </td>
  </tr>
</tbody>
</table>

<caution>

**security: { strict: true } needs testing against Convex storage.** Strict mode raises `Cross-Origin-Embedder-Policy` to `require-corp`, under which every cross-origin subresource must send `Cross-Origin-Resource-Policy`. If Convex's storage responses don't, your images and media break. The default `credentialless` has no such requirement. Strict mode also sets `default-src 'none'`, so directives the module doesn't manage need filling in. Verify a production build before enabling it.

</caution>

CSRF protection (`nuxt-csurf`) is **off** by default in nuxt-security. Better Auth carries its own CSRF defences for the auth routes; if you add your own state-changing endpoints, enable it or check origins yourself.

## Production checklist

Tick these off before you deploy; the page remembers your ticks.

<checklist id="security-production">
<checklist-item>

**Install nuxt-security.** Without it there is no CSP and no security headers — the module's own hardening is only part of the picture.

</checklist-item>

<checklist-item>

**Check the CSP against a production build**, not `nuxt dev` — `connect-src` only exists in production.

</checklist-item>

<checklist-item>

**Add third-party origins** to `connect-src` (Clerk, Auth0, analytics, error reporting).

</checklist-item>

<checklist-item>

**Use resolveAuthRedirect()** on your login page. Never navigate to `route.query.redirect` directly.

</checklist-item>

<checklist-item>

**Set crossDomainCallbackRoute** if you use `crossDomainClient()`.

</checklist-item>

<checklist-item>

**Keep authenticated pages out of shared caches** and away from prerendering.

</checklist-item>

<checklist-item>

**Terminate behind a proxy that overwrites X-Forwarded-For**, or the rate limiter is bypassable.

</checklist-item>

<checklist-item>

**Enforce upload limits in your Convex functions** — direct-to-Convex uploads skip Nitro's limiter.

</checklist-item>

<checklist-item>

**Set NUXT_PUBLIC_CONVEX_SITE_URL** to the `.site` domain. The module errors on a `.convex.cloud` value here, but a wrong custom domain is silent.

</checklist-item>

<checklist-item>

**Keep dependencies current.** The repository runs Dependabot with a release-age cooldown; do the same in your app.

</checklist-item>
</checklist>

## Reporting a vulnerability

Found something in this module? Report it privately through [GitHub Security Advisories](https://github.com/qruto/nuxt-convex-module/security/advisories/new) — not a public issue. The full policy, including scope and response times, is in [SECURITY.md](https://github.com/qruto/nuxt-convex-module/blob/main/SECURITY.md).

Issues in Convex, Better Auth, or Polar themselves belong to those projects' security processes. If you're unsure which side a finding lands on, report it here and it will be routed.


## Sitemap

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