Skip to main content

Add Authentication

Add the maintained Better Auth and Convex integration to the working Nuxt app.

This page adds email/password authentication without an application user projection. Better Auth remains the only auth-user source.

Install the auth packages

bash
pnpm add better-auth@1.7.0-rc.2 @better-auth/oauth-provider@1.7.0-rc.2

Better Convex Nuxt contains the maintained Convex component, adapter, JWT integration, schema, and Nuxt proxy. Better Auth and the OAuth Provider are exact optional peers: a Convex-only app installs neither, while an auth-enabled app owns both versions explicitly. Better Auth already owns its Kysely runtime, so do not add a standalone Better Convex Kysely peer.

Register the Convex component

convex/convex.config.ts
import betterAuth from '@lupinum/better-convex-nuxt/convex-auth/convex.config'
import { defineApp } from 'convex/server'

const app = defineApp()
app.use(betterAuth, { name: 'betterAuth' })

export default app
convex/auth.config.ts
import { getConvexAuthProvider } from '@lupinum/better-convex-nuxt/convex-auth'
import type { AuthConfig } from 'convex/server'

export default {
  providers: [getConvexAuthProvider()],
} satisfies AuthConfig

Create Better Auth

convex/auth.ts
import { betterAuth } from 'better-auth'
import { jwt } from 'better-auth/plugins'
import {
  convexAuth,
  createAuthComponent,
  getConvexAuthProvider,
  requireAuthOrigin,
  type AuthCtx,
} from '@lupinum/better-convex-nuxt/convex-auth'

import { components } from './_generated/api'
import type { DataModel } from './_generated/dataModel'

export const authComponent = createAuthComponent<DataModel>(components.betterAuth)

export async function createAuth(ctx: AuthCtx<DataModel>) {
  try {
    const siteUrl = requireAuthOrigin('SITE_URL')
    const convexSiteUrl = requireAuthOrigin('CONVEX_SITE_URL')
    const secret = process.env.BETTER_AUTH_SECRETS
    if (!secret) throw new Error('BETTER_AUTH_SECRETS is required')
    if (secret.length < 32) {
      throw new Error('BETTER_AUTH_SECRETS must contain at least 32 random characters')
    }
    const authIssuer = `${siteUrl}/api/auth`

    const auth = betterAuth({
      account: { encryptOAuthTokens: true, storeAccountCookie: false },
      advanced: { ipAddress: { ipAddressHeaders: ['x-bcn-verified-client-ip'] } },
      basePath: '/api/auth',
      baseURL: siteUrl,
      database: authComponent.adapter(ctx),
      disabledPaths: [
        '/token',
        '/get-access-token',
        '/refresh-token',
        '/.well-known/openid-configuration',
        '/oauth2/register',
        '/oauth2/introspect',
        '/oauth2/userinfo',
        '/oauth2/end-session',
      ],
      emailAndPassword: { autoSignIn: false, enabled: true, minPasswordLength: 15 },
      plugins: [
        jwt({
          disableSettingJwtHeader: true,
          jwks: {
            disablePrivateKeyEncryption: false,
            gracePeriod: 21 * 60,
            keyPairConfig: { alg: 'RS256' },
          },
          jwt: { audience: authIssuer, expirationTime: '10m', issuer: authIssuer },
        }),
        convexAuth({
          authConfig: { providers: [getConvexAuthProvider()] },
          sessionJwt: {
            audience: 'convex',
            expirationTime: '15m',
            issuer: convexSiteUrl,
          },
        }),
      ],
      rateLimit: { enabled: true, modelName: 'rateLimit', storage: 'database' },
      trustedOrigins: [siteUrl],
      verification: { storeIdentifier: 'hashed' },
    })

    await auth.$context
    return auth
  } catch {
    throw new Error('AUTH_CONFIG_INVALID')
  }
}

export const { rotateSigningKey } = authComponent.jwksOperatorFunctions(createAuth)

Register the HTTP routes. The wrapper validates and rewrites the request to the configured public origin before Better Auth sees it:

convex/http.ts
import { httpRouter } from 'convex/server'

import { authComponent, createAuth } from './auth'

const http = httpRouter()
authComponent.registerRoutes(http, createAuth)

export default http

Convex verifies session JWTs against the deployment-owned CONVEX_SITE_URL/api/auth/jwks route. The Nuxt same-origin proxy exposes the same public key set at SITE_URL/api/auth/jwks for browser-facing inspection; the verifier never depends on the host app being publicly reachable during local development.

Run pnpm exec better-convex-nuxt-convex dev so component types and routes are generated.

Set the auth environment

Set secrets in the Convex environment:

bash
export BCN_AUTH_PROXY_IP_SECRET="$(openssl rand -base64 32)"
pnpm exec better-convex-nuxt-convex env set SITE_URL http://localhost:3000
printf '1:%s' "$(openssl rand -base64 32)" | pnpm exec better-convex-nuxt-convex env set BETTER_AUTH_SECRETS
printf '%s' "$BCN_AUTH_PROXY_IP_SECRET" | pnpm exec better-convex-nuxt-convex env set BCN_AUTH_PROXY_IP_SECRET

Convex injects CONVEX_SITE_URL into functions as a deployment-owned built-in; do not try to set it with convex env set. The selected deployment still writes its public HTTP Actions URL to your local environment for Nuxt configuration.

Your Nuxt environment needs the deployment and HTTP Actions URLs:

.env.local
SITE_URL=http://localhost:3000
NUXT_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud
NUXT_PUBLIC_CONVEX_SITE_URL=https://your-deployment.convex.site
BCN_AUTH_PROXY_IP_SECRET=
BCN_AUTH_TRUSTED_CLIENT_IP_HEADER=

BETTER_AUTH_SECRETS is versioned; put the newest version first when rotating it. The blank proxy-secret example deliberately fails closed. Start Nuxt from the same shell so it inherits the exported BCN_AUTH_PROXY_IP_SECRET, or inject that exact value into the Nuxt process with your secret manager. Do not print or commit it, and never expose either secret through public runtime config. Configure NUXT_PUBLIC_CONVEX_SITE_URL explicitly for local or custom HTTP Actions domains; the Convex function runtime continues to use its deployment-owned built-in.

The trusted client-IP header may stay blank only on the exact loopback origin shown here. For any preview or production HTTPS origin, set it to one header that your ingress overwrites with exactly one client IP. The module rejects startup and requests when that production boundary is absent. Header rewriting is not sufficient if clients can bypass the ingress: restrict the Nuxt origin so public traffic reaches it only through that ingress, or independently authenticate ingress requests at the origin.

Provision the signing key

After the final schema, environment, functions, and HTTP routes are deployed, run the internal operator action before admitting auth traffic:

bash
pnpm exec better-convex-nuxt-convex run auth:rotateSigningKey '{}'

On a fresh deployment, require previousKids to be empty. Once Nuxt is running behind a closed traffic gate, verify the returned newKid appears at /api/auth/jwks. Keep later rotations additive; never delete all key rows. The delegated OAuth and MCP guide contains the complete rotation and production recovery ceremony.

Enable the Nuxt auth runtime

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@lupinum/better-convex-nuxt'],

  convex: {
    auth: {
      origin: process.env.SITE_URL ?? 'http://localhost:3000',
      trustedClientIpHeader: process.env.BCN_AUTH_TRUSTED_CLIENT_IP_HEADER,
    },
  },
})

An auth object explicitly enables the auth build. origin is required and must match the exact public Nuxt origin used as SITE_URL; the loopback fallback is for local development only. Omission disables auth.

The module registers the same-origin /api/auth/* Nitro proxy, server and client auth plugins, auth composable, and route middleware.

Add a combined auth page

app/pages/auth.vue
<script setup lang="ts">
const { status, user, client } = useConvexAuth()
const mode = ref<'sign-in' | 'sign-up'>('sign-up')
const name = ref('')
const email = ref('')
const password = ref('')
const message = ref<string | null>(null)

async function submit() {
  message.value = null
  if (!client) {
    message.value = 'Authentication is still starting'
    return
  }

  const result =
    mode.value === 'sign-up'
      ? await client.signUp.email({
          name: name.value,
          email: email.value,
          password: password.value,
        })
      : await client.signIn.email({ email: email.value, password: password.value })

  if (result.error) {
    message.value = mode.value === 'sign-up' ? 'Sign up failed' : 'Sign in failed'
    return
  }

  message.value = mode.value === 'sign-up' ? 'Account created. Sign in next.' : 'Signed in.'
  password.value = ''
}
</script>

<template>
  <main>
    <h1>Account</h1>

    <p v-if="status === 'loading'">Checking session…</p>

    <section v-else-if="status === 'authenticated'">
      <p>Signed in as {{ user?.email ?? user?.name ?? 'user' }}</p>
      <button @click="client?.signOut()">Sign out</button>
    </section>

    <form v-else @submit.prevent="submit">
      <label>
        Mode
        <select v-model="mode">
          <option value="sign-up">Sign up</option>
          <option value="sign-in">Sign in</option>
        </select>
      </label>

      <label v-if="mode === 'sign-up'">
        Name
        <input v-model="name" autocomplete="name" required />
      </label>

      <label>
        Email
        <input v-model="email" type="email" autocomplete="email" required />
      </label>

      <label>
        Password
        <input v-model="password" type="password" minlength="15" required />
      </label>

      <button type="submit">Continue</button>
      <p v-if="message">{{ message }}</p>
    </form>
  </main>
</template>

Verify the flow

Run Convex and Nuxt in separate terminals. Create an account, sign in, reload the page, then sign out.

The session should survive the authenticated reload. The UI should not briefly render the anonymous form before the hydrated auth state settles.

Authentication identifies the caller. Continue with Protect data to enforce access.