Add authentication
Add Better Auth email and password sign-in to the todo app.
Add email and password sign-in with Better Auth. Better Auth stores users and sessions in a Convex component. Nuxt forwards /api/auth/* requests to it.
Keep better-convex convex dev running in its own terminal for this whole page.
Install Better Auth
pnpm add better-auth@1.7.6 @better-auth/core@1.7.6 @better-auth/oauth-provider@1.7.6All three packages must be exactly 1.7.6. The module does not work with other versions.
List the environment variables
Never commit .env.local. The next step writes a secret to it. Commit a .env.example with the same names and empty secret values instead:
CONVEX_DEPLOYMENT=
CONVEX_URL=
CONVEX_SITE_URL=
SITE_URL=http://localhost:3000
BCN_AUTH_PROXY_IP_SECRET=
BCN_AUTH_TRUSTED_CLIENT_IP_HEADER=Nuxt needs CONVEX_SITE_URL, the https://….convex.site address of your deployment. The Convex command wrote it to .env.local. Inside Convex functions, CONVEX_SITE_URL is a built-in variable. Do not set it with convex env set.
Generate the auth files
pnpm exec better-convex initThe command works only with a development deployment. It does these steps and asks before each one:
- It shows the files it will create and writes them. It stops without changes if a file already exists with other content.
- It generates the auth database schema in
convex/betterAuth/. - It asks for the site URL. Press Enter to accept
http://localhost:3000. - It sets
SITE_URLandBETTER_AUTH_SECRETSin Convex. - It creates the proxy secret
BCN_AUTH_PROXY_IP_SECRET. It writes the secret to.env.localand sets the same value in Convex. Nuxt signs each auth request with this secret, and Convex checks the signature. If.env.localalready has a value, the command uses it. - It creates the first key that signs session tokens.
If step 6 fails, wait until the convex dev terminal shows that the functions are ready. Then run pnpm exec better-convex init again. Running it again is safe.
Review the generated files
convex/auth.ts creates the auth setup:
import { createBetterConvexAuth } from '@lupinum/better-convex-nuxt/better-auth/server'
import { components } from './_generated/api'
import type { DataModel } from './_generated/dataModel'
export const auth = createBetterConvexAuth<DataModel>(components.betterAuth)
export const createAuth = auth.createAuth
export const { ensureSigningKey, pruneSigningKeys, rotateSigningKey } = auth.jwksOperatorFunctions()
export const { onCreate, onUpdate, onDelete } = auth.triggerFunctions()The default settings turn on email and password sign-in with minPasswordLength: 15 and autoSignIn: false. A new user must sign in after sign-up. Pass options to createBetterConvexAuth to change sign-in methods. See Better Auth setup.
convex/auth.config.ts tells Convex to accept the session tokens:
import { getConvexAuthProvider } from '@lupinum/better-convex-nuxt/better-auth/server'
import type { AuthConfig } from 'convex/server'
export default { providers: [getConvexAuthProvider()] } satisfies AuthConfigconvex/http.ts adds the /api/auth/* routes to Convex:
import { httpRouter } from 'convex/server'
import { auth } from './auth'
const http = httpRouter()
auth.registerRoutes(http)
export default httpconvex/convex.config.ts registers the auth component under the name betterAuth:
import { defineApp } from 'convex/server'
import betterAuth from './betterAuth/convex.config'
const app = defineApp()
app.use(betterAuth, { name: 'betterAuth' })
export default appDo not edit convex/betterAuth/schema.ts or schemaMetadata.ts by hand. They are generated.
Turn on auth in Nuxt
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,
},
},
})The auth object turns on auth. origin must be the exact public address of your Nuxt app.
Warning: trustedClientIpHeader may be empty only on localhost. On any other origin, Nuxt refuses to start without it. Set it to the header your hosting platform writes with the client IP. See deployment.
The module now adds the /api/auth/* proxy, the useConvexAuth() composable, and route protection.
Restart Nuxt so it reads the new .env.local values:
pnpm exec nuxt dev --dotenv .env.localAdd the sign-in page
Protected pages send anonymous visitors to /auth/signin by default. Create that page:
<script setup lang="ts">
definePageMeta({ convexAuth: 'guest' })
const { client } = useConvexAuth()
const returnTo = useConvexAuthReturnTo()
const mode = ref<'sign-in' | 'sign-up'>('sign-in')
const name = ref('')
const email = ref('')
const password = ref('')
const message = ref<string | null>(null)
async function submit() {
message.value = null
if (mode.value === 'sign-up') {
const { error } = await client.signUp.email({
name: name.value,
email: email.value,
password: password.value,
})
if (error) {
message.value = 'Sign up failed'
return
}
mode.value = 'sign-in'
password.value = ''
message.value = 'Account created. Sign in next.'
return
}
const { error } = await client.signIn.email({ email: email.value, password: password.value })
if (error) {
message.value = 'Sign in failed'
return
}
await navigateTo(returnTo.value ?? '/')
}
</script>
<template>
<main>
<h1>{{ mode === 'sign-in' ? 'Sign in' : 'Create account' }}</h1>
<form @submit.prevent="submit">
<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">{{ mode === 'sign-in' ? 'Sign in' : 'Create account' }}</button>
</form>
<p v-if="message">{{ message }}</p>
<button type="button" @click="mode = mode === 'sign-in' ? 'sign-up' : 'sign-in'">
{{ mode === 'sign-in' ? 'Create an account' : 'I already have an account' }}
</button>
</main>
</template>convexAuth: 'guest'sends a signed-in user away from this page.useConvexAuthReturnTo()returns the page the visitor came from, ornull. It accepts only paths on your own site.clientis the Better Auth client. Call its methods only in the browser, for example in event handlers. On the server they throw aConvexCallErrorwith codeCLIENT_UNAVAILABLE.
Show the user and a sign-out button
<script setup lang="ts">
const { status, user, client } = useConvexAuth()
async function signOut() {
await client.signOut()
await navigateTo('/auth/signin')
}
</script>
<template>
<header v-if="status === 'authenticated'">
<span>Signed in as {{ user?.email }}</span>
<button @click="signOut">Sign out</button>
</header>
<NuxtPage />
</template>status is 'loading', 'anonymous', 'authenticated', or 'error'. The server already knows the session, so a signed-in page does not flash the anonymous state.
user is read from the Convex session token. By default the token carries only the user ID, so user.email is undefined. To show the email, add it to the token in convex/auth.ts:
export const auth = createBetterConvexAuth<DataModel>(components.betterAuth, {
defineSessionClaims: ({ user }) => ({ email: user.email }),
})See Session claims.
Try it
- Open
http://localhost:3000/auth/signin. - Click Create an account and create an account with a password of at least 15 characters.
- Sign in. The app opens
/and the header shows your email. - Reload the page. You are still signed in.
- Click Sign out.
The todo list is still public. Anyone can read and write it. Continue with Protect data.
Production
better-convex init does not work with a production deployment. Set the production secrets and the first signing key as described in deployment.