Guides
Authentication with Better Auth
Email and password accounts with Better Auth and Prisma: sign-up, sign-in and sign-out, the session in middleware and protected pages.
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 is in the
ssr-auth sample, with an e2e test.
| 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. 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, check
ctx.locals.session in the middleware after setting it and return a redirect for the paths that
need one (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.
#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).
#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. prisma generatefailing on a fresh checkout with "File './.lumiose/tsconfig.generated.json' not found". Runlumiose typegenfirst.
See also: Server, Data and actions, Islands, Security.