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.

bash
npm install @erquhart/convex-better-auth better-auth@1.2.7

Add the component to your application.

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

convex/auth.config.ts
export default {
providers: [
{
// This should be your Convex site URL, which ends in .convex.site
domain: 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.

convex/auth.ts
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 provider
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID as string,
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
},
},
},
)

Register route handlers.

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

lib/auth.ts
import { createAuthClient } from '@erquhart/convex-better-auth/react'
export const authClient = createAuthClient({
// This should be your Convex site URL, which ends in .convex.site
baseURL: '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 ConvexBetterAuthProviderinstead of ConvexProvider. The specific file this happens in will depend on your framework.

src/index.tsx
"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

Authorization

Client

To check authentication state in your React components, use the useConvexAuth hook from convex/react. This hook provides a simple, idiomatic way to determine if a user is authenticated and whether the authentication state is still loading.

tsx
import { useConvexAuth } from "convex/react";
export default function App() {
const { isAuthenticated, isLoading } = useConvexAuth();
if (isLoading) {
return <div>Loading...</div>;
}
if (isAuthenticated) {
return <Dashboard />;
}
return <SignIn />;
}
  • isLoading: true while the authentication state is being determined.
  • isAuthenticated: true if the user is signed in.

Use this hook to conditionally render UI based on authentication state. This is the recommended approach for client-side auth checks in React apps using Convex.

Convex Functions

For authorization and user checks inside Convex functions (queries, mutations, actions), use the helpers provided by the Better Auth Convex component:

ts
import { betterAuth } from "./auth";
export const myFunction = async (ctx) => {
// Get the full user object for the currently authenticated user
const user = await betterAuth.getAuthUser(ctx);
// Get the authenticated user's ID as a string
const userId = await betterAuth.getAuthUserId(ctx);
// Get the user's unique identifier
// (the 'sub' claim from the JWT)
const identity = await ctx.auth.getUserIdentity();
};

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:

typescript
// In your Convex functions
const 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:

convex/schema.ts
const schema = defineSchema({
users: defineTable({
authId: v.string(), // Reference to Better Auth user ID
// Your custom fields
role: 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:

convex/userHooks.ts
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 app
await 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:

convex/auth.ts
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 any
const onDeleteUserFn = internal.userHooks.onDeleteUser as any
export 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():

convex/schema.ts
const schema = defineSchema({
posts: defineTable({
// Before
// authorId: v.id("users"),
// After
authorId: 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:

convex/schema.ts
const schema = defineSchema({
// Option 1: Add to existing users table
legacyUsers: defineTable({
// ... existing fields ...
isMigrated: v.boolean(),
}),
// Option 2: Create a separate tracking table
migratedUsers: 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:

convex/userHooks.ts
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 migrated
const 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 verification
const user = await betterAuth.getAnyUserById(ctx, session.userId)
if (!user?.emailVerified) {
return // Wait for email verification
}
// Find legacy user data
const 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 tables
const 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 migration
await ctx.db.insert("migratedUsers", {
legacyUserId: legacyUser._id,
betterAuthId: session.userId
})
}
})

Finally, update your Better Auth configuration to use the migration hook:

convex/auth.ts
export const betterAuth: BetterAuth = new BetterAuth(
components.betterAuth,
{ ...options },
{
onCreateSession: internal.userHooks.onCreateSession,
// ... other hooks
}
)