This guide walks you through setting up authentication with Better Auth and Convex. The @convex-dev/better-auth plugin acts as a "Sync Engine," automatically keeping your user data in sync between the Auth provider and your Convex database.
Install the Better Auth core and the Convex integration.
pnpm add better-auth @convex-dev/better-auth
bun add better-auth @convex-dev/better-auth
npm install better-auth @convex-dev/better-auth
These files are the same regardless of whether you use SSR or SPA mode.
Register the Better Auth component in your Convex configuration.
import betterAuth from '@convex-dev/better-auth/convex.config'
import { defineApp } from 'convex/server'
const app = defineApp()
app.use(betterAuth)
export default app
Create the auth config provider file.
import type { AuthConfig } from 'convex/server'
import { getAuthConfigProvider } from '@convex-dev/better-auth/auth-config'
export default {
providers: [getAuthConfigProvider()],
} satisfies AuthConfig
Add a users table to store user data synced from Better Auth.
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'
export default defineSchema({
users: defineTable({
authId: v.string(),
displayName: v.optional(v.string()),
email: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_auth_id', ['authId'])
.index('by_email', ['email']),
// Your existing tables
tasks: defineTable({
text: v.string(),
isCompleted: v.boolean(),
}),
})
This file configures Better Auth and defines Triggers to sync user data into your users table automatically.
import { createClient, type GenericCtx, type AuthFunctions } from '@convex-dev/better-auth'
import { convex } from '@convex-dev/better-auth/plugins'
import { betterAuth } from 'better-auth'
import { createUserSyncTriggers } from 'better-convex-nuxt/server'
import type { DataModel } from './_generated/dataModel'
import { components, internal } from './_generated/api'
import authConfig from './auth.config'
// Get URLs from environment
const siteUrl = process.env.SITE_URL! // Public app URL (Better Auth base URL when using the Nuxt proxy)
// Auth functions for triggers
const authFunctions: AuthFunctions = internal.auth
// Create the auth component client with triggers to sync users
export const authComponent = createClient<DataModel>(components.betterAuth, {
authFunctions,
triggers: createUserSyncTriggers({
table: 'users',
index: 'by_auth_id',
createDoc: ({ user, now }) => ({
authId: user._id,
displayName: user.name,
email: user.email,
avatarUrl: user.image ?? undefined,
createdAt: now,
updatedAt: now,
}),
patchDoc: ({ user, previousUser, now }) => {
const nameChanged = user.name !== previousUser.name
const emailChanged = user.email !== previousUser.email
const imageChanged = user.image !== previousUser.image
if (!nameChanged && !emailChanged && !imageChanged) return null
return {
...(nameChanged && { displayName: user.name }),
...(emailChanged && { email: user.email }),
...(imageChanged && { avatarUrl: user.image ?? undefined }),
updatedAt: now,
}
},
}),
})
// Factory function to create auth instance per request
export const createAuth = (ctx: GenericCtx<DataModel>) => {
return betterAuth({
baseURL: siteUrl,
database: authComponent.adapter(ctx),
emailAndPassword: {
enabled: true,
},
plugins: [convex({ authConfig })],
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day
},
trustedOrigins: [siteUrl],
})
}
// Export trigger handlers for the component
export const { onCreate, onUpdate, onDelete } = authComponent.triggersApi()
Expose the Better Auth routes on the Convex HTTP server.
import { httpRouter } from 'convex/server'
import { authComponent, createAuth } from './auth'
const http = httpRouter()
// Register all Better Auth routes (/api/auth/*)
authComponent.registerRoutes(http, createAuth)
export default http
npx convex dev is running to sync your schema and functions.Go to your Convex Dashboard → Settings → Environment Variables and add:
| Variable | Value |
|---|---|
BETTER_AUTH_SECRET | Generate with openssl rand -base64 32 |
SITE_URL | Your frontend URL, e.g., http://localhost:3000 |
Add to your .env.local:
CONVEX_DEPLOYMENT=dev:your-project
CONVEX_URL=https://your-project.convex.cloud
# Your Nuxt app URL (must match SITE_URL in Convex Dashboard)
SITE_URL=http://localhost:3000
siteUrl) is automatically derived from CONVEX_URL by replacing .convex.cloud with .convex.site. You only need to set CONVEX_SITE_URL if you use a custom domain.convex.siteUrl explicitly.convex: {
url: process.env.CONVEX_URL,
siteUrl: process.env.CONVEX_SITE_URL
}
Authentication is enabled by default - just set your Convex URL:
export default defineNuxtConfig({
modules: ['better-convex-nuxt'],
convex: {
url: process.env.CONVEX_URL,
// auth.enabled is true by default
},
})
The module automatically:
useConvexAuth)/api/auth/*siteUrl from your Convex URL (replacing .convex.cloud with .convex.site)convex: {
url: process.env.CONVEX_URL,
auth: {
enabled: false
}
}
siteUrl explicitly:convex: {
url: process.env.CONVEX_URL,
siteUrl: 'https://site.yourdomain.com'
}
/api/auth/**. You can customize this:convex: {
url: process.env.CONVEX_URL,
authRoute: '/custom/auth' // Proxy at /custom/auth/**
}
That's it! The module automatically handles everything based on your rendering mode:
| Mode | What happens |
|---|---|
SSR (ssr: true, default) | Auto-creates /api/auth/* proxy to forward auth requests to Convex |
SPA (ssr: false) | Auth client talks directly to Convex, no proxy needed |
ssr: false mode, seeing auth/session checks during navigation can be expected because auth is resolved client-side.better-convex-nuxtv0.2.11+, the auth proxy follows only canonical host redirects server-side (same path and query, different host, e.g. apex to www). OAuth/login redirects that change path or query are forwarded back to the browser.We'll update the pages from the Basics guide and add authentication. The app/pages/index.vue will become a landing page with sign-in/sign-out, and app/pages/query.vue will be protected.
<script setup lang="ts">
const router = useRouter()
const { signIn, isAuthenticated, refreshAuth } = useConvexAuth()
const email = ref('')
const password = ref('')
const error = ref<string | null>(null)
const loading = ref(false)
// Redirect to home if already authenticated
watch(
isAuthenticated,
(authenticated) => {
if (authenticated) {
router.push('/')
}
},
{ immediate: true },
)
async function handleSignIn() {
error.value = null
loading.value = true
try {
const { error: authError } = await signIn.email({
email: email.value,
password: password.value,
})
if (authError) {
error.value = authError.message
} else {
await refreshAuth()
router.push('/')
}
} finally {
loading.value = false
}
}
</script>
<template>
<div>
<h1>Sign In</h1>
<form @submit.prevent="handleSignIn">
<div>
<label>
Email
<input v-model="email" type="email" required />
</label>
</div>
<div>
<label>
Password
<input v-model="password" type="password" required />
</label>
</div>
<button type="submit" :disabled="loading">
{{ loading ? 'Signing in...' : 'Sign In' }}
</button>
</form>
<p v-if="error">{{ error }}</p>
<p>
Don't have an account?
<NuxtLink to="/signup">Sign Up</NuxtLink>
</p>
</div>
</template>
<script setup lang="ts">
const router = useRouter()
const { signUp, isAuthenticated, refreshAuth } = useConvexAuth()
const name = ref('')
const email = ref('')
const password = ref('')
const error = ref<string | null>(null)
const loading = ref(false)
// Redirect to home if already authenticated
watch(
isAuthenticated,
(authenticated) => {
if (authenticated) {
router.push('/')
}
},
{ immediate: true },
)
async function handleSignUp() {
error.value = null
loading.value = true
try {
const { error: authError } = await signUp.email({
name: name.value,
email: email.value,
password: password.value,
})
if (authError) {
error.value = authError.message
} else {
await refreshAuth()
router.push('/')
}
} finally {
loading.value = false
}
}
</script>
<template>
<div>
<h1>Sign Up</h1>
<form @submit.prevent="handleSignUp">
<div>
<label>
Name
<input v-model="name" type="text" required />
</label>
</div>
<div>
<label>
Email
<input v-model="email" type="email" required />
</label>
</div>
<div>
<label>
Password
<input v-model="password" type="password" required />
</label>
</div>
<button type="submit" :disabled="loading">
{{ loading ? 'Signing up...' : 'Sign Up' }}
</button>
</form>
<p v-if="error">{{ error }}</p>
<p>
Already have an account?
<NuxtLink to="/signin">Sign In</NuxtLink>
</p>
</div>
</template>
<script setup lang="ts">
const { isAuthenticated, user, signOut } = useConvexAuth()
async function handleSignOut() {
await signOut()
}
</script>
<template>
<div>
<nav>
<NuxtLink to="/query">Query</NuxtLink>
</nav>
<h1>Home Page</h1>
<div v-if="isAuthenticated">
<p>Welcome, {{ user?.email || user?.name || 'User' }}!</p>
<button @click="handleSignOut">Sign Out</button>
</div>
<div v-else>
<p>You are not signed in.</p>
<NuxtLink to="/signin">Sign In</NuxtLink>
<span> or </span>
<NuxtLink to="/signup">Sign Up</NuxtLink>
</div>
</div>
</template>
Use definePageMeta({ convexAuth: true }) for zero-boilerplate route protection.
<script setup lang="ts">
definePageMeta({ convexAuth: true })
import { api } from '~~/convex/_generated/api'
const {
data: tasks,
status,
error,
refresh,
} = await useConvexQuery(api.tasks.get, {}, { server: true })
const { execute, pending, error: mutationError, reset } = useConvexMutation(api.tasks.create)
const taskText = ref('')
async function handleSubmit() {
if (!taskText.value.trim()) return
await execute({ text: taskText.value })
taskText.value = ''
}
</script>
<template>
<NuxtLink to="/">Home</NuxtLink>
<div>
<form @submit.prevent="handleSubmit">
<input v-model="taskText" type="text" placeholder="Enter task text" :disabled="pending" />
<button type="submit" :disabled="pending || !taskText.trim()">
{{ pending ? 'Creating...' : 'Create Task' }}
</button>
</form>
<div v-if="mutationError">
<p>Something went wrong</p>
<button @click="reset">Dismiss</button>
</div>
<button @click="() => refresh()">Refresh</button>
<p v-if="status === 'pending'">Loading...</p>
<p v-else-if="error">Error: {{ error.message }}</p>
<ul v-else>
<li v-for="task in tasks" :key="task._id">
{{ task.text }}
</li>
</ul>
</div>
</template>
convexAuth page meta and skip the extra glue code entirely.Secure your queries and mutations by checking the user identity. For more advanced access control with roles, see Permissions.
import { query, mutation } from './_generated/server'
import { v } from 'convex/values'
export const get = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity()
if (!identity) {
throw new Error('Not authenticated')
}
return await ctx.db.query('tasks').collect()
},
})
export const create = mutation({
args: {
text: v.string(),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity()
if (!identity) {
throw new Error('Not authenticated')
}
return await ctx.db.insert('tasks', {
text: args.text,
isCompleted: false,
})
},
})
convex/auth.ts firesusers tableuseConvexAuth() to check login statusctx.auth.getUserIdentity() to secure dataYou now have a full-stack, authenticated, real-time application!
Here is the complete project structure after setting up authentication.
/api/auth/*) is automatically created by the module when convex.auth.enabled is true (the default). You don't need to create any server routes manually.import { createClient, type GenericCtx, type AuthFunctions } from '@convex-dev/better-auth'
import { convex } from '@convex-dev/better-auth/plugins'
import { betterAuth } from 'better-auth'
import type { DataModel } from './_generated/dataModel'
import { components, internal } from './_generated/api'
import authConfig from './auth.config'
const siteUrl = process.env.SITE_URL! // Public app URL (Better Auth base URL when using the Nuxt proxy)
const authFunctions: AuthFunctions = internal.auth
export const authComponent = createClient<DataModel>(components.betterAuth, {
authFunctions,
triggers: {
user: {
onCreate: async (ctx, doc) => {
const now = Date.now()
await ctx.db.insert('users', {
authId: doc._id,
displayName: doc.name,
email: doc.email,
avatarUrl: doc.image ?? undefined,
createdAt: now,
updatedAt: now,
})
},
onUpdate: async (ctx, newDoc, oldDoc) => {
const nameChanged = newDoc.name !== oldDoc.name
const emailChanged = newDoc.email !== oldDoc.email
const imageChanged = newDoc.image !== oldDoc.image
if (nameChanged || emailChanged || imageChanged) {
const user = await ctx.db
.query('users')
.withIndex('by_auth_id', (q) => q.eq('authId', newDoc._id))
.first()
if (user) {
await ctx.db.patch(user._id, {
...(nameChanged && { displayName: newDoc.name }),
...(emailChanged && { email: newDoc.email }),
...(imageChanged && { avatarUrl: newDoc.image ?? undefined }),
updatedAt: Date.now(),
})
}
}
},
onDelete: async (ctx, doc) => {
const user = await ctx.db
.query('users')
.withIndex('by_auth_id', (q) => q.eq('authId', doc._id))
.first()
if (user) {
await ctx.db.delete(user._id)
}
},
},
},
})
export const createAuth = (ctx: GenericCtx<DataModel>) => {
return betterAuth({
baseURL: siteUrl,
database: authComponent.adapter(ctx),
emailAndPassword: { enabled: true },
plugins: [convex({ authConfig })],
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
},
trustedOrigins: [siteUrl],
})
}
export const { onCreate, onUpdate, onDelete } = authComponent.triggersApi()
If you need Better Auth plugins (for example admin) or typed Better Auth additionalFields on the frontend, use a custom Better Auth client composable in addition to useConvexAuth().client (or instead of it).
This is an advanced pattern for:
authClient.admin.listUsers()authClient.useSession() fields from Better Auth user.additionalFieldsconvex/auth.ts)import { createClient, type GenericCtx, type AuthFunctions } from '@convex-dev/better-auth'
import { convex } from '@convex-dev/better-auth/plugins'
import { betterAuth } from 'better-auth'
import { admin } from 'better-auth/plugins'
// ...existing authComponent setup
export const createAuth = (ctx: GenericCtx<DataModel>) => {
return betterAuth({
// ...your existing config
database: authComponent.adapter(ctx),
user: {
additionalFields: {
organizationId: { type: 'string', required: false },
marketingOptIn: { type: 'boolean', required: false },
},
},
plugins: [convex({ authConfig }), admin()],
})
}
export type AppAuth = ReturnType<typeof createAuth>
app/composables/useExtendedAuthClient.ts)import { convexClient } from '@convex-dev/better-auth/client/plugins'
import { createAuthClient } from 'better-auth/vue'
import { adminClient, inferAdditionalFields } from 'better-auth/client/plugins'
import type { AppAuth } from '../../convex/auth'
export function useExtendedAuthClient() {
const authBaseURL = `${window.location.origin}/api/auth`
return createAuthClient({
// Better Auth client expects an absolute URL.
baseURL: authBaseURL,
plugins: [convexClient(), inferAdditionalFields<AppAuth>(), adminClient()],
fetchOptions: { credentials: 'include' },
})
}
<script setup lang="ts">
const authClient = useExtendedAuthClient()
const { signOut } = useConvexAuth()
const session = authClient.useSession()
const orgId = computed(() => session.value.data?.user.organizationId)
async function loadUsers() {
const result = await authClient.admin.listUsers({ query: { limit: 10 } })
console.log(result)
}
async function handleSignOut() {
// Keep Convex auth state reactive
await signOut()
}
</script>
import type { AppAuth } only. Do not runtime-import your server auth instance into client code.For a deeper explanation of Better Auth additionalFields vs Convex JWT claims (useConvexAuth().user) vs your app's Convex users table, see Authentication API & Patterns.
You've built an authenticated app! Here's where to go next:
title: Add Permissions icon: i-lucide-shield to: /docs/guide/permissions
Add role-based access control with admin, member, and viewer roles.
title: Auth Components icon: i-lucide-layout to: /docs/auth-security/authentication
Learn about <ConvexAuthenticated>, <ConvexUnauthenticated>, and declarative auth patterns.
::
title: Performance Optimization icon: i-lucide-gauge to: /docs/advanced/performance
Static JWKS, auth caching, and other optimizations for high-traffic apps. ::
title: Skip Auth for Public Pages icon: i-lucide-eye-off to: /docs/auth-security/authentication#skipping-auth-checks
Improve performance on marketing pages by skipping auth checks entirely. ::
::