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 codes | Settings → 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 account | Settings → 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?.nameWhich 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:
export const auth = defineConfig('auth', () => ({
home: '/dashboard',
mustVerifyEmail: false,
twoFactor: true,
passkeys: true,
passwordTimeoutSeconds: 3 * 60 * 60,
}))home | Where people land after signing in or registering. |
mustVerifyEmail | New accounts get a link to click, and mw.verified() routes (the dashboard and settings) wait for it. |
twoFactor | Offer two-factor sign-in. false hides it and turns its routes off. |
passkeys | Offer passkeys. false hides them and turns their routes off. |
passwordTimeoutSeconds | How 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:migrateThe 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.