Guide

Authentication

Secure your application using Better Auth and Convex.

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.

Prerequisite: Complete the Getting Started and Basics guides first. You should have a working Nuxt + Convex app with tasks.

Install Packages

Install the Better Auth core and the Convex integration.

pnpm add better-auth @convex-dev/better-auth

Convex Backend Setup

These files are the same regardless of whether you use SSR or SPA mode.

Enable the Component

Register the Better Auth component in your Convex configuration.

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

Auth Configuration

Create the auth config provider file.

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

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

Define the Schema

Add a users table to store user data synced from Better Auth.

convex/schema.ts
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(),
  }),
});

Create the Auth Bridge

This file configures Better Auth and defines Triggers to sync user data into your users table automatically.

convex/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 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: {
    user: {
      // Auto-create user in our table when Better Auth creates one
      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
        })
      },
      // Sync name and email changes
      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()
            })
          }
        }
      },
      // Delete from our table when auth user is deleted
      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)
        }
      }
    }
  }
})

// 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()

Register HTTP Routes

Expose the Better Auth routes on the Convex HTTP server.

convex/http.ts
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
Sync your changes! After creating all the Convex files above, make sure npx convex dev is running to sync your schema and functions.

Environment Variables

Convex Dashboard

Go to your Convex Dashboard → Settings → Environment Variables and add:

VariableValue
BETTER_AUTH_SECRETGenerate with openssl rand -base64 32
SITE_URLYour frontend URL, e.g., http://localhost:3000

Local Environment

Add to your .env.local:

.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

# Recommended for localhost-first auth in dev
NUXT_PUBLIC_CONVEX_SITE_URL=https://your-project.convex.site
The HTTP Actions URL (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.
Localhost-first dev with custom domains: Keep browser auth calls same-origin (/api/auth/*) and point Nuxt to your dev HTTP Actions URL:
convex: {
  url: process.env.CONVEX_URL,
  siteUrl: process.env.NUXT_PUBLIC_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL
}
Use a dev .convex.site URL in NUXT_PUBLIC_CONVEX_SITE_URL so local development does not depend on production domains.

Nuxt Configuration

Authentication is enabled by default - just set your Convex URL:

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

  convex: {
    url: process.env.CONVEX_URL
    // auth: true is the default!
  }
})

The module automatically:

  • Enables auth composables (useConvexAuth, useAuthClient)
  • Creates the SSR auth proxy at /api/auth/*
  • Derives siteUrl from your Convex URL (replacing .convex.cloud with .convex.site)
Don't need auth? If you only want Convex without Better Auth, disable it explicitly:
convex: {
  url: process.env.CONVEX_URL,
  auth: false  // Disable auth features
}
Custom domain? If you use a custom domain for Convex HTTP Actions, set siteUrl explicitly:
convex: {
  url: process.env.CONVEX_URL,
  siteUrl: 'https://site.yourdomain.com'
}
Custom auth route? By default, the auth proxy is created at /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:

ModeWhat 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
Why the proxy in SSR? When rendering on the server, auth requests originate from the Nuxt server. The auto-generated proxy forwards them to Convex while keeping cookies on the same domain as your app.
From 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.

Frontend Implementation

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.

Sign In Page

app/pages/signin.vue
<script setup lang="ts">
const router = useRouter()
const authClient = useAuthClient()
const { isAuthenticated } = 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() {
  if (!authClient) return

  error.value = null
  loading.value = true

  try {
    const { error: authError } = await authClient.signIn.email({
      email: email.value,
      password: password.value,
    })

    if (authError) {
      error.value = authError.message
    } else {
      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>

Sign Up Page

app/pages/signup.vue
<script setup lang="ts">
const router = useRouter()
const authClient = useAuthClient()
const { isAuthenticated } = 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() {
  if (!authClient) return

  error.value = null
  loading.value = true

  try {
    const { error: authError } = await authClient.signUp.email({
      name: name.value,
      email: email.value,
      password: password.value,
    })

    if (authError) {
      error.value = authError.message
    } else {
      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>

Home Page with Sign Out

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

Auth Middleware

Create a route middleware to protect sensitive pages.

app/middleware/auth.ts
export default defineNuxtRouteMiddleware(() => {
  const { isAuthenticated, isPending } = useConvexAuth()

  // Wait for auth to load
  if (isPending.value) {
    return
  }

  // Redirect to login if not authenticated
  if (!isAuthenticated.value) {
    return navigateTo('/')
  }
})

Protected Page

Apply the middleware to pages that require authentication.

app/pages/query.vue
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })

import { api } from '~~/convex/_generated/api'

const { data: tasks, status, error, refresh } = await useConvexQuery(api.tasks.get, {}, { server: true })

const { mutate, pending, error: mutationError, reset } = useConvexMutation(api.tasks.create)

const taskText = ref('')

async function handleSubmit() {
  if (!taskText.value.trim()) return
  await mutate({ 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>

Backend Protection

Secure your queries and mutations by checking the user identity. For more advanced access control with roles, see Permissions.

convex/tasks.ts
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,
    });
  },
});

What Just Happened?

  1. Better Auth handles session and cookie management
  2. When a user registers, the Trigger in convex/auth.ts fires
  3. This writes the user data into your local users table
  4. Your Nuxt app uses useConvexAuth() to check login status
  5. Your backend uses ctx.auth.getUserIdentity() to secure data

You now have a full-stack, authenticated, real-time application!

Performance tip: For high-traffic apps, consider using Static JWKS to eliminate database lookups during token verification.

Final Structure

Here is the complete project structure after setting up authentication.

The auth proxy route (/api/auth/*) is automatically created by the module when auth: true is set. You don't need to create any server routes manually.
convex/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 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()

Advanced: Additional Fields & Plugins

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 useAuthClient().

This is an advanced pattern for:

  • plugin APIs like authClient.admin.listUsers()
  • typed authClient.useSession() fields from Better Auth user.additionalFields

Backend (convex/auth.ts)

convex/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>

Frontend custom client (app/composables/useExtendedAuthClient.ts)

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' }
  })
}
app/pages/admin.vue
<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>
Use 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.

Next Steps

You've built an authenticated app! Here's where to go next:

Add Permissions

Add role-based access control with admin, member, and viewer roles.

Auth Components

Learn about <ConvexAuthenticated>, <ConvexUnauthenticated>, and declarative auth patterns.

Performance Optimization

Static JWKS, auth caching, and other optimizations for high-traffic apps.

Skip Auth for Public Pages

Improve performance on marketing pages by skipping auth checks entirely.