Authentication

Sign-up, sign-in, passkeys, two-factor and email verification in the starter kit, and how to change them.

The starter kit comes signed in and out of the box. It's Better Auth underneath, on your app's own users table, with the pages and routes written in your app so you can change any of them.

What you get

Where
Register, log in, log out/register, /login
Forgot and reset password/forgot-password, then the emailed link
Email verification, when you turn it on/verify-email
Two-factor sign-in with an authenticator app, and recovery codesSettings → Security, then /two-factor-challenge at sign-in
Passkeys (Face ID, Touch ID, Windows Hello, security keys)Settings → Security, then "Sign in with a passkey"
Confirming the password before sensitive settings/confirm-password
Changing name, email and password; deleting the accountSettings → Profile, Security

Sign-in is rate limited to five failed attempts a minute for each address and email, and two-factor codes the same way. After ten wrong codes, Better Auth locks the account for 15 minutes.

The signed-in user

Every request knows who's signed in. c.var.user is their row from the User model, or null for a guest:

.get('index', '/', (c) => c.json({ hello: c.var.user?.name ?? 'stranger' }))

Behind mw.auth(), there's always a user, so c.var.user! is safe. Pages get the same user as a shared prop:

import { usePage } from '@inertiajs/react'

const { auth } = usePage().props
auth.user?.name

Which fields pages get is up to share in src/inertia.ts: name, email, verified and picture to begin with. Anything you add there reaches the browser, so add only what's safe to show.

Protecting routes

Routes say who may use them with middleware:

export const notes = defineRoutes('notes', { prefix: '/notes', middleware: [mw.auth()] })
mw.auth()Signed in. Guests get a 401; on a page visit, that's the login page, then back here.
mw.guest()Signed out, for the login and register pages.
mw.verified()A confirmed email, when mustVerifyEmail is on.
mw.passwordConfirmed()The password typed recently, for sensitive settings.

Whether someone may do something to a particular row is authorization.

Settings

src/config/auth.ts:

src/config/auth.ts
export const auth = defineConfig('auth', () => ({
  home: '/dashboard',
  mustVerifyEmail: false,
  twoFactor: true,
  passkeys: true,
  passwordTimeoutSeconds: 3 * 60 * 60,
}))
homeWhere people land after signing in or registering.
mustVerifyEmailNew accounts get a link to click, and mw.verified() routes (the dashboard and settings) wait for it.
twoFactorOffer two-factor sign-in. false hides it and turns its routes off.
passkeysOffer passkeys. false hides them and turns their routes off.
passwordTimeoutSecondsHow long a confirmed password counts for mw.passwordConfirmed(). Three hours to begin with.

Turning twoFactor or passkeys off never locks anyone out: both stay installed, so people who already set them up still sign in with them.

Emails

The reset and verification emails are React Email templates in src/mail/reset-password.tsx and src/mail/verify-email.tsx. With the log mailer, the default in .env, nothing is sent: open the outbox at /_caldera/mail/outbox to read them and click the links. Set MAIL_MAILER to send them for real (src/config/mail.ts lists the mailers).

How it fits together

Better Auth is configured in src/auth.ts: the users table, password rules, the emails, and its plugins (two-factor and passkeys). Its own tables (sessions, accounts, codes, passkeys) are in src/db/auth-schema.ts.

The pages don't talk to Better Auth directly. Each form posts to a route in src/routes/auth.ts or settings.ts, which calls Better Auth on the server and answers like any other route, so failures come back as validation errors on the form:

.post('login.store', '/login', { middleware: [mw.guest()], throttle: login, request: Login }, async (c) => {
  const { email, password, remember } = c.req.valid('json')
  const res = await c.var.auth.api.signInEmail({
    body: { email, password, rememberMe: remember },
    headers: c.req.raw.headers,
    asResponse: true,
  })
  if (!res.ok) throw new ValidationError({ email: ['These credentials don’t match our records.'] })
  // …
  return withAuthCookies(c, res, intended(c, c.var.config.auth.home))
})

withAuthCookies copies Better Auth's session cookie onto your response. intended goes back to the page the person was sent away from, or home.

Better Auth's own endpoints are mounted at /api/auth too, which is what passkeys use from the browser.

Changing it

Anything Better Auth can do is a change to src/auth.ts, such as adding a social login or another plugin. When a plugin adds tables or columns, regenerate its schema and migrate:

pnpm exec caldera auth:schema
pnpm exec caldera db:generate --name auth
pnpm exec caldera db:migrate

The users table is yours: add columns to it in src/db/schema.ts, as with any table. A column like role belongs in the model's guarded list (Models).

Testing

The starter's tests make users through Better Auth, the way registering does, and sign in as them:

import { setupApp } from '@caldera/testing'
import { app } from '../src/app'
import { http } from '../src/http'
import { createUser } from './helpers'

const t = setupApp(app)

it('shows the dashboard', async () => {
  const client = await t.client(http).actingAs(await createUser(t))
  ;(await client.visit('/dashboard')).assertOk()
})

createUser(t, { name, email, password }) takes overrides. For pages behind mw.passwordConfirmed(), sign in with actingAsConfirmed(t, user) from the same file.

On this page