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 the auth provider: Better Auth on your databasesrc/db/schema.ts also has Better Auth's tables: user, session, account, verificationsrc/context.server.ts puts the session on ctx: ctx.user and ctx.sessionsrc/server/session.ts me, signUp, signIn and signOutsrc/routes/login.tsx the sign-in and sign-up pagesrc/routes/todos/_layout.tsx the guard: /todos is for signed-in users only.env BETTER_AUTH_SECRET and BETTER_AUTH_URLAn app made without it gets the same with weft add auth, as long as it has a database (weft add db first). Run weft db generate afterwards, because it adds tables.
The provider is a few lines. createAuth makes a Better Auth instance with weft's defaults (email and password on, telemetry off), stored through Drizzle in your own database:
// src/auth.server.tsimport { createAuth } from '@weft-js/start/auth/better-auth'import { drizzleAuth } from '@weft-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) } })weft 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 '@weft-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 '@weft-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: weft 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 '@weft-js/start'import { forward } from '@weft-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({ input: credentials.extend({ name: z.string().trim().min(1) }), run: async ({ input, ctx }) => { await forward(ctx, auth.api.signUpEmail({ body: input, headers: ctx.request.headers, asResponse: true })) redirect('/todos') },})export const signIn = mutation({ input: credentials, run: async ({ input, ctx }) => { await forward(ctx, auth.api.signInEmail({ body: input, headers: ctx.request.headers, asResponse: true })) redirect('/todos') },})export const signOut = mutation({ run: async ({ ctx }) => { await forward(ctx, auth.api.signOut({ headers: ctx.request.headers, asResponse: true })) 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