LearnFull-stack apps
Auth
Until now, every visitor to your app has been the same person. As soon as data belongs to someone, you need to know who is asking: who may see this list, who may delete this row. That's authentication (who you are) and authorisation (what you may do), and this page covers both.
An app made with a database gets sign-in by default (Better Auth, with email and password), wired through everything you've seen so far. ctx.user is the signed-in user in every server function and load, auth: 'user' refuses anyone else, and the session is a cookie, so pages rendered on the server know who it is too.
What sign-in is made of
A new app made with --auth (the default when you choose a database) has these files for it:
src/auth.server.ts Better Auth on your databasesrc/db/schema.ts Better Auth tables: user, session, account, verificationsrc/context.server.ts puts user and session on ctxsrc/server/session.ts me, signUp, signIn and signOutsrc/routes/login.tsx the sign-in and sign-up pagesrc/routes/todos/_layout.tsx guards /todos for signed-in users.env BETTER_AUTH_SECRET and BETTER_AUTH_URLAn app made without it gets the same with kerf add auth, as long as it has a database (kerf add db first). Run kerf db generate afterwards, because it adds tables.
The provider is a few lines. createAuth makes a Better Auth instance with Kerf's defaults (email and password on, telemetry off), stored through Drizzle in your own database:
// src/auth.server.tsimport { createAuth } from '@kerf-js/start/auth/better-auth'import { drizzleAuth } from '@kerf-js/start/auth/drizzle'import { db } from '#/db/index.server'import * as schema from '#/db/schema'import { env } from '#/env.server'export default createAuth({ db, secret: env.BETTER_AUTH_SECRET, options: { database: drizzleAuth(db, schema) },})Kerf serves Better Auth's own routes under /api/auth/*. Anything in options goes to Better Auth as is, and wins over the defaults, so its plugins and settings (social sign-in, email verification, session length) are configured here, as its docs describe. A provider with no store is A005.
How a request knows who it is
Every request runs src/context.server.ts once, before any server function or load:
// src/context.server.tsimport { session } from '@kerf-js/start/auth'import auth from '#/auth.server'import { db } from '#/db/index.server'export default async ({ request }: { request: Request }) => ({ db, ...(await session(auth, request)),})type Session = Awaited<ReturnType<typeof auth.session>>declare module '@kerf-js/start' { interface Context { db: typeof db user: Session['user'] session: Session['session'] }}session(auth, request) reads the session cookie and returns { user, session }, which is spread onto ctx. For a visitor who isn't signed in both are null. The declare module block types them, so ctx.user.email is checked everywhere.
A request with no session cookie costs nothing: Kerf answers { user: null, session: null } without asking the database. Pages for visitors stay as cheap as pages without auth.
Signing up, in and out
Sign-in is a mutation, like any other write. This is the one a new app has:
// src/server/session.tsimport { mutation, query, redirect } from '@kerf-js/start'import { forward } from '@kerf-js/start/auth'import { z } from 'zod'import auth from '#/auth.server'const credentials = z.object({ email: z.email(), password: z.string().min(8),})/** Who is signed in, or null. */export const me = query({ tags: ['session'], run: ({ ctx }) => ctx.user ? { id: ctx.user.id, name: ctx.user.name, email: ctx.user.email } : null,})export const signUp = mutation({ auth: 'public', input: credentials.extend({ name: z.string().trim().min(1) }), run: async ({ input, ctx }) => { const response = auth.api.signUpEmail({ body: input, headers: ctx.request.headers, asResponse: true, }) await forward(ctx, response) redirect('/todos') },})export const signIn = mutation({ auth: 'public', input: credentials, run: async ({ input, ctx }) => { const response = auth.api.signInEmail({ body: input, headers: ctx.request.headers, asResponse: true, }) await forward(ctx, response) redirect('/todos') },})export const signOut = mutation({ auth: 'public', run: async ({ ctx }) => { const response = auth.api.signOut({ headers: ctx.request.headers, asResponse: true, }) await forward(ctx, response) redirect('/login') },})Each one calls Better Auth's API, and forward finishes the job: it copies Better Auth's cookies onto this response, and when Better Auth says no ("Invalid email or password"), it throws an FnError with that message, which the caller can show. Then redirect() sends the browser on.
Because these are ordinary mutations, everything from Server functions applies: the input is validated on the server, and no Better Auth code reaches the browser. The sign-in page calls them like any mutation. With