Convex
+Better Auth
Comprehensive, secure authentication with Better Auth for Convex.
Alpha Status
The Convex Better Auth component is in early alpha development.
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.
What is this?
This library is a Convex Component that provides an integration layer for using Better Auth with Convex.
After following the installation and setup steps below, you can use Better Auth in the normal way. Some exceptions will apply for certain configuration options, apis, and plugins.
Check out the Better Auth docs for usage information, plugins, and more.
Examples
Check out complete working examples on GitHub.
Getting Started
Prerequisites
You'll first need a project on Convex where npx convex dev
has been run on your local machine. If you don't have one, run npm create convex@latest
to get started, and check out the docs to learn more.
It's helpful to have the Convex dev server (npx convex dev
) running in the background while setting up, otherwise you'll see type errors that won't resolve until you run it.
Installation
Install the component
To get started, install the component and a pinned version of Better Auth.
npm install @convex-dev/better-authnpm install better-auth@1.2.7 --save-exact
Add the component to your application.
import { defineApp } from 'convex/server'import betterAuth from '@convex-dev/better-auth/convex.config'const app = defineApp()app.use(betterAuth)export default app
Add a convex/auth.config.ts
file to configure Better Auth as an authentication provider:
export default {providers: [{// Your Convex site URL is provided in a system// environment variabledomain: process.env.CONVEX_SITE_URL,// Application ID has to be "convex"applicationID: "convex",},],}
Set environment variables
Generate a secret for encryption and generating hashes. Use the command below if you have openssl installed, or use the button to generate a random value instead. Or generate your own however you like.
npx convex env set BETTER_AUTH_SECRET=$(openssl rand -base64 32)
Add the Convex site URL environment variable to the .env.local
file created by npx convex dev
. It will be picked up by your framework dev server.
# Deployment used by `npx convex dev`CONVEX_DEPLOYMENT=dev:adjective-animal-123 # team: team-name, project: project-nameVITE_CONVEX_URL=https://adjective-animal-123.convex.cloud# Same as VITE_CONVEX_URL but ends in .siteVITE_CONVEX_SITE_URL=https://adjective-animal-123.convex.site
Initialize Better Auth
The Better Auth component uses the Convex database adapter, which handles all things schema and migration related automatically.
First, add a users table to your schema. Name it whatever you like.
import { defineSchema, defineTable } from "convex/server";export default defineSchema({users: defineTable({// Fields are optional}),});
Create your Better Auth instance.
Note: Some Typescript errors will show until you save the file.
import {BetterAuth,convexAdapter,type AuthFunctions,} from "@convex-dev/better-auth";import { convex } from "@convex-dev/better-auth/plugins";import { betterAuth } from "better-auth";import { api, components, internal } from "./_generated/api";import { query, type GenericCtx } from "./_generated/server";import type { Id, DataModel } from "./_generated/dataModel";// Typesafe way to pass Convex functions defined in this fileconst authFunctions: AuthFunctions = internal.auth;// Initialize the componentexport const betterAuthComponent = new BetterAuth(components.betterAuth,{authFunctions,});export const createAuth = (ctx: GenericCtx) =>// Configure your Better Auth instance herebetterAuth({database: convexAdapter(ctx, betterAuthComponent),// Simple non-verified email/password to get startedemailAndPassword: {enabled: true,requireEmailVerification: false,},plugins: [// The Convex plugin is requiredconvex(),// The cross domain plugin is required for client side frameworkscrossDomain({siteUrl: "http://localhost:5173",}),],});// Example function for getting the current user// Feel free to edit, omit, etc.export const getCurrentUser = query({args: {},handler: async (ctx) => {// Get user data from Better Auth - email, name, image, etc.const userMetadata = await betterAuthComponent.getAuthUser(ctx);if (!userMetadata) {return null;}// Get user data from your application's database// (skip this if you have no fields in your users table schema)const user = await ctx.db.get(userMetadata.userId as Id<"users">);return {...user,...userMetadata,};},});
Mount handlers
Register Better Auth route handlers on your Convex deployment.
import { httpRouter } from 'convex/server'import { betterAuthComponent, createAuth } from './auth'const http = httpRouter()betterAuthComponent.registerRoutes(http, createAuth)export default http
Set up route handlers to proxy auth requests from your framework server to your Convex deployment.
// The cross domain plugin is used to redirect auth requests// for client apps. This is also an option for any framework if// server side auth is not needed.
Create a Better Auth client instance
Create a Better Auth client instance for interacting with the Better Auth server from your client.
import { createAuthClient } from "better-auth/react";import {convexClient,crossDomainClient,} from "@convex-dev/better-auth/client/plugins";export const authClient = createAuthClient({baseURL: import.meta.env.VITE_CONVEX_SITE_URL,plugins: [convexClient(),crossDomainClient(),],});
Set up Convex client provider
import React from "react";import ReactDOM from "react-dom/client";import App from "./App";import "./index.css";import { ConvexReactClient } from "convex/react";import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";import { authClient } from "@/lib/auth-client";const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);ReactDOM.createRoot(document.getElementById("root")!).render(<React.StrictMode><ConvexBetterAuthProvider client={convex} authClient={authClient}><App /></ConvexBetterAuthProvider></React.StrictMode>);
Users table
The Better Auth component has it's own tables in it's own space in your Convex project, like all Convex components. This means the Better Auth user table is separate from your application tables.
Because of this, the Better Auth component requires that you create your own users table for your application. This table can have whatever fields you like, while the component user table keeps basic info such as email, verification status, two factor, etc.
User creation
When Better Auth creates a user, it will first run anonCreateUser
hook where you will create your user and return the id. Better Auth then creates it's own user record and sets a relation to the provided id.
The id you return will be the canonical user id. It will be referenced in the session and in the jwt claims provided to Convex.
onCreateUser
is required for keeping your users table transactionally synced with the Better Auth user table. There are also optional onUpdateUser
and onDeleteUser
hooks. These hooks can also do whatever else you want for each event.
onUpdateUser
and onDeleteUser
run when Better Auth updates a user, but any updates to your own app's users table will not trigger it. If you are syncing fields from Better Auth (eg., email
) to your own users table, it is recommended to make changes to those fields through Better Auth so things stay synced.
import { asyncMap } from "convex-helpers";import { betterAuthComponent } from "./auth";import { Id } from "./_generated/dataModel";export const { createUser, deleteUser, updateUser, createSession } =betterAuthComponent.createAuthFunctions({// Must create a user and return the user idonCreateUser: async (ctx, user) => {const userId = await ctx.db.insert("users", {someField: "foo",});// The user id must be returnedreturn userId;},onUpdateUser: async (ctx, user) => {await ctx.db.patch(user.userId as Id<"users">, {someField: "foo",});},// Delete the user when they are deleted from Better Auth// You can also omit this and use Better Auth's// auth.api.deleteUser() function to trigger user deletion// from within your own user deletion logic.onDeleteUser: async (ctx, userId) => {await ctx.db.delete(userId as Id<"users">);// Optionally delete any related data},});
Indexing on metadata
You may have a need for accessing user metadata in your own user table, such as indexing by email or some other metadata. You can copy user metadata to your own user table on creation, and use the optional onUpdateUser
hook to update your user table when a user's metadata changes. Note that changes you make to the synced field will not be reflected in the Better Auth user table.
The user hooks are run in the same transaction as Better Auth's user create/update/delete operations, so if your hook throws an error or fails to write, the entire operation is guaranteed to fail, ensuring the user tables stay synced.
// ...export const { createUser, deleteUser, updateUser } =betterAuthComponent.createAuthFunctions({onCreateUser: async (ctx, user) => {// Copy the user's email to the application users table.return await ctx.db.insert("users", {email: user.email,});},onUpdateUser: async (ctx, user) => {// Keep the user's email syncedawait ctx.db.patch(user.userId as Id<"users">, {email: user.email,});},// ...});
Basic Usage
Follow the Better Auth documentation for basic usage. The Convex component provides a compatibility layer so things generally work as expected.
Some things that do work differently with this component are documented here.
Signing in
Below is an extremely basic example of a working auth flow with email (unverified) and password.
import { useState } from "react";import {Authenticated,Unauthenticated,AuthLoading,useQuery,} from "convex/react";import { authClient } from "@/lib/auth-client";import { api } from "../convex/_generated/api";export default function App() {return (<><AuthLoading><div>Loading...</div></AuthLoading><Unauthenticated><SignIn /></Unauthenticated><Authenticated><Dashboard /></Authenticated></>);}function Dashboard() {const user = useQuery(api.auth.getCurrentUser);return (<div><div>Hello {user?.name}!</div><button onClick={() => authClient.signOut()}>Sign out</button></div>);}function SignIn() {const [showSignIn, setShowSignIn] = useState(true);const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {e.preventDefault();const formData = new FormData(e.target as HTMLFormElement);if (showSignIn) {await authClient.signIn.email({email: formData.get("email") as string,password: formData.get("password") as string,},{onError: (ctx) => {window.alert(ctx.error.message);},});} else {await authClient.signUp.email({name: formData.get("name") as string,email: formData.get("email") as string,password: formData.get("password") as string,},{onError: (ctx) => {window.alert(ctx.error.message);},});}};return (<><form onSubmit={handleSubmit}>{!showSignIn && <input name="name" placeholder="Name" />}<input type="email" name="email" placeholder="Email" /><input type="password" name="password" placeholder="Password" /><button type="submit">{showSignIn ? "Sign in" : "Sign up"}</button></form><p>{showSignIn ? "Don't have an account? " : "Already have an account? "}<button onClick={() => setShowSignIn(!showSignIn)}>{showSignIn ? "Sign up" : "Sign in"}</button></p></>);}
Server side
Using auth.api
For full stack frameworks like Next.js and TanStack Start, Better Auth provides server side functionality via auth.api
methods. With Convex, you would instead run these methods in your Convex functions.
auth.api
read-only methods can be run in a query. Use a mutation for anything that updates Better Auth tables.
-import { auth } from "./auth";+import { createAuth } from "./auth";// Example: viewing backup codes with the Two Factor plugin-export const getBackupCodes = () => {- return auth.api.viewBackupCodes({- body: { userId: "user-id" }- })-}+export const getBackupCodes = query({+ args: {+ userId: v.id("users"),+ },+ handler: async (ctx, args) => {+ const auth = createAuth(ctx);+ return await auth.api.viewBackupCodes({+ body: {+ userId: args.userId,+ },+ });+ },+});
Sessions
Accessing the session server side requires request headers. The Convex component provides a method for generating headers for the current session.
import { createAuth, betterAuthComponent } from "./auth";export const getSession = query({args: {},handler: async (ctx) => {const auth = createAuth(ctx);const headers = await betterAuthComponent.getHeaders(ctx);const session = await auth.api.getSession({headers,});if (!session) {return null;}// Do something with the sessionreturn session;}});
Server-side auth
Server-side authentication with the Better Auth component works similar to other Convex authentication providers. See the Convex docs for your framework for more details.
Server side authentication with Convex requires a token. To get an identity token with Better Auth, use the framework appropriate getToken
approach.
"use server";import { api } from "@/convex/_generated/api";import { getToken } from "@convex-dev/better-auth/nextjs";import { createAuth } from "@/convex/auth";import { fetchMutation } from "convex/nextjs";// Authenticated mutation via server functionexport async function createPost(title: string, content: string) {const token = await getToken(createAuth);await fetchMutation(api.posts.create, { title, content }, { token });}
Integrations
Hono
Hono can be used in place of the component registerRoutes()
method. Check out the Convex w/ Hono Stack article and the Better Auth Hono docs for more details.
You'll need to install the convex-helpers
package if you haven't already.
import { Hono } from "hono";import { HonoWithConvex, HttpRouterWithHono } from "convex-helpers/server/hono";import { ActionCtx } from "./_generated/server";import { createAuth } from "./auth";const app: HonoWithConvex<ActionCtx> = new Hono();app.use("/api/auth/*",cors({origin: "http://localhost:5173",allowHeaders: ["Content-Type", "Authorization", "Better-Auth-Cookie"],allowMethods: ["GET", "POST", "OPTIONS"],exposeHeaders: ["Content-Length", "Set-Better-Auth-Cookie"],maxAge: 600,credentials: true,}));// Redirect root well-known to api well-knownapp.get("/.well-known/openid-configuration", async (c) => {return c.redirect('/api/auth/convex/.well-known/openid-configuration')});app.on(["POST", "GET"], "/api/auth/*", async (c) => {const auth = createAuth(c.env);return auth.handler(c.req.raw);});const http = new HttpRouterWithHono(app);export default http;
Guides
Migrating Existing Users
Note: This guide is for applications migrating users that are already in their Convex database, and does not cover email/password authentication due to differences in password hashing.
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.
Implement the migration logic in your onCreateUser
hook in convex/auth.ts
. This will run when Better Auth attempts to create a new user, allowing you to gradually migrate users as they access your app.
export const { createUser, deleteUser, updateUser, createSession } =betterAuthComponent.createAuthFunctions({onCreateUser: async (ctx, user) => {const existingUser = await ctx.db.query('users').withIndex('email', (q) => q.eq('email', user.email)).unique()if (existingUser && !user.emailVerified) {// This would be due to a social login provider where the email is not// verified.throw new ConvexError('Email not verified')}if (existingUser) {// Drop old auth system fields (if any)await ctx.db.patch(existingUser._id as Id<'users'>, {oldAuthField: undefined,otherOldAuthField: undefined,foo: 'bar',})return existingUser._id as Id<'users'>}// No existing user found, create a new one and return the idreturn await ctx.db.insert('users', {foo: 'bar',})},// ...})
Migrate 0.5 → 0.6
- All imports from
@erquhart/convex-better-auth
have been updated to@convex-dev/better-auth
. Search and replace this across your repo. - Your framework may work full stack without cross domain - go checkout the installation section for more details.
AuthFunctions
are now passed to theBetterAuth
component constructor via theconfig
object.- The
crossDomain
plugin now requires asiteUrl
option. - The
siteUrl
value will be added totrustedOrigins
automatically.trustedOrigins
can be removed if using the cross domain plugin and your site url was the only value.
-import { BetterAuth, type AuthFunctions, convexAdapter } from "@erquhart/convex-better-auth";-import { convex, crossDomain } from "@erquhart/convex-better-auth/plugins";+import { BetterAuth, type AuthFunctions, convexAdapter } from "@convex-dev/better-auth";+import { convex, crossDomain } from "@convex-dev/better-auth/plugins";export const betterAuthComponent = new BetterAuth(components.betterAuth,- authFunctions,+ {+ authFunctions: authFunctions,+ })export const createAuth = (ctx: GenericCtx) =>betterAuth({- trustedOrigins: ["http://localhost:3000"],database: convexAdapter(ctx, betterAuthComponent),plugins: [convex(),- crossDomain(),+ crossDomain({+ siteUrl: "http://localhost:3000",+ }),],});
Migrate 0.4 → 0.5
- Plugins and client plugins exported by the Convex Better Auth component are now exported under
/plugins
and/client/plugins
respectively. - A new
crossDomain
plugin is available. It's functionality was previously baked into theconvex
plugin. - Projects that were running v0.4.x will need to add the
crossDomain
plugin to their Better Auth client and server instances.
import { convex, crossDomain } from "@erquhart/convex-better-auth/plugins";import { betterAuth } from "better-auth";import { GenericCtx } from "./_generated/server";export const createAuth = (ctx: GenericCtx) =>betterAuth({// ...plugins: [crossDomain(), convex()],});
import { createAuthClient } from "better-auth/react";import {convexClient,crossDomainClient,} from "@erquhart/convex-better-auth/client/plugins";export const authClient = createAuthClient({// ...plugins: [crossDomainClient(), convexClient()],});
- The
betterAuthComponent.authApi
method is nowbetterAuthComponent.createAuthFunctions
. - All four named exports returned from
betterAuthComponent.createAuthFunctions
are now required, even if you're only providing anonCreateUser
hook. - If you pass your
DataModel
tobetterAuthComponent.createAuthFunctions
, everything is now typed except for Ids, which still need to be asserted. Any other type assertions from before can be removed.
import { betterAuthComponent } from "./auth";import type { DataModel } from "./_generated/dataModel";export const { createUser, deleteUser, updateUser, createSession } =betterAuthComponent.createAuthFunctions<DataModel>({onCreateUser: async (ctx, user) => {return await ctx.db.insert('users', {})},})
- The
authFunctions
object (formerlyauthApi
) is now passed to theBetterAuth
constructor, and is no longer passed toconvexAdapter
. authFunctions
is now typed using theAuthFunctions
type.convexAdapter
now takes thebetterAuthComponent
instance instead of thecomponents.betterAuth
object.
import { BetterAuth, type AuthFunctions } from "@erquhart/convex-better-auth";import { convex, crossDomain } from "@erquhart/convex-better-auth/plugins";import { components, internal } from "./_generated/api";const authFunctions: AuthFunctions = internal.users;export const betterAuthComponent = new BetterAuth(components.betterAuth,authFunctions,);export const createAuth = (ctx: GenericCtx) =>betterAuth({database: convexAdapter(ctx, betterAuthComponent),trustedOrigins: ["http://localhost:3000"],plugins: [convex(), crossDomain()],});