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
pnpm add better-auth@1.6.23 @convex-dev/better-auth@0.12.5Use the versions required by your installed Better Convex Nuxt release. Do not allow the three auth packages to drift independently.
Register the Convex component
import betterAuth from '@convex-dev/better-auth/convex.config'
import { defineApp } from 'convex/server'
const app = defineApp()
app.use(betterAuth)
export default appimport { getAuthConfigProvider } from '@convex-dev/better-auth/auth-config'
import type { AuthConfig } from 'convex/server'
export default {
providers: [getAuthConfigProvider()],
} satisfies AuthConfigCreate Better Auth
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:
import { httpRouter } from 'convex/server'
import { authComponent, createAuth } from './auth'
const http = httpRouter()
authComponent.registerRoutesLazy(http, createAuth)
export default httpRun pnpm exec convex dev so component types and routes are generated.
Set the auth environment
Set secrets in the Convex environment:
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:
NUXT_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud
NUXT_PUBLIC_CONVEX_SITE_URL=https://your-deployment.convex.siteThe 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
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
<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.