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 --yesUse npx kitcn init --yes instead when you are adopting the current
supported app.
Then install auth:
npx kitcn add auth --yesOn 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.
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:
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 --yesThe 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 --yesThat 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.
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);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;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:
SITE_URL=http://localhost:3000
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secretFor 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 --rotateFramework 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:
| Adapter | Context | Behavior |
|---|---|---|
adapter | Queries/mutations (ctx.db) | Direct DB access, no runQuery/runMutation wrapper |
httpAdapter | Actions/HTTP contexts | Uses 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).
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:
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:
| Variable | Description |
|---|---|
SITE_URL | Your app URL (e.g., http://localhost:3000) |
JWKS | Auto-generated during local auth bootstrap or env push |
BETTER_AUTH_SECRET | Auto-generated during local auth bootstrap or env push |
Social providers (optional):
| Variable | Description |
|---|---|
GOOGLE_CLIENT_ID | Google OAuth client ID |
GOOGLE_CLIENT_SECRET | Google 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 --prodAuthentication 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 --prodThis 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 --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 Reference
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 '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: