BETTER-CONVEX

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:

ComponentDescription
Auth configJWT provider configuration
Auth clientConvex adapter with triggers
SchemaAuth tables in your app schema
HTTP routesBetter Auth endpoints
EnvironmentSecrets 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 hono

Pin 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:

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

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

Then import in your schema:

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

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

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

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

convex/functions/http.ts
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);
convex/functions/http.ts
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:

convex/.env
SITE_URL=http://localhost:3000
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret

With Convex dev server running (npx convex dev), sync to Convex in another terminal:

npx better-convex env sync --auth

The --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 JWKS

Framework 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?

AdapterUsed byContextPerformance
adaptergetAuthQueries/mutationsDirect DB access, no overhead
httpAdaptercreateAuthHTTP routes/actionsUses 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 CaseDescription
User aggregationMaintain counts when users are created/deleted
Custom trigger systemsProjects using convex-helpers triggers
MiddlewareAdd logging, validation, or other cross-cutting concerns
convex/lib/triggers.ts
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 }))
);
convex/lib/triggers.ts
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 }))
);
convex/functions/auth.ts
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:

VariableDescription
SITE_URLYour app URL (e.g., http://localhost:3000)
JWKSAuto-generated by env sync
BETTER_AUTH_SECRETAuto-generated by env sync

Social providers (optional):

VariableDescription
GOOGLE_CLIENT_IDGoogle OAuth client ID
GOOGLE_CLIENT_SECRETGoogle 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 --prod

Authentication 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 --prod

This 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 --prod

Warning: 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 rotateKeys command
  • 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:

OptionRequiredDescription
authConfigYesYour Convex auth config from convex/auth.config.ts
jwtNoCustomize token expiration and payload
jwksNoStatic JWKS string for faster validation
optionsNoPass 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:

On this page