---
title: "Auth setup"
description: "The scaffolded Convex files, setupAuth, and how Better Auth is wired across components."
canonical_url: "https://nuxt-backend.dev/docs/auth/setup"
---
# Auth setup

> The scaffolded Convex files, setupAuth, and how Better Auth is wired across components.

The `nuxt-backend/auth` entrypoint runs in your **Convex backend** (`backend/`), not the Vue app. Its one function, `setupAuth(...)`, builds the app's auth: the auth instance for each request, the functions the scaffold re-exports, and the agent token exchange. Every auth instance comes from it, so the HTTP routes, session reads and the agent exchange always run the same configuration.

## The scaffolded files

If you let the module scaffold the files, you can keep them as generated. If you create them manually, they look like this:

```ts [backend/convex.config.ts]
import { defineApp } from 'convex/server'
import { backendEnv } from 'nuxt-backend/app'
import backend from 'nuxt-backend/convex.config'
import aggregate from '@convex-dev/aggregate/convex.config'
import migrations from '@convex-dev/migrations/convex.config'
import persistentTextStreaming from '@convex-dev/persistent-text-streaming/convex.config'
import polar from '@convex-dev/polar/convex.config'
import rateLimiter from '@convex-dev/rate-limiter/convex.config'
import workflow from '@convex-dev/workflow/convex.config'

const app = defineApp({ env: backendEnv })

// Components are isolated from the app env — forward the email config by
// reference so the deployment's values reach the backend component (auth,
// email, billing cache, gifts — the email provider is nested inside).
app.use(backend, {
  env: {
    EMAIL_API_KEY: app.env.EMAIL_API_KEY,
    EMAIL_FROM: app.env.EMAIL_FROM,
    EMAIL_TEST_MODE: app.env.EMAIL_TEST_MODE,
    EMAIL_WEBHOOK_SECRET: app.env.EMAIL_WEBHOOK_SECRET,
  },
})
app.use(aggregate)
app.use(migrations)
app.use(persistentTextStreaming)
app.use(polar)
app.use(rateLimiter)
app.use(workflow)

export default app
```

<note>

The module scaffolds `convex.config.ts` for you (so does `npx nuxt-backend init`). Convex discovers components from the `convex.config` imports it meets while bundling this file, and current Convex backends crash the push (`start_push 500`) when such an import is reached through an intermediate module or `app.use` runs in a loop — so the definition is deliberately explicit: every component imported and mounted in the root file, one `app.use` per component. `backendEnv` (from `nuxt-backend/app`) is the typed env contract — `AUTH_SECRET` + `SITE_URL` required, the rest optional with designed fallbacks.

</note>

```ts [backend/auth.config.ts]
export { default } from 'nuxt-backend/auth.config'
```

```ts [backend/auth.ts]
import { setupAuth } from 'nuxt-backend/auth'
import { components, internal } from './_generated/api'
import { query } from './_generated/server'
import { email } from './email'
import { rateLimiter } from './rateLimiter'
import { workflow } from './workflows'

export const {
  authComponent,
  createAuth,
  getAuthUser,
  authConfig,
  listWorkspaces,
  listWorkspaceMembers,
  updateProfile,
  listSessions,
  revokeSession,
  // The agent token exchange (mounted at /mcp/exchange by http.ts) — the
  // app's OAuth-protected MCP endpoint calls Convex through it.
  mcp,
} = setupAuth(components, query, {
  // Roles/permissions (admin plugin) and workspaces (organization plugin)
  // come built in, with a personal workspace per user and emailed
  // workspace invitations with an /accept-invitation page. Customize them
  // with `admin: { … }` and `organization: { … }`.
  //
  // Every auth email (sign-in and email-change codes, welcome,
  // invitations) goes out through email.ts, which holds the templates.
  email,
  // Throttle code sends and other auth-sensitive flows.
  rateLimiter,
  // Start the onboarding sequence (workflows.ts) when a user signs up. The
  // packaged welcome email has already gone out.
  onUserCreated: async (ctx, user) => {
    await workflow.start(ctx, internal.workflows.onSignup, {
      userId: user.id,
      email: user.email,
      name: user.name,
    })
  },
})
```

```ts [backend/http.ts]
import { registerBackendRoutes } from 'nuxt-backend/http'
import { httpRouter } from 'convex/server'
import { ai } from './ai'
import { authComponent, createAuth, mcp } from './auth'
import { billing } from './billing'
import { email } from './email'

// Mounts every inbound route the backend handles: the auth routes and the
// agent token exchange (/mcp/exchange) behind the app's MCP endpoint, the
// billing events endpoint (/billing/events, BILLING_WEBHOOK_SECRET) that
// keeps the reactive feature/credit cache fresh and fulfils gifts, the
// email events endpoint (/email/events, EMAIL_WEBHOOK_SECRET) that makes
// useEmailStatus reactive, and the metered AI stream endpoint (/ai/stream).
// Every webhook delivery is signature-verified, deduped, logged, and
// fail-closed (503 until its secret is set). React to events via
// `setupBilling({ events })` / `setupEmail({ events })`.
const http = httpRouter()
registerBackendRoutes(http, {
  auth: { authComponent, createAuth, mcp },
  billing,
  email,
  ai,
})

export default http
```

That setup mounts the packaged all-in-one `backend` component (plus the upstream components), configures Convex to trust its tokens, registers the auth, [webhook, AI stream, and agent exchange routes](https://nuxt-backend.dev/docs/ship/production/webhooks), and exposes `api.auth.getAuthUser` for app queries. The scaffolded `backend/auth.ts` re-exports what `setupAuth` returns — `authComponent`, `createAuth`, `getAuthUser`, `authConfig`, `listWorkspaces`, `listWorkspaceMembers`, `updateProfile`, `listSessions`, `revokeSession`, and `mcp` — for app Convex functions (and `http.ts`) to import. The `email` / `rateLimiter` / `workflow` imports come from the scaffold's sibling files (`email.ts`, `rateLimiter.ts`, `workflows.ts`).

## `setupAuth(...)`

```ts
function setupAuth<DM>(
  components: AuthSetupComponents,  // the whole generated `components` object — uses `backend`
  queryBuilder: QueryBuilder<DM, 'public'>,
  options: SetupAuthOptions<DM>,      // `email` is required
): {
  authComponent: AuthComponent
  createAuth: (ctx) => AuthInstance     // the auth instance for one request ctx
  getAuthUser: RegisteredQuery           // the signed-in user; throws Unauthenticated when signed out
  authConfig: RegisteredQuery            // the page paths auth emails and agents link to, for doctor
  listWorkspaces: RegisteredQuery        // the caller's workspaces
  listWorkspaceMembers: RegisteredQuery  // members of the active workspace
  updateProfile: RegisteredMutation      // { name } — name only, by design
  listSessions: RegisteredQuery          // the caller's device sessions, by id (never tokens)
  revokeSession: RegisteredMutation      // { sessionId } — one of the caller's own sessions
  mcp: McpExchange                       // the agent token exchange, mounted by registerBackendRoutes({ auth })
}
```

`updateProfile` takes `{ name }` and nothing else, by design: an email change takes a code from each address (`useEmailChange()`, see [Profile](https://nuxt-backend.dev/docs/auth/accounts/profile)), so neither a profile page nor an agent can move an account to an unproven address through it. The avatar is edited from the client with `useAuth().updateUser({ image })`.

`listSessions` and `revokeSession` back `useSessions()` and the `/security` page. Better Auth's own `/list-sessions` refuses any session signed in more than a day ago (its `freshAge`), so the list reads the session table instead and works for sessions of any age. Rows carry ids, never session tokens. `revokeSession` looks the id up among the caller's own sessions, then signs it out through Better Auth's `/revoke-session`, so your session database hooks still run. Agent tokens belong to no session: the list is `null` for them and a revoke is refused.

Session tokens carry the active workspace (`activeOrganizationId`) and, while an admin impersonates the user, the admin's id (`impersonatedBy`), so a Convex function can refuse what an impersonating admin must not do: `Authorization.requireUser` returns both (see [Roles](https://nuxt-backend.dev/docs/auth/access/roles)).

### Codes, not links

Every proof of a mailbox is a one-time code: sign-in, verifying an address, and both halves of an email change (a code from the current address, then one from the new address). No link signs anyone in or moves an account, so an email someone forwards, or a scanner that opens every link, changes nothing: Better Auth's link routes (`/send-verification-email`, `/verify-email`, `/change-email`) are off. The one link left is the account-deletion confirmation, until deletion moves behind a recent sign-in.

The database keeps only a hash of each code, keyed by `AUTH_SECRET`, so reading the table yields no code and no way to compute one. Session tokens and agents' OAuth tokens are another matter: Better Auth stores them as they are, so read access to the deployment's data is access to every signed-in account. [Security](https://nuxt-backend.dev/docs/ship/production/security#passwordless-sign-in-and-its-rate-limits) has the rest: the limits on guessing, the routes the package turns off, and what an administrator acting as someone else cannot do.

## Cross-component wiring

The codes, the welcome email, the deletion confirmation and workspace invitations go out through `email`, the app's [`setupEmail`](https://nuxt-backend.dev/docs/email/sending) instance, which holds their [templates](https://nuxt-backend.dev/docs/email/templates). Beside the plugin options, `setupAuth` takes `email`, `rateLimiter`, `canSignIn`, `onUserCreated` and `onUserDeleted`:

- `email` (required) is the instance every auth email is sent through. Restyle one with `setupEmail(components, { templates })`, not here
- `rateLimiter` throttles sending codes — per address (`emailOtp`, keyed by a SHA-256 of the email), per account for email changes (`emailChange`), and deployment-wide (`emailOtpGlobal`, 300/hour) — and checking them, per address (`emailOtpAttempt`, 10/hour), as well as the [privileged routes](https://nuxt-backend.dev/docs/ship/production/rate-limits#privileged-routes); a closed limit answers `TOO_MANY_REQUESTS` with `retryAfter` in the body, which `useLoginFlow()` and `useEmailChange()` show as *"Try again in N seconds"*
- `canSignIn(ctx, { email, isNewUser, purpose })` gates sign-in and sign-up — an invite-only launch, an allow-list, a closed beta. Return `true` or `{ allowed: false, message? }`. It is asked before an OTP is sent for a sign-in request (`purpose: 'sign-in'`, with `isNewUser` from the auth tables), before any account is created (`'user-create'`), before a session is created for an existing account (`'session'` — passkey sign-in included), and before an account moves to a new address (`'email-change'`, with the new address as `email` and `isNewUser: false`; asked before a code goes to it and again before the account moves). A refusal is a `FORBIDDEN` `APIError` carrying the message, which `useLoginFlow().error` shows; the default message is "Sign-in is by invitation right now."
- `onUserCreated(ctx, user)` runs on signup (the ctx is a full mutation/action ctx, so it can start a Workflow)
- `onUserDeleted(ctx, user)` runs after an account is deleted — erase the app's own rows, forget the billing cache (`billing.forgetEntity(ctx, user.id)`). It chains after any `user.deleteUser.afterDelete` you pass in `authOptions`, and the emailed delete confirmation stays on

```ts [backend/auth.ts]
export const { authComponent, createAuth, getAuthUser } = setupAuth(components, query, {
  email, // from email.ts: the templates and the transport (the EMAIL_* env vars)
  rateLimiter,
  onUserCreated: async (ctx, user) => {
    await workflow.start(ctx, internal.workflows.onSignup, {
      userId: user.id, email: user.email, name: user.name,
    })
  },
})
```

<warning>

A refusal at `'sign-in'` answers whoever typed the address. A gate that refuses new addresses there, `isNewUser ? { allowed: false } : true`, tells anyone which addresses have an account, at the pace the code limits allow. To keep that private, allow every `'sign-in'` and refuse at `'user-create'` instead: the code goes out either way, and only the person holding the mailbox, who has the code, reads the refusal. The cost is a code email to every address that asks.

</warning>

Any `databaseHooks` you pass in `authOptions` **compose** with the package's own: per model and operation, the package hook runs first and yours second — a `before` hook receives the entity as the package left it (a new session already carries its `activeOrganizationId`), and a `false` from either side aborts the write.

To refuse with a message instead — from one of those hooks, or from an `organization.organizationHooks` hook such as `beforeCreateInvitation` — throw `APIError`, re-exported from `nuxt-backend/auth` so there's no `better-auth` dependency to add: `throw new APIError('BAD_REQUEST', { message: 'Invite a company address.' })`. The client gets the status and the message.

Billing links provider customers to auth users, and its reactive feature/credit cache lives **inside** the `backend` component — so subscriptions, discounts, feature-gating, prepaid credits, and gifts work out of the box with nothing added to your schema.

<note>

Email delivery is configured by the optional `EMAIL_*` env vars — until `EMAIL_API_KEY` is set, sends no-op, and a code request or an invitation that could not arrive is refused with a message the form shows (sandbox addresses still get theirs; see [Deliverability](https://nuxt-backend.dev/docs/email/deliverability)). During local dev, `NUXT_BACKEND_LOG_OTP=1` (which `npm run dev` sets on a dev deployment without a transport) echoes the codes to the `convex dev` console instead.

</note>

## Auth route and options

Zero-config mode mounts the packaged components and registers Better Auth HTTP routes from `backend/http.ts`. The default route is `/api/auth`.

### Extend Better Auth options

To keep the packaged component but extend Better Auth options such as providers or plugins, pass `authOptions` to `setupAuth(...)`:

```ts [backend/auth.ts]
import { setupAuth } from 'nuxt-backend/auth'
import { components } from './_generated/api'
import { query } from './_generated/server'
import { email } from './email'

export const {
  authComponent,
  createAuth,
  getAuthUser,
} = setupAuth(components, query, {
  email,
  authOptions: {
    appName: 'Acme',
    emailAndPassword: {
      enabled: true,
    },
  },
})
```

<note>

`emailAndPassword` is off by default — the package is passwordless (passkey + OTP): the shipped `<AuthForm>` and `useLoginFlow()` offer only those two, the password-reset code routes are off, and the [default rate limits](https://nuxt-backend.dev/docs/ship/production/rate-limits) carry no password limits. Enabling it, as above, is supported, and turns the reset-code routes on under the code limits — but the sign-in form is yours to build, and none of the package's named limits covers `/sign-in/email` or `/sign-up/email`. Better Auth's own per-path limiter is off by default (it counts in memory, which on Convex lasts one function call): turn it on with `authOptions.rateLimit: { enabled: true, storage: 'database' }`, as [Rate limiting](https://nuxt-backend.dev/docs/ship/production/rate-limits) shows, or add named limits of your own.

</note>

### Change the auth route

If you want a different auth route, update all three places so they match — the Nuxt module's `backend.authRoute`, Better Auth `basePath`, and the Convex auth config.

`backend/convex.config.ts` stays exactly as [scaffolded](https://nuxt-backend.dev/docs/auth/setup#the-scaffolded-files) — the auth route is not part of the app definition.

```ts [backend/auth.ts]
export const {
  authComponent,
  createAuth,
  getAuthUser,
} = setupAuth(components, query, {
  email,
  basePath: '/internal/auth',
})
```

```ts [backend/auth.config.ts]
import { defineBackendAuthConfig } from 'nuxt-backend/auth.config'

export default defineBackendAuthConfig({
  basePath: '/internal/auth',
})
```

<warning>

If you change the Nuxt module's `backend.authRoute`, keep these Convex component helpers on the same path. The Nuxt proxy route, Better Auth `basePath`, and `defineBackendAuthConfig({ basePath })` must all match.

</warning>

<note>

The `backend` component owns the auth schema. Keep data of your own in app tables keyed by the user id rather than in extra fields on `user`, and leave out Better Auth plugins that add tables or fields: the packaged schema has no room for them.

</note>


## Sitemap

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