Server
Set up Better Auth with Convex database adapter.
In this guide, we'll set up Better Auth with Convex as the database adapter. You'll configure the auth client, define the schema, register HTTP routes, and sync environment variables.
Overview
The server setup involves these components:
| Component | Description |
|---|---|
| Auth config | JWT provider configuration |
| Auth client | Convex adapter with triggers |
| Schema | Auth tables in your app schema |
| HTTP routes | Better Auth endpoints |
| Environment | Secrets and provider credentials |
Let's set it up step by step.
Prerequisites
We'll start by installing the required packages:
bun add better-auth@1.4.9 @convex-dev/better-auth better-convex honoPin better-auth to avoid breaking changes. Check Better Auth releases for updates.
1. Auth Config
Next, we'll create the auth configuration file. This tells Convex how to validate JWTs using Static JWKS:
import { getAuthConfigProvider } from '@convex-dev/better-auth/auth-config';
import type { AuthConfig } from 'convex/server';
export default {
providers: [getAuthConfigProvider({ jwks: process.env.JWKS })],
} satisfies AuthConfig;2. Create Auth Client
Now we'll create the auth client. This connects Better Auth to your Convex database and lets you add triggers for user lifecycle events:
import { betterAuth, type BetterAuthOptions } from 'better-auth';
import { convex } from '@convex-dev/better-auth/plugins';
import { admin } from 'better-auth/plugins';
import { createClient, createApi, type AuthFunctions } from 'better-convex/auth';
import { internal } from './_generated/api';
import type { MutationCtx, QueryCtx } from './_generated/server';
import type { DataModel } from './_generated/dataModel';
import schema from './schema';
import authConfig from './auth.config';
type GenericCtx = QueryCtx | MutationCtx | ActionCtx;
const authFunctions: AuthFunctions = internal.auth;
// Create client with Convex adapter and triggers
export const authClient = createClient<DataModel, typeof schema>({
authFunctions,
schema,
internalMutation, // Optional: custom mutation wrapper (see below)
triggers: {
user: {
beforeCreate: async (_ctx, data) => {
// Ensure every user has a username
const username =
data.username?.trim() ||
data.email?.split('@')[0] ||
`user-${Date.now()}`;
return { ...data, username };
},
// ctx.db version - for ctx.table, see Triggers documentation
onCreate: async (ctx, user) => {
// Create related records after signup
await ctx.db.insert('profiles', {
userId: user._id,
bio: '',
});
},
},
},
});
// Auth options factory
export const createAuthOptions = (ctx: GenericCtx) =>
({
baseURL: process.env.SITE_URL!,
database: authClient.httpAdapter(ctx),
plugins: [
convex({
authConfig,
jwks: process.env.JWKS,
}),
admin(),
],
session: {
expiresIn: 60 * 60 * 24 * 30, // 30 days
updateAge: 60 * 60 * 24 * 15, // 15 days
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
// Fallback for CLI schema generation
trustedOrigins: [process.env.SITE_URL ?? 'http://localhost:3000'],
}) satisfies BetterAuthOptions;
// Create auth instance for HTTP routes
export const createAuth = (ctx: GenericCtx) => betterAuth(createAuthOptions(ctx));
// IMPORTANT: Use getAuth for queries/mutations (direct DB access)
export const getAuth = <Ctx extends QueryCtx | MutationCtx>(ctx: Ctx) => {
return betterAuth({
...createAuthOptions(ctx),
database: authClient.adapter(ctx, createAuthOptions),
});
};
// Export trigger handlers for Convex
export const {
beforeCreate,
beforeDelete,
beforeUpdate,
onCreate,
onDelete,
onUpdate,
} = authClient.triggersApi();
// Export CRUD functions for Better Auth
export const {
create,
deleteMany,
deleteOne,
findMany,
findOne,
updateMany,
updateOne,
getLatestJwks,
rotateKeys,
} = createApi(schema, createAuth, {
internalMutation, // Optional: same wrapper as createClient
skipValidation: true, // Smaller generated types
});
// Export auth instance for Better Auth CLI
// biome-ignore lint/suspicious/noExplicitAny: Required for CLI
export const auth = betterAuth(createAuthOptions({} as any));Run npx convex dev first to generate internal.auth types.
3. Update Schema
Your database needs tables for users, sessions, accounts, and more. You have two options: generate them automatically or define them manually.
The easiest approach is to let the Better Auth CLI generate the tables based on your plugins:
npx @better-auth/cli generate -y --output convex/functions/authSchema.ts --config convex/functions/auth.tsThen import in your schema:
import { defineSchema } from 'convex/server';
import { authSchema } from './authSchema';
export default defineSchema({
...authSchema,
// Your other tables
});If you prefer full control, define the auth tables directly in your schema:
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
export default defineSchema({
user: defineTable({
name: v.string(),
email: v.string(),
emailVerified: v.boolean(),
image: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
// Admin plugin
role: v.optional(v.string()),
banned: v.optional(v.boolean()),
banReason: v.optional(v.string()),
banExpires: v.optional(v.number()),
})
.index('email', ['email']),
session: defineTable({
token: v.string(),
expiresAt: v.number(),
createdAt: v.number(),
updatedAt: v.number(),
ipAddress: v.optional(v.string()),
userAgent: v.optional(v.string()),
userId: v.string(),
// Admin plugin
impersonatedBy: v.optional(v.string()),
})
.index('token', ['token'])
.index('userId', ['userId']),
account: defineTable({
accountId: v.string(),
providerId: v.string(),
userId: v.string(),
accessToken: v.optional(v.string()),
refreshToken: v.optional(v.string()),
idToken: v.optional(v.string()),
accessTokenExpiresAt: v.optional(v.number()),
refreshTokenExpiresAt: v.optional(v.number()),
scope: v.optional(v.string()),
password: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('accountId', ['accountId'])
.index('userId', ['userId']),
verification: defineTable({
identifier: v.string(),
value: v.string(),
expiresAt: v.number(),
createdAt: v.optional(v.number()),
updatedAt: v.optional(v.number()),
})
.index('identifier', ['identifier']),
jwks: defineTable({
publicKey: v.string(),
privateKey: v.string(),
createdAt: v.number(),
}),
// Your other tables
});import { v } from 'convex/values';
import { defineEnt, defineEntSchema, getEntDefinitions } from 'convex-ents';
const schema = defineEntSchema({
user: defineEnt({
name: v.string(),
email: v.string(),
emailVerified: v.boolean(),
image: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
// Admin plugin
role: v.optional(v.string()),
banned: v.optional(v.boolean()),
banReason: v.optional(v.string()),
banExpires: v.optional(v.number()),
})
.index('email', ['email'])
.edges('sessions', { to: 'session', ref: 'userId' })
.edges('accounts', { to: 'account', ref: 'userId' }),
session: defineEnt({
token: v.string(),
expiresAt: v.number(),
createdAt: v.number(),
updatedAt: v.number(),
ipAddress: v.optional(v.string()),
userAgent: v.optional(v.string()),
// Admin plugin
impersonatedBy: v.optional(v.string()),
})
.index('token', ['token'])
.edge('user', { to: 'user', field: 'userId' }),
account: defineEnt({
accountId: v.string(),
providerId: v.string(),
accessToken: v.optional(v.string()),
refreshToken: v.optional(v.string()),
idToken: v.optional(v.string()),
accessTokenExpiresAt: v.optional(v.number()),
refreshTokenExpiresAt: v.optional(v.number()),
scope: v.optional(v.string()),
password: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('accountId', ['accountId'])
.edge('user', { to: 'user', field: 'userId' }),
verification: defineEnt({
identifier: v.string(),
value: v.string(),
expiresAt: v.number(),
createdAt: v.optional(v.number()),
updatedAt: v.optional(v.number()),
})
.index('identifier', ['identifier']),
jwks: defineEnt({
publicKey: v.string(),
privateKey: v.string(),
createdAt: v.number(),
}),
// Your other tables
});
export default schema;
export const entDefinitions = getEntDefinitions(schema);Add custom indexes as needed:
// Override with custom indexes
user: authSchema.user.index('username', ['username']),4. Polyfills (Required)
Convex's runtime environment doesn't include MessageChannel, which Better Auth's HTTP handling requires. Create this polyfill file:
// polyfill MessageChannel without using node:events
if (typeof MessageChannel === 'undefined') {
class MockMessagePort {
onmessage: ((ev: MessageEvent) => void) | undefined;
onmessageerror: ((ev: MessageEvent) => void) | undefined;
addEventListener() {}
close() {}
dispatchEvent(_event: Event): boolean {
return false;
}
postMessage(_message: unknown, _transfer: Transferable[] = []) {}
removeEventListener() {}
start() {}
}
class MockMessageChannel {
port1: MockMessagePort;
port2: MockMessagePort;
constructor() {
this.port1 = new MockMessagePort();
this.port2 = new MockMessagePort();
}
}
globalThis.MessageChannel =
MockMessageChannel as unknown as typeof MessageChannel;
}Import this at the top of your HTTP file (before other imports).
5. HTTP Routes
Now we'll expose Better Auth's endpoints via HTTP. We use Hono as the router:
import '../lib/http-polyfills';
import { authMiddleware } from 'better-convex/auth';
import { createHttpRouter } from 'better-convex/server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { router } from '../lib/crpc';
import { createAuth } from './auth';
const app = new Hono();
// CORS for API routes
app.use(
'/api/*',
cors({
origin: process.env.SITE_URL!,
allowHeaders: ['Content-Type', 'Authorization', 'Better-Auth-Cookie'],
exposeHeaders: ['Set-Better-Auth-Cookie'],
credentials: true,
})
);
// Better Auth middleware
app.use(authMiddleware(createAuth));
export const appRouter = router({
// Add your routers here
});
export default createHttpRouter(app, appRouter);import '../lib/http-polyfills';
import { authMiddleware } from 'better-convex/auth';
import { HttpRouterWithHono } from 'better-convex/server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { createAuth } from './auth';
const app = new Hono();
// CORS for API routes
app.use(
'/api/*',
cors({
origin: process.env.SITE_URL!,
allowHeaders: ['Content-Type', 'Authorization', 'Better-Auth-Cookie'],
exposeHeaders: ['Set-Better-Auth-Cookie'],
credentials: true,
}),
);
// Better Auth middleware
app.use(authMiddleware(createAuth));
export default new HttpRouterWithHono(app);See HTTP Router for route examples, webhooks, and streaming.
6. Sync Environment Variables
Finally, we need to set up environment variables. Create convex/.env:
SITE_URL=http://localhost:3000
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secretWith Convex dev server running (npx convex dev), sync to Convex in another terminal:
npx better-convex env sync --authThe --auth flag auto-generates BETTER_AUTH_SECRET and JWKS if they don't exist. See CLI Reference for more options.
That's it for the basic setup! Your auth is now configured. To rotate keys later (this invalidates all tokens):
npx convex run auth:rotateKeys | npx convex env set JWKSFramework Integration
You've set up the Convex backend. Now let's connect your frontend:
Key Concepts
Now that you have auth working, let's understand a few important concepts.
Direct DB Access vs HTTP Adapter
The auth client provides two adapters depending on where you're calling from:
// ✅ In queries/mutations: Use getAuth (direct DB access)
export const someQuery = publicQuery
.query(async ({ ctx }) => {
const auth = getAuth(ctx);
const session = await auth.api.getSession({ headers: await getHeaders(ctx) });
});
// ✅ In HTTP routes/actions: Use createAuth (HTTP adapter)
export const someAction = publicAction
.action(async ({ ctx }) => {
const auth = createAuth(ctx);
// Use for webhooks, external API calls
});Why two adapters?
| Adapter | Used by | Context | Performance |
|---|---|---|---|
adapter | getAuth | Queries/mutations | Direct DB access, no overhead |
httpAdapter | createAuth | HTTP routes/actions | Uses ctx.run* internally |
Performance: The httpAdapter uses ctx.runQuery/ctx.runMutation internally, which adds >50ms latency per call (increasing with app size). Always use getAuth with direct DB access in queries and mutations.
Custom Mutation Wrapper
Want to run custom logic when users are created or deleted? The internalMutation option lets you wrap auth mutations:
| Use Case | Description |
|---|---|
| User aggregation | Maintain counts when users are created/deleted |
| Custom trigger systems | Projects using convex-helpers triggers |
| Middleware | Add logging, validation, or other cross-cutting concerns |
import { Triggers } from 'convex-helpers/server/triggers';
import { DataModel } from '../functions/_generated/dataModel';
const triggers = new Triggers<DataModel>();
// Maintain user count aggregate
triggers.register('user', {
async afterCreate(ctx, user) {
const stats = await ctx.db.query('stats').first();
if (stats) {
await ctx.db.patch(stats._id, { totalUsers: stats.totalUsers + 1 });
}
},
async afterDelete(ctx, user) {
const stats = await ctx.db.query('stats').first();
if (stats) {
await ctx.db.patch(stats._id, { totalUsers: stats.totalUsers - 1 });
}
},
});
export const internalMutationWithTriggers = customMutation(
internalMutation,
customCtx(async (ctx) => ({ db: triggers.wrapDB(ctx).db }))
);import { Triggers } from 'convex-helpers/server/triggers';
import { DataModel } from '../functions/_generated/dataModel';
const triggers = new Triggers<DataModel>();
// Maintain user count aggregate
triggers.register('user', {
async afterCreate(ctx, user) {
const stats = await ctx.table('stats').first();
if (stats) {
await stats.patch({ totalUsers: stats.totalUsers + 1 });
}
},
async afterDelete(ctx, user) {
const stats = await ctx.table('stats').first();
if (stats) {
await stats.patch({ totalUsers: stats.totalUsers - 1 });
}
},
});
export const internalMutationWithTriggers = customMutation(
internalMutation,
customCtx(async (ctx) => ({ db: triggers.wrapDB(ctx).db }))
);import { internalMutationWithTriggers } from './lib/triggers';
export const authClient = createClient({
// ...
internalMutation: internalMutationWithTriggers,
});
export const { ... } = createApi(schema, createAuth, {
internalMutation: internalMutationWithTriggers,
});Without internalMutation, auth operations use the default Convex internalMutation and won't trigger your custom logic.
Environment Variables
Here's a quick reference for all the environment variables:
| Variable | Description |
|---|---|
SITE_URL | Your app URL (e.g., http://localhost:3000) |
JWKS | Auto-generated by env sync |
BETTER_AUTH_SECRET | Auto-generated by env sync |
Social providers (optional):
| Variable | Description |
|---|---|
GOOGLE_CLIENT_ID | Google OAuth client ID |
GOOGLE_CLIENT_SECRET | Google OAuth client secret |
Run npx better-convex env sync to sync all variables to Convex.
Production Deployment
Your first production deployment will fail authentication because JWKS isn't set yet. Don't worry - here's how to fix it:
1. Deploy your app to production
npx convex deploy --prodAuthentication will fail at this point - that's expected.
2. Generate and set JWKS
Run from your local machine with Convex CLI authenticated:
npx convex run auth:getLatestJwks --prod | npx convex env set JWKS --prodThis generates new signing keys (if none exist) and sets the JWKS environment variable.
3. Verify authentication works
Test a protected endpoint or sign in through your app. Authentication should now succeed.
Troubleshooting: If you see "Invalid token signature" errors after deployment, your JWKS may be outdated. Re-run the getLatestJwks command to sync.
Key Rotation
Need to rotate signing keys? Maybe for security compliance or after a suspected compromise:
npx convex run auth:rotateKeys --prod | npx convex env set JWKS --prodWarning: Key rotation invalidates all existing tokens. All users will be logged out and must re-authenticate. Plan rotations during low-traffic periods.
Rotation checklist:
- Schedule during low-traffic window
- Notify users about required re-login (optional)
- Run
rotateKeyscommand - Verify authentication works with new keys
- Monitor for authentication errors
API
Convex Plugin
The convex plugin handles JWT generation, cookie handling, and JWKS endpoints. Here's what you can configure:
| Option | Required | Description |
|---|---|---|
authConfig | Yes | Your Convex auth config from convex/auth.config.ts |
jwt | No | Customize token expiration and payload |
jwks | No | Static JWKS string for faster validation |
options | No | Pass custom basePath if using non-default routes |
The simplest setup just needs your auth config:
convex({
authConfig,
})Customizing JWT Tokens
By default, JWTs expire after 15 minutes. The default definePayload includes all user fields except id and image, plus the session ID and issued-at timestamp:
definePayload: ({ user, session }) => ({
...omit(user, ["id", "image"]),
sessionId: session.id,
iat: Math.floor(new Date().getTime() / 1000),
});Here's how to customize the expiration and payload—for example, extending to 4 hours and including only specific fields:
convex({
authConfig,
jwt: {
expirationSeconds: 60 * 60 * 4, // 4 hours
definePayload: ({ user, session }) => ({
name: user.name,
email: user.email,
role: user.role,
sessionId: session.id,
}),
},
})The sessionId and iat (issued-at) fields are always added automatically, even if you don't include them in your custom payload.
Static JWKS
For best performance, pass your JWKS as an environment variable. This eliminates database lookups during token validation:
convex({
authConfig,
jwks: process.env.JWKS,
})See Authentication Flow to understand why this matters.
Custom Base Path
If your Better Auth uses a non-default basePath, pass the same value here so the JWKS endpoint is configured correctly:
convex({
authConfig,
options: {
basePath: "/custom/auth/path",
},
})getAuthUserIdentity
Returns the full user identity:
import { getAuthUserIdentity } from 'better-convex/auth';
const identity = await getAuthUserIdentity(ctx);
if (identity) {
identity.userId; // Id<'user'>
identity.sessionId; // Id<'session'>
identity.subject; // string (user ID as string)
}getAuthUserId
If you just need the user ID:
import { getAuthUserId } from 'better-convex/auth';
const userId = await getAuthUserId(ctx);
if (!userId) {
throw new CRPCError({ code: 'UNAUTHORIZED' });
}
// userId is Id<'user'>
const user = await ctx.db.get(userId);import { getAuthUserId } from 'better-convex/auth';
const userId = await getAuthUserId(ctx);
if (!userId) {
throw new CRPCError({ code: 'UNAUTHORIZED' });
}
// userId is Id<'user'>
const user = await ctx.table('user').get(userId);getSession
Returns the full session document:
import { getSession } from 'better-convex/auth';
const session = await getSession(ctx);
if (session) {
session._id; // Id<'session'>
session.userId; // Id<'user'>
session.activeOrganizationId; // Id<'organization'> | null
session.expiresAt; // number
}getHeaders
Need to call an external API with the current session? This builds the headers:
import { getHeaders } from 'better-convex/auth';
const headers = await getHeaders(ctx);
// Headers { authorization: 'Bearer ...', x-forwarded-for: '...' }
// Use with fetch
const response = await fetch('https://api.example.com', {
headers,
});Next Steps
Done! You now have authentication set up. Here's where to go next: