Convex
+Better Auth
Own your auth in your Convex applications. Comprehensive, secure authentication and a beautiful developer experience with Better Auth. Built for modern TypeScript applications with real-time data sync.
Check out working example implementations in the examples directory on GitHub, including setups for Next.js and Vite. TanStack coming soon, but it should work similarly to the others.
Alpha Status
The Convex Better Auth component is in early alpha development.
The challenge for this component is that Better Auth is designed to generate dynamic queries for traditional databases, and Convex is... not a traditional database. It's way better. Which is why it will eventually take over the world, but that's just one man's opinion. Sorry, getting off track here. Anywho. While dynamic queries are somewhat possible in Convex, dynamic indexes are not, so dynamic queries have to be supported by a set of predefined indexes.
All of this means that the Better Auth component has to support specific plugins and features intentionally, and there will always be a set of known working plugins/features, while others may or may not run into errors.
Known To Work
- Basic functionality
- Email / Password Auth
- Social Auth (Google, GitHub, etc)
- Magic Link / OTP Code Auth
- Two Factor (OTP, TOTP)
Might Not Work
- Plugins not listed under "known to work"
Not Currently Supported
- Plugins not listed above that include a migration step (indicated in Better Auth docs) are almost guaranteed not to work.
- JWT and OIDC plugins probably won't work, preconfigured versions are already in use for Convex support
Issues and Feedback
If your use case isn't supported, a plugin doesn't work, you hit a bug, etc, please open a GitHub issue or reach out on Discord.
Getting Started
This library is a Convex Component. Components have their own tables and functions that are separate from those of your application, but are still able to interact with them in specific ways. This allows Better Auth to run securely in your Convex database, but without direct access to your application data, and with it's own space in the dashboard.
Installation
To get started, install the component and a pinned version of Better Auth.
npm install @erquhart/convex-better-auth better-auth@1.2.7
Add the component to your application.
import { defineApp } from 'convex/server'import betterAuth from '@erquhart/convex-better-auth/convex.config'const app = defineApp()app.use(betterAuth)export default app
Add a convex/auth.config.ts
file to configure authentication providers for Better Auth:
export default {providers: [{// This should be your Convex site URL, which ends in .convex.sitedomain: process.env.CONVEX_SITE_URL,// Application ID has to be "convex"applicationID: "convex",},],}
Set up Better Auth
Create a Better Auth instance in your backend.
Keeping the Better Auth instance in a separate file like convex/auth.ts is recommended for better organization, but it will work from any file in the Convex directory.
import { BetterAuth } from '@erquhart/convex-better-auth'import { components } from './_generated/api'export const betterAuth: BetterAuth = new BetterAuth(components.betterAuth,{trustedOrigins: [process.env.SITE_URL as string],// Example config for GitHub social providersocialProviders: {github: {clientId: process.env.GITHUB_CLIENT_ID as string,clientSecret: process.env.GITHUB_CLIENT_SECRET as string,},},},)
Register route handlers.
import { httpRouter } from 'convex/server'import { betterAuth } from './auth'const http = httpRouter()betterAuth.registerRoutes(http, {allowedOrigins: [process.env.SITE_URL],})export default http
Set up client
Create a Better Auth client instance.
Be sure to import createAuthClient
from the component, not directly from the better-auth package.
import { createAuthClient } from '@erquhart/convex-better-auth/react'export const authClient = createAuthClient({// This should be your Convex site URL, which ends in .convex.sitebaseURL: 'https://funky-penguin-123.convex.site',// The rest of your Better Auth client config goes here,// such as client plugins})
Set up the Convex client using ConvexBetterAuthProvider
instead of ConvexProvider
. The specific file this happens in will depend on your framework.
"use client"import { ConvexReactClient } from 'convex/react'import { ConvexBetterAuthProvider } from '@erquhart/convex-better-auth/react'import { authClient } from 'path/to/your/auth.ts'const convex = new ConvexReactClient(process.env.CONVEX_URL as string,);const ConvexProvider = ({ children }: PropsWithChildren) => (<ConvexBetterAuthProvider client={convex} authClient={authClient}>{children}</ConvexBetterAuthProvider>)export default ConvexProvider
Basic Usage
Better Auth provides comprehensive documentation for all its features. Better Auth features supported by the Convex component can be used as documented in the Better Auth documentation, including email/password auth, social providers, magic links, and more. Clear supported/unsupported functionality lists will be added here soon.
Working with Users
The Better Auth component maintains its own tables in your Convex database, including a users table. There are two main approaches to working with user data:
1. Using Component Tables Directly
If the default user fields (id, email, name, etc) are sufficient for your app, you can work directly with the component's users table using the provided methods:
// In your Convex functionsconst user = await betterAuth.getAuthUser(ctx)// user has: id, email, name, emailVerified, image, etc.
2. Custom User Data
For apps that need additional user fields, create your own users table and use the event hooks to keep it synchronized with the component's table.
Transactional Safety
Because Convex mutations are atomic, the event hooks run within the same transaction as the component's user creation/deletion. This means your app's user records will always stay in sync - if the hook fails, the entire operation is rolled back.
First, define your app's users table in your schema. Include an authId
field to reference the Better Auth user, and add any custom fields your app needs:
const schema = defineSchema({users: defineTable({authId: v.string(), // Reference to Better Auth user ID// Your custom fieldsrole: v.string(),preferences: v.object({theme: v.string(),notifications: v.boolean()})}).index("authId", ["authId"]) // Important: index for efficient queries})
Create mutations for your event hooks. These will run whenever Better Auth creates or deletes a user, keeping your app's user table in sync:
import { userValidator } from '@erquhart/convex-better-auth'import { asyncMap } from 'convex-helpers'import { typedV } from 'convex-helpers/validators'import { internalMutation } from './_generated/server'import schema from './schema'const vv = typedV(schema)export const onCreateUser = internalMutation({args: { doc: vv.doc("users") },handler: async (ctx, { doc }) => {// Do something in your appawait ctx.db.insert("todos", {userId: doc._id,text: "Test todo",completed: false,})}})export const onDeleteUser = internalMutation({args: { id: v.id("users") },handler: async (ctx, { id }) => {const todos = await ctx.db.query("todos").withIndex("userId", q => q.eq("userId", id)).collect()await asyncMap(todos, async todo => {await ctx.db.delete(todo._id)})}})
Finally, update your Better Auth configuration to use these hooks:
import { BetterAuth } from '@erquhart/convex-better-auth'import { components, internal } from './_generated/api'// Define these outside of the config object to avoid circular// inference issues. The functions themselves can be// defined in this same file or any convex file.const onCreateUserFn = internal.userHooks.onCreateUser as anyconst onDeleteUserFn = internal.userHooks.onDeleteUser as anyexport const betterAuth: BetterAuth = new BetterAuth(components.betterAuth,// Better Auth options (e.g., social providers){socialProviders: {github: {clientId: process.env.GITHUB_CLIENT_ID as string,clientSecret: process.env.GITHUB_CLIENT_SECRET as string,},},},// Event hooks configuration{onCreateUser: onCreateUserFn,onDeleteUser: onDeleteUserFn,})
Migrating Existing Users
If you're migrating from an existing authentication system, you can use a gradual migration approach that moves users over as they log in. This method is less disruptive than a bulk migration and allows you to handle edge cases more gracefully.
Security Warning
It is strongly recommended to verify the user's email before executing any migration logic. Without verification, account takeovers can occur easily.
For systems with additional security measures like 2FA, consider requiring users to authenticate through your legacy system first. Future versions of these docs will include more sophisticated migration strategies for such cases.
First, ensure Better Auth is configured to handle email verification. For email/password auth, follow the email verification setup guide. For social login providers, email verification status is automatically provided by the OAuth service via the user emailVerified field, which is covered below.
Update any schemas that use v.id()
for user IDs to use v.string()
instead, as Better Auth user IDs are strings and won't validate against v.id()
:
const schema = defineSchema({posts: defineTable({// Before// authorId: v.id("users"),// AfterauthorId: v.string(),title: v.string(),content: v.string(),})})
Add a field to track migrated users. This can be done by adding an isMigrated
field to your legacy users table, or by creating a separate tracking table:
const schema = defineSchema({// Option 1: Add to existing users tablelegacyUsers: defineTable({// ... existing fields ...isMigrated: v.boolean(),}),// Option 2: Create a separate tracking tablemigratedUsers: defineTable({legacyUserId: v.string(),betterAuthId: v.string(),}).index("legacyUserId", ["legacyUserId"]),})
Implement the migration logic in your onCreateSession
hook. This will run each time a user logs in, allowing you to gradually migrate users as they access your app. Be sure to verify the user's email status before proceeding with migration:
import { sessionValidator } from '@erquhart/convex-better-auth'import { asyncMap } from 'convex-helpers'import { internalMutation } from './_generated/server'export const onCreateSession = internalMutation({args: { session: sessionValidator },handler: async (ctx, { session }) => {// Check if user is already migratedconst migrated = await ctx.db.query("migratedUsers").withIndex("legacyUserId", q =>q.eq("legacyUserId", session.userId)).unique()if (migrated) {return // User already migrated}// Get full user object to check email verificationconst user = await betterAuth.getAnyUserById(ctx, session.userId)if (!user?.emailVerified) {return // Wait for email verification}// Find legacy user dataconst legacyUser = await ctx.db.query("legacyUsers").withIndex("email", q => q.eq(q.field("email"), session.email)).unique()if (!legacyUser) {return // No legacy user to migrate}// Update references in other tablesconst userPosts = await ctx.db.query("posts").withIndex("authorId", q => q.eq(q.field("authorId"), legacyUser._id)).collect()await asyncMap(userPosts, async post => {await ctx.db.patch(post._id, {authorId: session.userId})})// Track the migrationawait ctx.db.insert("migratedUsers", {legacyUserId: legacyUser._id,betterAuthId: session.userId})}})
Finally, update your Better Auth configuration to use the migration hook:
export const betterAuth: BetterAuth = new BetterAuth(components.betterAuth,{ ...options },{onCreateSession: internal.userHooks.onCreateSession,// ... other hooks})