Guides
Authentication with Better Auth
Accounts with Better Auth and Prisma: sign-up, sign-in with a password or an email code, sign-out, the session in middleware, protected pages and onboarding.
Lumiose has no auth module of its own: Better Auth runs on any
server that hands it a web Request, and that is what endpoints, middleware and actions have. This
guide stores accounts with Prisma on a SQLite file. Every piece of the
password flow is in the
ssr-auth sample, with an e2e test;
sign-in by email code builds on it.
| File | Role |
|---|---|
prisma/schema.prisma |
Better Auth's tables. |
src/server/db.server.ts |
The Prisma client. Server-only. |
src/server/auth.server.ts |
The Better Auth instance and helpers for pages and actions. |
src/server/client-address.server.ts |
The client's address for Better Auth's rate limiter. |
src/routes/api/auth/[...all]/route.ts |
Better Auth's HTTP API, for its browser client. |
src/middleware.ts |
The session on ctx.locals. |
src/routes/sign-in/page.stsx, … |
Form actions that post to Better Auth's handler, rate-limited. |
src/islands/AccountMenu.stsx |
An island that reads the session with Better Auth's browser client. |
#Setup
pnpm add better-auth @prisma/client @prisma/adapter-libsql @libsql/client
pnpm add -D prismaPrisma 7 talks to the database through a driver adapter; @prisma/adapter-libsql opens SQLite
files with prebuilt binaries. For Postgres, swap it for @prisma/adapter-pg and the schema's
provider. For a local Postgres without installing one or running Docker, npx prisma dev starts
Prisma's local Postgres (-d keeps it in the background, prisma dev stop stops it) and prints
its connection URLs: the postgres:// one goes in DATABASE_URL.
pnpm asks whether prisma and @prisma/engines may run install scripts: allowBuilds set to
false for both works, since the CLI downloads its schema engine the first time it needs it
(prisma migrate).
prisma.config.ts, at the project root:
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: { path: "prisma/migrations" },
datasource: { url: process.env.DATABASE_URL ?? "file:./data/auth.db" },
});prisma/schema.prisma generates the client into src/server/, which makes it server-only, then
lists Better Auth's tables (npx auth generate writes them from your Better Auth config):
generator client {
provider = "prisma-client"
output = "../src/server/generated/prisma"
}
datasource db {
provider = "sqlite"
}
model User {
id String @id
name String
email String
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
@@unique([email])
@@map("user")
}
// Session, Account and Verification: see the sample's schema.Add src/server/generated/ to .gitignore. prisma generate reads tsconfig.json, which extends
.lumiose/tsconfig.generated.json, so run lumiose typegen before it in a fresh checkout. The
sample's scripts do:
{
"scripts": {
"dev": "lumiose typegen && prisma generate && prisma migrate deploy && lumiose dev",
"build": "lumiose typegen && prisma generate && lumiose build",
"typecheck": "lumiose typegen && prisma generate && lumiose check",
"db:migrate": "prisma migrate deploy"
}
}Create the first migration with prisma migrate dev --name init and commit prisma/migrations.
| Variable | Value |
|---|---|
BETTER_AUTH_SECRET |
Signs Better Auth's cookies and tokens. A random value of at least 32 bytes (openssl rand -base64 32); a dev-only one in .env.development. |
BETTER_AUTH_URL |
The public URL (https://example.com), in production. Without it Better Auth takes the origin from each request, which suits dev. |
DATABASE_URL |
file:./data/auth.db by default, relative to the working directory. |
TRUSTED_PROXIES |
Behind a proxy: its address (127.0.0.1 for nginx or Caddy on the same machine), so the rate limiter sees each client's address. See below. |
#Prisma as a server-only module
// src/server/db.server.ts
import { PrismaLibSql } from "@prisma/adapter-libsql";
import { env } from "lumiose";
import { PrismaClient } from "./generated/prisma/client.ts";
let client: PrismaClient | undefined;
export function prisma(): PrismaClient {
client ??= new PrismaClient({
adapter: new PrismaLibSql({ url: env("DATABASE_URL") ?? "file:./data/auth.db" }),
});
return client;
}Files under src/server/ and named *.server.ts are server-only: an island or any other module
that reaches the browser and imports them, directly or through another module, fails
lumiose dev and lumiose build with LUM4003, and lumiose check reports
it. The client is created on first use rather than at import, so env() reads the request's
environment: in dev, that includes the .env files.
#The Better Auth instance
// src/server/auth.server.ts
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { env, href, locals, redirect, url, type RequestContext } from "lumiose";
import { forwardedFor, parseTrustedProxies } from "./client-address.server.ts";
import { prisma } from "./db.server.ts";
function createAuth(trustedProxies: string[]) {
return betterAuth({
secret: env("BETTER_AUTH_SECRET"),
baseURL: env("BETTER_AUTH_URL"),
database: prismaAdapter(prisma(), { provider: "sqlite" }),
emailAndPassword: { enabled: true },
// The rate limiter (on in production) keys requests by client address.
advanced: { ipAddress: { trustedProxies } },
});
}
let instance: { auth: ReturnType<typeof createAuth>; trustedProxies: string[] } | undefined;
function setup() {
if (instance === undefined) {
const trustedProxies = parseTrustedProxies(env("TRUSTED_PROXIES"));
instance = { auth: createAuth(trustedProxies), trustedProxies };
}
return instance;
}
export function auth(): ReturnType<typeof createAuth> {
return setup().auth;
}
/** `{ user, session }` of a signed-in request. */
export type AuthSession = ReturnType<typeof createAuth>["$Infer"]["Session"];
declare module "lumiose" {
interface Locals {
/** Set by src/middleware.ts: the signed-in user and session, or `null`. */
session: AuthSession | null;
}
}
/** The session of a page that needs one; without one, redirects to the sign-in page and back. */
export function requireSession(): AuthSession {
const session = locals().session;
if (session === null) {
const { pathname, search } = url();
redirect(`${href("/sign-in")}?next=${encodeURIComponent(pathname + search)}`);
}
return session;
}
/** `next` when it is a path on this site, else `fallback`: never a redirect to another site. */
export function safeNext(next: string | null, fallback: string = href("/dashboard")): string {
return next !== null && /^\/(?![/\\])/.test(next) ? next : fallback;
}
// authHandler(), authRequest(), authError() and redirectWithCookies(): below.auth() creates the instance inside the first request, for the same reason as prisma(). It also
keeps Better Auth from starting while static pages prerender, where there is no secret.
#Client addresses for the rate limiter
In production, Better Auth limits requests per client address: 3 sign-in or sign-up attempts per
10 seconds, 100 requests per 10 seconds elsewhere. It reads the address from X-Forwarded-For,
which a request handed to it from Lumiose does not carry by itself. The sample sets it from
ctx.platform.clientAddress, the address the host saw:
// src/server/client-address.server.ts
/** `X-Forwarded-For` to hand to Better Auth, or `undefined` when the host knows no address. */
export function forwardedFor(
headers: Headers,
clientAddress: string | undefined,
trustedProxies: readonly string[],
): string | undefined {
// Without trusted proxies, the client's own X-Forwarded-For is ignored: it could pick any address.
const chain = trustedProxies.length > 0 ? headers.get("x-forwarded-for") : null;
const hops = [chain, clientAddress].filter((hop): hop is string => hop != null && hop !== "");
return hops.length === 0 ? undefined : hops.join(", ");
}// src/server/auth.server.ts, continued
type AuthContext = Pick<RequestContext, "request" | "url" | "platform">;
function clientHeaders(ctx: AuthContext, headers: Headers): Headers {
const { trustedProxies } = setup();
const forwarded = forwardedFor(ctx.request.headers, ctx.platform.clientAddress, trustedProxies);
if (forwarded === undefined) headers.delete("x-forwarded-for");
else headers.set("x-forwarded-for", forwarded);
return headers;
}
/** Better Auth's HTTP API for a request, with the client's address for its rate limiter. */
export function authHandler(ctx: AuthContext): Promise<Response> {
const headers = clientHeaders(ctx, new Headers(ctx.request.headers));
return auth().handler(new Request(ctx.request, { headers }));
}Behind a proxy, the address the host sees is the proxy's, and every visitor would share one
bucket. Set TRUSTED_PROXIES to the proxy's address (or CIDR range, comma-separated): the
proxy's X-Forwarded-For chain is then kept, and Better Auth, given the same list as
advanced.ipAddress.trustedProxies, walks it from the right, skips the trusted hops and takes the
client. On Cloudflare, clientAddress is already the visitor's (CF-Connecting-IP).
#The handler
Better Auth's HTTP API (sessions, sign-in, sign-out and the rest) goes under /api/auth/*, where its
browser client calls it. A catch-all endpoint passes the request through, with the client's
address:
// src/routes/api/auth/[...all]/route.ts
import type { Endpoint } from "lumiose";
import { authHandler } from "../../../../server/auth.server.ts";
export const GET: Endpoint<"/api/auth/[...all]"> = (ctx) => authHandler(ctx);
export const POST: Endpoint<"/api/auth/[...all]"> = (ctx) => authHandler(ctx);The client posts JSON, which Lumiose's cross-site check leaves to the endpoint; Better Auth checks
the Origin of those requests against BETTER_AUTH_URL (403 otherwise).
#The session in middleware
// src/middleware.ts
import { getSessionCookie } from "better-auth/cookies";
import type { Middleware } from "lumiose";
import { auth } from "./server/auth.server.ts";
const middleware: Middleware = async (ctx, next) => {
ctx.locals.session = null;
// Without a session cookie the visitor is signed out: no database query. (This is also the case
// while prerendering.) Better Auth's own routes read the session themselves.
if (getSessionCookie(ctx.request) === null || ctx.url.pathname.startsWith("/api/auth/")) {
return next();
}
const { response: session, headers } = await auth().api.getSession({
headers: ctx.request.headers,
returnHeaders: true,
});
ctx.locals.session = session;
const response = await next();
// Better Auth extends a session that passed its update age: send the renewed cookie along.
const cookies = headers.getSetCookie();
if (cookies.length === 0) return response;
const renewed = new Response(response.body, response);
for (const cookie of cookies) renewed.headers.append("set-cookie", cookie);
return renewed;
};
export default middleware;Every page, endpoint and action then reads ctx.locals.session (locals().session in a component).
getSessionCookie() only checks that the cookie is there; getSession() verifies it against the
database.
#Protected pages
// src/routes/dashboard/page.stsx
import { href, response, Title } from "lumiose";
import { requireSession } from "../../server/auth.server.ts";
export default function Dashboard() {
const { user, session } = requireSession();
response().setHeader("cache-control", "private, no-store");
return (
<main>
<Title>Dashboard</Title>
<p>
Signed in as <strong>{user.name}</strong> ({user.email}).
</p>
<p>Your session lasts until {session.expiresAt.toISOString().slice(0, 10)}.</p>
<form method="post" action={href("/sign-out")}>
<button>Sign out</button>
</form>
</main>
);
}A visitor without a session gets a 302 to /sign-in?next=%2Fdashboard, and signing in brings them
back. Protected pages render per request (mode = "server", the sample's default): a static page
cannot read the session (LUM5001). To guard a whole section instead, with
its form actions and endpoints, add a middleware.ts to its folder that returns a redirect when
ctx.locals.session is null: it runs after src/middleware.ts for every route below the folder
(Server).
#Forms: sign-up, sign-in and sign-out
The forms post to page actions, so they work without JavaScript, and Lumiose's cross-site check
guards them like any form action. Sign-in and sign-up go on to Better Auth's HTTP handler, as its
browser client would, so its rate limiter counts every attempt: it runs on HTTP requests only,
and calls through auth.api.signInEmail() would skip it, leaving passwords open to guessing.
// src/server/auth.server.ts, continued
/** Posts `body` to a Better Auth endpoint (`/sign-in/email`) through its HTTP handler. */
export function authRequest(
ctx: AuthContext,
path: `/${string}`,
body: unknown,
): Promise<Response> {
const headers = new Headers({ "content-type": "application/json" });
for (const name of ["cookie", "origin", "referer", "user-agent"]) {
const value = ctx.request.headers.get(name);
if (value !== null) headers.set(name, value);
}
const request = new Request(new URL(`/api/auth${path}`, ctx.url), {
method: "POST",
headers: clientHeaders(ctx, headers),
body: JSON.stringify(body),
});
return auth().handler(request);
}
/** The message of a failed Better Auth response: "Invalid email or password", "Too many requests…". */
export async function authError(response: Response): Promise<string> {
const body = (await response.json().catch(() => null)) as { message?: unknown } | null;
return typeof body?.message === "string" ? body.message : response.statusText;
}
/** A 303 redirect that sets the cookies of a Better Auth response. */
export function redirectWithCookies(location: string, headers: Headers): Response {
const response = new Response(null, { status: 303, headers: { location } });
for (const cookie of headers.getSetCookie()) response.headers.append("set-cookie", cookie);
return response;
}// src/routes/sign-in/page.stsx
import { actionData, fail, Title, type ActionContext, type ActionData } from "lumiose";
import { authError, authRequest, redirectWithCookies, safeNext } from "../../server/auth.server.ts";
export const action = async (ctx: ActionContext<"/sign-in">) => {
const form = await ctx.formData();
const email = String(form.get("email") ?? "").trim();
const password = String(form.get("password") ?? "");
const response = await authRequest(ctx, "/sign-in/email", { email, password });
// A wrong password (401) or too many attempts (429): Better Auth's message.
if (!response.ok) return fail(response.status, { email, message: await authError(response) });
return redirectWithCookies(safeNext(ctx.url.searchParams.get("next")), response.headers);
};
export default function SignIn() {
return <SignInForm failed={actionData<typeof action>()} />;
}
function SignInForm(props: { failed: ActionData<typeof action> | undefined }) {
return (
<main>
<Title>Sign in</Title>
{/* No action attribute: the form posts to this URL, `?next=` included. */}
<form method="post">
<input type="email" name="email" value={props.failed?.email ?? ""} required />
<input type="password" name="password" required />
<p role="alert">{props.failed?.message}</p>
<button>Sign in</button>
</form>
</main>
);
}- Cookies. Better Auth's response sets the session cookie; the action returns a redirect that
carries its
Set-Cookielines. A plainredirect()would drop them, and the visitor would not be signed in. - Errors. A wrong password (
401), a taken email (422), a short password (400) or too many attempts (429, "Too many requests. Please try again later.") come back as a status and a message, whichfail()shows on the page. - Sign-up is the same with
authRequest(ctx, "/sign-up/email", { name, email, password }), which signs the new user in. - Sign-out needs no limit, so its action calls the server API, on
/sign-outso that a form on any page can post to it:
export const action = async (ctx: ActionContext<"/sign-out">) => {
if (ctx.locals.session === null) return redirectWithCookies(href("/"), new Headers());
const { headers } = await auth().api.signOut({
headers: ctx.request.headers,
returnHeaders: true,
});
return redirectWithCookies(href("/"), headers); // the headers clear the cookies
};#Better Auth's client in an island
A prerendered page cannot know who is signed in, and an island can find out in the browser. Better Auth's browser client has no server imports, so islands can use it:
// src/lib/auth-client.ts
import { createAuthClient } from "better-auth/client";
export const authClient = createAuthClient(); // calls /api/auth/* on the page's origin// src/islands/AccountMenu.stsx (shortened), used as <AccountMenu client:load /> in the layout
import { href, onMount, signal } from "lumiose";
import { authClient } from "../lib/auth-client.ts";
export function AccountMenu() {
// `undefined` until the session has loaded: no links that may be wrong.
const account = signal<{ name: string } | null | undefined>(undefined);
onMount(() =>
// Called at once with the current value; the returned unsubscribe runs on unmount.
authClient.useSession.subscribe(({ data, isPending }) => {
if (!isPending) account.set(data === null ? null : { name: data.user.name });
}),
);
async function signOut() {
await authClient.signOut();
location.assign(href("/"));
}
return (
<div class="account">
{account() === null ? <a href={href("/sign-in")}>Sign in</a> : null}
{account() ? (
<button type="button" onClick={signOut}>
Sign out {account()?.name}
</button>
) : null}
</div>
);
}authClient.useSession is a nanostores atom: subscribe fetches the session and calls back on every
change (after authClient.signOut() too), and onMount runs only in the browser and disposes the
subscription with the island. Server-rendered pages can pass what they already know as props
instead: locals().session is there during their render. With the client, the sample's island
chunk is about 11 kB (brotli); an island that only needs the session can fetch
/api/auth/get-session with fetchJSON instead.
#Sign-in by email code
Instead of a password, the visitor enters their email and gets a one-time code, with Better Auth's email OTP plugin. The flow is two form actions, so it works without JavaScript:
/sign-inasks Better Auth for a code, stores the sign-in on the server and redirects to/sign-in/code/<random id>. The URL carries the id, never the email: URLs end up in the browser's history, in server and proxy logs and in analytics./sign-in/code/[challenge]finds the email by the id, signs in with the code and sets the session cookie.
An unknown address gets a code too, and entering it creates the account: sign-up is the same flow.
These pages replace the sample's sign-in and sign-up pages, and reuse authRequest(),
authError(), redirectWithCookies() and safeNext() from above.
#The plugin
// src/server/auth.server.ts
import { emailOTP } from "better-auth/plugins";
import { sendMail } from "./mailer.server.ts";
function createAuth(trustedProxies: string[]) {
return betterAuth({
// secret, baseURL, database and advanced as above; keep emailAndPassword for passwords too.
user: {
// Set by the onboarding form; until then, the guard below sends the account there.
additionalFields: { onboardedAt: { type: "date", required: false, input: false } },
},
plugins: [
emailOTP({
expiresIn: 300, // seconds: the default
allowedAttempts: 3, // wrong codes before the code is void: the default
storeOTP: "hashed", // the database keeps a hash of the code
async sendVerificationOTP({ email, otp, type }) {
if (type !== "sign-in") return;
// Not awaited: the response does not wait for the mail server (Better Auth's advice).
sendMail({
to: email,
subject: "Your sign-in code",
text: `Your code is ${otp}. It expires in 5 minutes.`,
}).catch((error: unknown) => console.error("The sign-in code was not sent", error));
},
}),
],
});
}Codes have 6 digits (otpLength). input: false keeps onboardedAt out of what a request may
set. On a serverless host, which may stop work that outlives the response, see Better Auth's
advanced.backgroundTasks. The schema gets the field (npx auth generate adds it) and a table of
your own for the sign-ins in progress:
model User {
// Better Auth's fields, as above, and:
onboardedAt DateTime?
}
/// A sign-in in progress: the email a code went to, behind a random id.
model SignInChallenge {
id String @id
email String
expiresAt DateTime
@@map("sign_in_challenge")
}#A mailer for development
// src/server/mailer.server.ts
export interface Mail {
to: string;
subject: string;
text: string;
}
export async function sendMail(mail: Mail): Promise<void> {
if (import.meta.env.DEV) {
// Development: the server's console, and nowhere else.
console.info(`\n[mail] to ${mail.to}: ${mail.subject}\n${mail.text}\n`);
return;
}
// Production: your email provider's API, or SMTP.
throw new Error("sendMail(): no mail provider is set up");
}In lumiose dev the code shows in the terminal that runs it. Never render a code on a page, in a
response or in a URL, not even in development. A code on the page proves nothing about the
inbox, and a page that shows it in development is one wrong flag away from showing anyone the code
for any address. import.meta.env.DEV is false in lumiose build, which drops the branch, and
production without a provider fails loudly instead of skipping the mail. End-to-end tests read the
code from the server's output, or from a test inbox of the mail provider.
#Asking for a code
// src/server/sign-in-challenge.server.ts
import { prisma } from "./db.server.ts";
/** As long as the code lives (`expiresIn`). */
const LIFETIME_MS = 5 * 60 * 1000;
/** Stores a sign-in for `email`; returns its random id, which is all the URL carries. */
export async function createChallenge(email: string): Promise<string> {
const id = crypto.randomUUID();
const db = prisma();
await db.signInChallenge.deleteMany({ where: { expiresAt: { lt: new Date() } } });
await db.signInChallenge.create({
data: { id, email, expiresAt: new Date(Date.now() + LIFETIME_MS) },
});
return id;
}
/** The email of a sign-in that has not expired, or `undefined`. */
export async function challengeEmail(id: string): Promise<string | undefined> {
const challenge = await prisma().signInChallenge.findUnique({ where: { id } });
return challenge !== null && challenge.expiresAt > new Date() ? challenge.email : undefined;
}
export async function deleteChallenge(id: string): Promise<void> {
await prisma().signInChallenge.deleteMany({ where: { id } });
}
/** Resolves once `Date.now()` reaches `deadline`. */
export function sleepUntil(deadline: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now())));
}// src/routes/sign-in/page.stsx
import { actionData, fail, href, redirect, Title, type ActionContext } from "lumiose";
import { authError, authRequest } from "../../server/auth.server.ts";
import { createChallenge, sleepUntil } from "../../server/sign-in-challenge.server.ts";
export const action = async (ctx: ActionContext<"/sign-in">) => {
// Every answer takes this long, so the time Better Auth takes does not tell whether an
// account has this email.
const deadline = Date.now() + 1000;
const form = await ctx.formData();
const email = String(form.get("email") ?? "")
.trim()
.toLowerCase();
const response = await authRequest(ctx, "/email-otp/send-verification-otp", {
email,
type: "sign-in",
});
if (!response.ok) {
// An invalid address (400) or too many requests (429): neither depends on an account.
const message = await authError(response);
await sleepUntil(deadline);
return fail(response.status, { email, message });
}
const challenge = await createChallenge(email);
await sleepUntil(deadline);
// The random id in the URL, never the email; `?next=` goes along.
redirect(href("/sign-in/code/[challenge]", { challenge }) + ctx.url.search);
};
export default function SignIn() {
const failed = actionData<typeof action>();
return (
<main>
<Title>Sign in</Title>
{/* No action attribute: the form posts to this URL, `?next=` included. */}
<form method="post">
<input
type="email"
name="email"
value={failed?.email ?? ""}
autocomplete="email"
required
/>
<p role="alert">{failed?.message}</p>
<button>Email me a code</button>
</form>
</main>
);
}- The same answer. With sign-up open, Better Auth sends a code to any address, so every valid
address gets the redirect. With
emailOTP({ disableSignUp: true }), an unknown address gets no mail and the same redirect. The errors left, an invalid address or too many requests, do not depend on an account either. - The same time. Every answer waits until a second has passed, so the time a user lookup or a database write takes does not tell either. Pick a delay above what the request takes in your setup.
- The id has 122 random bits (
crypto.randomUUID()), so it cannot be guessed. Whoever has the URL can try codes for its address, three per code, which is why the code page does not show the address.
#Signing in with the code
// src/routes/sign-in/code/[challenge]/page.stsx
import { actionData, fail, href, Title, type ActionContext } from "lumiose";
import {
authError,
authRequest,
redirectWithCookies,
safeNext,
} from "../../../../server/auth.server.ts";
import { challengeEmail, deleteChallenge } from "../../../../server/sign-in-challenge.server.ts";
export const action = async (ctx: ActionContext<"/sign-in/code/[challenge]">) => {
const { challenge } = ctx.params;
const email = await challengeEmail(challenge);
if (email === undefined) return fail(410, { message: "The code has expired. Ask for a new one." });
const form = await ctx.formData();
const otp = String(form.get("code") ?? "").replace(/\s/g, "");
// Signs the account in, or creates it and signs it in: the response sets the session cookie.
const response = await authRequest(ctx, "/sign-in/email-otp", { email, otp });
// A wrong or expired code (400), a fourth try (403), too many requests (429).
if (!response.ok) return fail(response.status, { message: await authError(response) });
await deleteChallenge(challenge);
return redirectWithCookies(safeNext(ctx.url.searchParams.get("next")), response.headers);
};
export default function EnterCode() {
const failed = actionData<typeof action>();
return (
<main>
<Title>Enter your code</Title>
{/* Not the address: anyone with this URL could read it. */}
<p>We sent a 6-digit code to the address you entered. It expires in 5 minutes.</p>
<form method="post">
<input name="code" inputmode="numeric" autocomplete="one-time-code" required />
<p role="alert">{failed?.message}</p>
<button>Sign in</button>
</form>
<a href={href("/sign-in")}>Use another address, or get a new code</a>
</main>
);
}- Cookies. Better Auth's response sets the session cookie, so the action returns
redirectWithCookies(), as the password sign-in does. - Errors. A wrong code is "Invalid OTP" (
400), an old one "OTP expired" (400). After three wrong codes the code is void: the next try gets "Too many attempts" (403), and the visitor asks for a new code.autocomplete="one-time-code"lets phones fill in the code from the mail. - Cleanup. The challenge goes once the sign-in succeeds; expired ones go with the next
createChallenge().
#Sign-up and onboarding
Entering a code for an unknown address creates the account, with its email verified (the code
proves it), an empty name and no onboardedAt. Middleware in a route group sends such accounts to
onboarding, and visitors without a session to sign-in, for every page, action and endpoint in the
group. A group folder adds no URL segment: src/routes/(app)/dashboard/page.stsx is /dashboard
(Server).
// src/routes/(app)/middleware.ts: every route in the (app) group
import { href, type Middleware } from "lumiose";
const onboarded: Middleware = (ctx, next) => {
const { session } = ctx.locals; // set by src/middleware.ts, which runs first
const back = `?next=${encodeURIComponent(ctx.url.pathname + ctx.url.search)}`;
if (session === null) return Response.redirect(new URL(href("/sign-in") + back, ctx.url), 303);
if (!session.user.onboardedAt) {
return Response.redirect(new URL(href("/onboarding") + back, ctx.url), 303);
}
return next();
};
export default onboarded;// src/routes/onboarding/page.stsx: outside the group, so the guard does not send it to itself
import { actionData, fail, href, redirect, Title, type ActionContext } from "lumiose";
import { requireSession, safeNext } from "../../server/auth.server.ts";
import { prisma } from "../../server/db.server.ts";
export const action = async (ctx: ActionContext<"/onboarding">) => {
const { session } = ctx.locals;
if (session === null) redirect(href("/sign-in"));
const form = await ctx.formData();
const name = String(form.get("name") ?? "").trim();
if (name === "") return fail(400, { name, message: "Enter your name." });
await prisma().user.update({
where: { id: session.user.id },
data: { name, onboardedAt: new Date() },
});
redirect(safeNext(ctx.url.searchParams.get("next")));
};
export default function Onboarding() {
const { user } = requireSession();
const failed = actionData<typeof action>();
return (
<main>
<Title>Welcome</Title>
<form method="post">
<input name="name" value={failed?.name ?? user.name} autocomplete="name" required />
<p role="alert">{failed?.message}</p>
<button>Continue</button>
</form>
</main>
);
}After its code, a new account lands on ?next= or /dashboard; the guard sends it to
/onboarding?next=%2Fdashboard, and onboarding sends it back. session.user.onboardedAt is typed
from additionalFields, and getSession() reads it from the database on each request. Keep
Better Auth's session.cookieCache off (the default), or the guard sees the cached session,
without onboardedAt, until the cache expires.
#Rate limits
- Per client. The plugin allows 3 requests per 60 seconds per client address on
/email-otp/send-verification-otpand on/sign-in/email-otp, in production (emailOTP({ rateLimit: { window, max } })changes both). The actions post throughauthRequest(), so the limiter counts them, keyed by the client's address; a limited request gets429. - Per code.
allowedAttemptscaps the guesses: three tries at a 6-digit code. - Per inbox. Many client addresses can still send codes to one inbox. To cap that, count the email's recent challenges in the sign-in action and, past a limit, skip the request to Better Auth but answer the same.
- Several instances need shared counts, as for passwords (see Production).
#Production
- Set
BETTER_AUTH_SECRET,BETTER_AUTH_URL(the publichttps://URL: Better Auth then marks its cookiesSecure) andDATABASE_URL, plusORIGINbehind a proxy (Deploy). - Apply migrations before starting the new build:
prisma migrate deploy(theprismaCLI is a dev dependency, so run it where those are installed, such as CI or a release step). - Behind a proxy, set
TRUSTED_PROXIESto its address, or the rate limiter puts every visitor in the proxy's bucket. The limiter is off in development (lumiose dev). - Several instances. The rate limiter keeps its counts in memory by default, per process: with
several instances (a cluster, replicas behind a load balancer, serverless functions), each one
counts on its own, and an attacker gets that many more attempts. Store them where every
instance sees them, with Better Auth's
rateLimit: { storage: "database" }(arateLimittable: runnpx auth generateand migrate) or"secondary-storage"(Redis or another store, through Better Auth'ssecondaryStorageoption). - Email codes. Give
sendMail()a mail provider (it throws without one), and set up SPF and DKIM for the sending domain, or the codes land in spam folders.
#Common mistakes
- Importing
auth.server.tsor the Prisma client into an island.LUM4003. Islands use the browser client, or call an endpoint. - Calling
betterAuth()at module scope withenv()values. In dev,.envvalues reachenv()only inside a request, and a static page's prerender would start Better Auth without a secret. Create it on first use, asauth()does. - Signing in with
auth.api.signInEmail()in the action. It works, but Better Auth's rate limiter never sees the attempt. Post to its handler, asauthRequest()does. - Redirecting with
redirect()after sign-in. The session cookie is in Better Auth's response headers; return aResponsethat sets them. - Reading the session in a
staticpage.locals()throwsLUM5001while prerendering; render the page per request, or read the session in an island. - Showing the email code on the page in development. Print it to the server's console only.
- Putting the email in the code page's URL (
/sign-in/code?email=…). Keep it on the server, behind a random id. - Answering differently for an unknown address ("No account with this email"), or faster. It tells anyone which addresses have accounts.
prisma generatefailing on a fresh checkout with "File './.lumiose/tsconfig.generated.json' not found". Runlumiose typegenfirst.
See also: Server, Data and actions, Islands, Security.