BETTER-CONVEX

Server

Set up Better Auth with Convex database adapter.

In this guide, you'll install Better Auth through kitcn's CLI-first flow. The CLI owns the auth scaffold, schema blocks, HTTP route wiring, and the first local Convex auth bootstrap, so you can start from a working baseline and only drop to manual wiring when you actually need it.

Start with the CLI

If kitcn is not bootstrapped yet, start there first:

npx kitcn init -t next --yes

Use npx kitcn init --yes instead when you are adopting the current supported app.

Then install auth:

npx kitcn add auth --yes

On local Convex, that command also finishes the live bootstrap pass for auth: generated runtime, BETTER_AUTH_SECRET, and JWKS.

What add auth owns

The default kitcn auth path writes and patches the auth surface for you:

  • <functionsDir>/auth.config.ts
  • <functionsDir>/auth.ts
  • auth-owned schema blocks in <functionsDir>/schema.ts
  • auth HTTP wiring in <functionsDir>/http.ts
  • auth-aware client wiring
  • the scaffolded auth page and Next auth route when the app shell supports them

functionsDir comes from convex.json.functions. Scaffolded kitcn apps use convex/functions.

Auth Config

The scaffolded auth config keeps the first local bootstrap healthy. It uses the static JWKS when present and falls back to the provider helper before that first JWKS sync exists.

convex/functions/auth.config.ts
import { getAuthConfigProvider } from 'kitcn/auth/config';
import type { AuthConfig } from 'convex/server';
import { getEnv } from '../lib/get-env';

export default {
  providers: [
    getEnv().JWKS
      ? getAuthConfigProvider({ jwks: getEnv().JWKS })
      : getAuthConfigProvider(),
  ],
} satisfies AuthConfig;

Auth Contract

auth.ts is the server contract. It defines the Better Auth options, registers the Convex adapter, and imports defineAuth from the generated runtime:

convex/functions/auth.ts
import { convex } from 'kitcn/auth';
import { getEnv } from '../lib/get-env';
import authConfig from './auth.config';
import { defineAuth } from './generated/auth';

export default defineAuth(() => ({
  emailAndPassword: {
    enabled: true,
  },
  baseURL: getEnv().SITE_URL,
  plugins: [
    convex({
      authConfig,
      jwks: getEnv().JWKS,
    }),
  ],
  session: {
    expiresIn: 60 * 60 * 24 * 30,
    updateAge: 60 * 60 * 24 * 15,
  },
  telemetry: { enabled: false },
  trustedOrigins: [getEnv().SITE_URL],
}));

This is the file you edit when you add Better Auth plugins or change auth behavior.

Refresh Auth-Owned Schema

When plugin changes in auth.ts affect auth tables, refresh only the auth-owned schema blocks:

npx kitcn add auth --schema --yes

The default kitcn path patches auth-owned table blocks directly into <functionsDir>/schema.ts and refreshes ownership in <functionsDir>/plugins.lock.json.

If you want to own the auth tables by hand instead, use the manual backend reference in Server Setup. That is the escape hatch, not the default path.

Raw Convex Path

Use the raw Convex preset only when the app stays on the plain Convex auth surface:

npx kitcn add auth --preset convex --yes

That path writes authSchema.ts, auth.config.ts, auth.ts, and raw Convex HTTP wiring. It assumes the raw Convex app is already initialized. When auth tables change, rerun the same preset command. It does not support --schema.

HTTP Routes

Now we'll expose Better Auth's endpoints via HTTP.

  • Without Hono: use Convex httpRouter + registerRoutes (no Hono required).
  • With Hono or custom middleware chains: use Hono + authMiddleware.
convex/functions/http.ts
import { authMiddleware } from 'kitcn/auth/http';
import { createHttpRouter } from 'kitcn/server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { getEnv } from '../lib/get-env';
import { router } from '../lib/crpc';
import { getAuth } from './generated/auth';

const app = new Hono();

// CORS for API routes
app.use(
  '/api/*',
  cors({
    origin: getEnv().SITE_URL,
    allowHeaders: ['Content-Type', 'Authorization', 'Better-Auth-Cookie'],
    exposeHeaders: ['Set-Better-Auth-Cookie'],
    credentials: true,
  })
);

// Better Auth middleware
app.use(authMiddleware(getAuth));

export const httpRouter = router({
  // Add your routers here
});

export default createHttpRouter(app, httpRouter);
convex/functions/http.ts
import { registerRoutes } from 'kitcn/auth/http';
import { httpRouter } from 'convex/server';
import { getAuth } from './generated/auth';

const http = httpRouter();

registerRoutes(http, getAuth, {
  cors: {
    allowedOrigins: [process.env.SITE_URL!],
  },
});

export default http;
convex/functions/http.ts
import { authMiddleware } from 'kitcn/auth/http';
import { HttpRouterWithHono } from 'kitcn/server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { getEnv } from '../lib/get-env';
import { getAuth } from './generated/auth';

const app = new Hono();

// CORS for API routes
app.use(
  '/api/*',
  cors({
      origin: getEnv().SITE_URL,
      allowHeaders: ['Content-Type', 'Authorization', 'Better-Auth-Cookie'],
      exposeHeaders: ['Set-Better-Auth-Cookie'],
      credentials: true,
  }),
);

// Better Auth middleware
app.use(authMiddleware(getAuth));

export default new HttpRouterWithHono(app);

See HTTP Router for route examples, webhooks, and streaming.

Sync Environment Variables

Base setup already creates convex/.env. Keep SITE_URL and any provider credentials current there. For the normal local path, SITE_URL should stay on http://localhost:3000.

Typical local values:

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

For local Convex, no extra bootstrap command is needed after that. kitcn init --yes, kitcn dev, and kitcn add auth --yes already drive the local auth bootstrap when they own the flow.

See CLI Backend for more options.

That's it for the basic setup! Your auth is now configured. To rotate keys later (this invalidates all tokens):

npx kitcn env push --rotate

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.

Context-Aware Adapter Selection

Use getAuth(ctx) in every context:

export const someQuery = publicQuery.query(async ({ ctx }) => {
  const auth = getAuth(ctx);
  const session = await auth.api.getSession({ headers: await getHeaders(ctx) });
});

export const someAction = publicAction.action(async ({ ctx }) => {
  const auth = getAuth(ctx);
});

Generated getAuth(ctx) uses the same context-aware adapter selection internally:

AdapterContextBehavior
adapterQueries/mutations (ctx.db)Direct DB access, no runQuery/runMutation wrapper
httpAdapterActions/HTTP contextsUses ctx.run* APIs

Performance: In queries/mutations, the direct DB adapter is selected automatically, avoiding the extra ctx.runQuery/ctx.runMutation hop.

Triggers

The recommended approach is schema-level ORM Triggers via defineTriggers in schema.ts. These run for all writes to auth tables (ORM mutations, auth API, direct ctx.db).

convex/functions/schema.ts
import { defineTriggers } from 'kitcn/orm';
import { relations } from './relations';

const triggers = defineTriggers(relations, {
  user: {
    create: {
      before: async (data) => {
        return { data: { ...data, role: 'user' } };
      },
      after: async (doc, ctx) => {
        // e.g. create personal organization, send welcome email
      },
    },
  },
  session: {
    create: {
      after: async (session, ctx) => {
        // e.g. set activeOrganizationId from last active
      },
    },
  },
});

See Triggers for the full hook signatures, change handler, cancellation, and withoutTriggers.

If you're not using the ORM, define triggers inline in auth.ts. These run only for Better Auth API operations:

convex/functions/auth.ts
import { getEnv } from '../lib/get-env';
import { defineAuth } from './generated/auth';

export default defineAuth(() => ({
  baseURL: getEnv().SITE_URL,
  triggers: {
    user: {
      create: {
        before: async (data, triggerCtx) => {
          return { data: { ...data, role: 'user' } };
        },
        after: async (doc, triggerCtx) => {},
      },
    },
  },
}));

See migrating from Better Auth component for this pattern.

Environment Variables

Here's a quick reference for all the environment variables:

VariableDescription
SITE_URLYour app URL (e.g., http://localhost:3000)
JWKSAuto-generated during local auth bootstrap or env push
BETTER_AUTH_SECRETAuto-generated during local auth bootstrap or env push

Social providers (optional):

VariableDescription
GOOGLE_CLIENT_IDGoogle OAuth client ID
GOOGLE_CLIENT_SECRETGoogle OAuth client secret

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. Sync auth env

Run from your local machine with Convex CLI authenticated:

npx kitcn env push --prod

This pushes convex/.env, generates BETTER_AUTH_SECRET when missing, fetches JWKS, and writes the full batch to the production deployment.

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, re-run kitcn env push --prod.

Key Rotation

Need to rotate signing keys? Maybe for security compliance or after a suspected compromise:

npx kitcn env push --rotate --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 Reference

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 'kitcn/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 'kitcn/auth';

const userId = await getAuthUserId(ctx);

if (!userId) {
  throw new CRPCError({ code: 'UNAUTHORIZED' });
}

// userId is Id<'user'>
const user = await ctx.orm.query.user.findFirst({ where: { id: userId } });

getSession

Returns the full session document:

import { getSession } from 'kitcn/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 'kitcn/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