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.6.23 @convex-dev/better-auth@0.12.5

Use the versions required by your installed Better Convex Nuxt release. Do not allow the three auth packages to drift independently.

Register the Convex component

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

const app = defineApp()
app.use(betterAuth)

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

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

Create Better Auth

convex/auth.ts
import { createClient, type AuthFunctions, type GenericCtx } from '@convex-dev/better-auth'
import { convex } from '@convex-dev/better-auth/plugins'
import { betterAuth } from 'better-auth'

import { components, internal } from './_generated/api'
import type { DataModel } from './_generated/dataModel'
import authConfig from './auth.config'

const authFunctions: AuthFunctions = internal.auth

export const authComponent = createClient<DataModel>(components.betterAuth, {
  authFunctions,
})

function requireAuthEnvironment() {
  const siteUrl = process.env.SITE_URL
  const secret = process.env.BETTER_AUTH_SECRET

  if (!siteUrl) throw new Error('SITE_URL is required')
  if (!secret || secret.trim() !== secret || secret.length < 32) {
    throw new Error('BETTER_AUTH_SECRET must contain at least 32 random characters')
  }

  const parsed = new URL(siteUrl)
  const isLoopback = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname)
  if (parsed.origin !== siteUrl) throw new Error('SITE_URL must be an exact origin')
  if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && isLoopback)) {
    throw new Error('SITE_URL must use HTTPS except for loopback development')
  }

  return { siteUrl, secret }
}

export function createAuth(ctx: GenericCtx<DataModel>) {
  const { siteUrl, secret } = requireAuthEnvironment()

  return betterAuth({
    baseURL: siteUrl,
    secret,
    database: authComponent.adapter(ctx),
    emailAndPassword: {
      enabled: true,
      minPasswordLength: 15,
      autoSignIn: false,
    },
    plugins: [convex({ authConfig })],
    trustedOrigins: [siteUrl],
  })
}

Register the HTTP routes lazily so environment validation happens per request rather than during module evaluation:

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

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

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

export default http

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

Set the auth environment

Set secrets in the Convex environment:

bash
pnpm exec convex env set SITE_URL http://localhost:3000
pnpm exec convex env set BETTER_AUTH_SECRET "$(openssl rand -base64 32)"

Your Nuxt environment needs the deployment and HTTP Actions URLs:

.env.local
NUXT_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud
NUXT_PUBLIC_CONVEX_SITE_URL=https://your-deployment.convex.site

The site URL can be derived from a standard .convex.cloud deployment URL. Set it explicitly for local or custom HTTP Actions domains.

Enable the Nuxt auth runtime

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

  convex: {
    auth: {},
  },
})

Omitting auth also enables it. Use the explicit object here to make the change visible.

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

Add a combined auth page

app/pages/auth.vue
<script setup lang="ts">
const { status, user, signIn, signUp, signOut } = 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

  const result =
    mode.value === 'sign-up'
      ? await signUp.email({ name: name.value, email: email.value, password: password.value })
      : await 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="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.