Skip to main content

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

bash
pnpm add better-auth@1.7.6 @better-auth/core@1.7.6 @better-auth/oauth-provider@1.7.6

All 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:

.env.example
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

bash
pnpm exec better-convex init

The command works only with a development deployment. It does these steps and asks before each one:

  1. It shows the files it will create and writes them. It stops without changes if a file already exists with other content.
  2. It generates the auth database schema in convex/betterAuth/.
  3. It asks for the site URL. Press Enter to accept http://localhost:3000.
  4. It sets SITE_URL and BETTER_AUTH_SECRETS in Convex.
  5. It creates the proxy secret BCN_AUTH_PROXY_IP_SECRET. It writes the secret to .env.local and sets the same value in Convex. Nuxt signs each auth request with this secret, and Convex checks the signature. If .env.local already has a value, the command uses it.
  6. 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:

convex/auth.ts
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:

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

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

convex/http.ts adds the /api/auth/* routes to Convex:

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

import { auth } from './auth'

const http = httpRouter()
auth.registerRoutes(http)
export default http

convex/convex.config.ts registers the auth component under the name betterAuth:

convex/convex.config.ts
import { defineApp } from 'convex/server'

import betterAuth from './betterAuth/convex.config'

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

Do not edit convex/betterAuth/schema.ts or schemaMetadata.ts by hand. They are generated.

Turn on auth in Nuxt

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,
    },
  },
})

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:

bash
pnpm exec nuxt dev --dotenv .env.local

Add the sign-in page

Protected pages send anonymous visitors to /auth/signin by default. Create that page:

app/pages/auth/signin.vue
<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, or null. It accepts only paths on your own site.
  • client is the Better Auth client. Call its methods only in the browser, for example in event handlers. On the server they throw a ConvexCallError with code CLIENT_UNAVAILABLE.

Show the user and a sign-out button

app/app.vue
<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:

convex/auth.ts
export const auth = createBetterConvexAuth<DataModel>(components.betterAuth, {
  defineSessionClaims: ({ user }) => ({ email: user.email }),
})

See Session claims.

Try it

  1. Open http://localhost:3000/auth/signin.
  2. Click Create an account and create an account with a password of at least 15 characters.
  3. Sign in. The app opens / and the header shows your email.
  4. Reload the page. You are still signed in.
  5. 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.