Authentication — Better Auth (sessions & sign-in)
Authentication answers exactly one question: who is making this request? It does not decide what they're allowed to do — that's Authorization, a separate layer. Keep the two apart in your head: this page is about establishing an identity; the moment you start gating actions on that identity you've crossed into authz.
The stack uses Better Auth — a TypeScript-native
auth library — wrapped in @tikab-interactive/fusion-auth. Sign-in is email +
password, the session lives in an HTTP-only cookie, and every request resolves
that cookie back to a user server-side, per request. There is no global
currentUser and no request.user that middleware quietly populates: you call
requireSession() (or getSession()) where you need it, and it reads the cookie off
this request.
The shape of it
Three moving parts, each its own section below:
- Sign-in mints a session and sets the cookie (
/api/auth/*). - Resolving the session turns that cookie back into a user, on the server, per
request (
getSession/requireSession/fetchSession). - The route guard (
_authed.tsx) resolves the session before render and redirects anonymous visitors — so every nested route is protected by construction.
Building the auth instance
fusion-auth doesn't hold a singleton — the consumer builds one with the db
client it owns and exports it. That happens in createAuth in
fusion-auth/src/server.ts:
import { betterAuth, type BetterAuthOptions } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { tanstackStartCookies } from "better-auth/tanstack-start";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import * as schema from "@tikab-interactive/fusion-db/schema";
/**
* Options the consumer injects, so core auth depends only on the `db` client it
* is handed. The optional features auth used to hard-import — a distributed
* rate-limit store, password-reset email, LDAP/OIDC sign-in — are wired in by
* the app instead. See the Fusion migration plan §6.0.
*/
export interface CreateAuthOptions {
/**
* The fusion-db client (from fusion-db 2.0.0's `createDb`). Injected so core
* auth holds no singleton and the app owns the one connection.
*/
db: NodePgDatabase<Record<string, unknown>>;
/**
* Shared store for rate-limit counters (and optionally sessions) — e.g. Valkey
* via fusion-cache. Omitted → per-process in-memory limits.
*/
secondaryStorage?: BetterAuthOptions["secondaryStorage"];
/**
* Password-reset delivery. Wire fusion-mailer's `sendEmail` in here. Omitted →
* reset links are generated but never delivered.
*/
sendResetPassword?: NonNullable<BetterAuthOptions["emailAndPassword"]>["sendResetPassword"];
/**
* Extra Better Auth plugins — e.g. the LDAP/OIDC plugins from
* fusion-auth-enterprise. Composed after `tanstackStartCookies()`.
*/
plugins?: BetterAuthOptions["plugins"];
}
/**
* ALLOWED_HOSTS, deliberately: only these origins may drive the auth API.
* BETTER_AUTH_URL is always trusted; TRUSTED_ORIGINS adds more (comma-separated)
* when the app sits behind a proxy with another name.
*/
function parseTrustedOrigins(): string[] {
return (process.env.TRUSTED_ORIGINS ?? "")
.split(",")
.map((o) => o.trim())
.filter(Boolean);
}
/**
* Build the Better Auth instance for email/password sign-up + sign-in, backed by
* the fusion-db Drizzle schema. The consumer owns the singleton:
*
* export const auth = createAuth({ db, secondaryStorage, sendResetPassword, plugins });
*/
export function createAuth(options: CreateAuthOptions) {
return betterAuth({
database: drizzleAdapter(options.db, {
provider: "pg",
schema: {
user: schema.user,
session: schema.session,
account: schema.account,
verification: schema.verification,
},
}),
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
autoSignIn: true,
// Injected by the consumer (the app wires fusion-mailer). Omitted when not
// provided, so core auth never imports a mailer.
...(options.sendResetPassword ? { sendResetPassword: options.sendResetPassword } : {}),
},
user: {
additionalFields: {
isSiteAdmin: {
type: "boolean",
defaultValue: false,
input: false,
},
deactivatedAt: {
type: "date",
required: false,
input: false,
},
},
},
// See parseTrustedOrigins: BETTER_AUTH_URL plus any TRUSTED_ORIGINS.
trustedOrigins: parseTrustedOrigins(),
rateLimit: {
// On in production by default; explicit here so the contract is visible.
// With a secondary store the counters are shared across instances; without
// one they fall back to per-process memory.
enabled: process.env.NODE_ENV === "production",
storage: options.secondaryStorage ? "secondary-storage" : "memory",
},
...(options.secondaryStorage ? { secondaryStorage: options.secondaryStorage } : {}),
plugins: [tanstackStartCookies(), ...(options.plugins ?? [])],
});
}
export type Auth = ReturnType<typeof createAuth>;
export {
createApiToken,
listApiTokens,
revokeApiToken,
tokenGrantsScope,
verifyApiToken,
type CreatedApiToken,
} from "./api-token";A few things worth reading off that config:
- The consumer injects everything optional.
CreateAuthOptionstakes thedbclient plus four optional features — a sharedsecondaryStorage(rate-limit counters / sessions, e.g. Valkey via fusion-cache),sendResetPassword(wire fusion-mailer here), and extraplugins. Omit one and core auth simply never imports that capability, so the auth slice depends only on thedbit's handed. emailAndPassword.enabledis the only sign-in method turned on by default;autoSignInmeans a successful sign-up immediately establishes a session (no separate sign-in round-trip).minPasswordLengthis 8.drizzleAdapterpoints Better Auth at the four tables it owns. They're plain Drizzle tables infusion-db(Data layer), so they migrate with everything else — there's no separate auth migration system.tanstackStartCookies()is the adapter that teaches Better Auth how to read and write cookies through TanStack Start's request/response objects. This is what makes the cookie "just work" in SSR.additionalFields.isSiteAdminextends Better Auth's user with a column it doesn't know about.input: falsemeans a client can't set it on sign-up — only an admin (or a seed) can. It's the one global role; everything finer-grained is Authorization. The companiondeactivatedAtfield (alsoinput: false) is what the session helpers read to lock out a deactivated user.trustedOrigins/rateLimitare the production knobs:parseTrustedOrigins()whitelists which origins may drive the auth API (BETTER_AUTH_URL plus any comma-separatedTRUSTED_ORIGINS), and rate-limiting is on in production — backed by the shared store when asecondaryStorageis supplied, per-process memory otherwise.
The example wires this up once and binds the session helpers to it, in
example/src/lib/auth.ts:
export const auth = createAuth({ db, plugins: edgeSsoPlugins() });
export const { getSession, requireSession } = createAuthSession(auth, db);That auth object is the single instance the rest of the app shares — the API
handler calls auth.handler, and the session helpers call auth.api.getSession.
Sign-in
The /api/auth/* handler
Better Auth is a request handler, not a set of hand-written routes. The example mounts
it under a single splat route, example/src/routes/api/auth/$.ts:
import { createFileRoute } from "@tanstack/react-router";
import { auth } from "#/lib/auth";
// Mounts Better Auth at /api/auth/* — sign-up, sign-in, sign-out, session.
export const Route = createFileRoute("/api/auth/$")({
server: {
handlers: {
GET: ({ request }) => auth.handler(request),
POST: ({ request }) => auth.handler(request),
},
},
});Every Better Auth endpoint lives under that one prefix: POST /api/auth/sign-in/email,
POST /api/auth/sign-up/email, POST /api/auth/sign-out, GET /api/auth/get-session,
and more. The $ splat hands the whole subtree to auth.handler, which routes
internally. You never write these endpoints — you mount the handler once and Better
Auth provides them.
From the browser: authClient
The browser doesn't fetch those endpoints by hand. fusion-auth/src/client.ts
exports a typed client created with Better Auth's React adapter:
import { genericOAuthClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
plugins: [genericOAuthClient()],
});The login route (example/src/routes/login.tsx) calls authClient.signIn.email(...),
which POSTs to /api/auth/sign-in/email for you:
const result = await authClient.signIn.email({
email: values.email,
password: values.password,
});
setLoading(false);
if (result.error) {
setError(result.error.message ?? m.login_failed());
return;
}
void navigate({ to: "/home" });Sign-up is the mirror image — authClient.signUp.email({ name, email, password }) in
signup.tsx. Because autoSignIn is on, a successful sign-up sets the session cookie
in the same response, so the redirect to /home lands already authenticated.
The session cookie
On success, the handler's response carries a Set-Cookie header for the session — an
HTTP-only cookie, so JavaScript can't read it (XSS can't exfiltrate it). The
browser attaches it automatically to every subsequent request to the origin, which is
why none of the later code ever touches a token: the cookie is the credential, and
the browser carries it.
The cookie's value maps to a row in the session table
(fusion-db/src/schema/foundation.ts):
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
// …
});So sessions are server-side state (a row per active session, userId foreign-keyed
to user), and the cookie is just the opaque handle to one. Deleting the row — or
letting expiresAt pass — invalidates the session regardless of what cookie the
browser still holds. The onDelete: "cascade" means deleting a user takes their
sessions with them.
Resolving the session
A cookie is worthless until something turns it back into a user. That "something" is
the session helpers — and the key idea is that resolution is per request, server-side,
not a global. There is no ambient currentUser; you ask, on the request you're
handling, and you get the answer for that request.
getSession() and requireSession()
The whole of fusion-auth/src/session.ts is small enough to read in one go.
createAuthSession binds the helpers to the auth instance and the db; the heart of
it is resolveSession, and the public getSession / requireSession /
requireSiteAdmin wrap it:
import { getRequestHeaders } from "@tanstack/react-start/server";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { verifyApiToken } from "./api-token";
import type { Auth } from "./server";
type Db = NodePgDatabase<Record<string, unknown>>;
/** A resolved Better Auth session (or null) — the shape `getSession` returns. */
type Session = Awaited<ReturnType<Auth["api"]["getSession"]>>;
/**
* Options shared by the session helpers.
*/
export interface SessionOptions {
/**
* Capability required of an API-token caller. A restricted (scoped) token
* that does not list it is treated as anonymous, fail-closed — see
* `verifyApiToken`. Cookie sessions carry the user's full authority and are
* never scope-checked. Omit for no capability check.
*/
requiredScope?: string;
}
/**
* Resolve the current request to a session, or null for anonymous (and
* deactivated) users. Two ways to be authenticated, unified here so the rest of
* the app never cares which: the Better Auth cookie (browsers), or an
* `Authorization: Bearer sk_…` API token (scripts, CI, integrations — see
* api-token.ts).
*/
async function resolveSession(auth: Auth, db: Db, opts?: SessionOptions): Promise<Session> {
const headers = new Headers(getRequestHeaders());
const session = await auth.api.getSession({ headers });
if (session) {
// Deactivated users (admin set `deactivatedAt`) are anonymous, so
// deactivation locks out an existing cookie session immediately.
if ((session.user as { deactivatedAt?: Date | null }).deactivatedAt) return null;
return session;
}
// No cookie session — try an API token. Shaped like a Better Auth session so
// callers (requireSession, requireSiteAdmin) are identical either way. The
// requested scope is enforced fail-closed inside verifyApiToken.
const tokenUser = await verifyApiToken(db, headers.get("authorization"), opts?.requiredScope);
if (tokenUser) {
return { user: tokenUser, session: null } as unknown as NonNullable<Session>;
}
return null;
}
/**
* Session helpers bound to a specific auth instance and the app's `db` client.
* The consumer creates the instance with `createAuth()` and binds the helpers
* once:
*
* export const { getSession, requireSession, requireSiteAdmin } =
* createAuthSession(auth, db);
*/
export function createAuthSession(auth: Auth, db: Db) {
/** Current session, or null for anonymous (and deactivated) users. */
const getSession = (opts?: SessionOptions) => resolveSession(auth, db, opts);
/**
* Throws if no session — use inside server functions that require auth. The
* thrown Response is converted to a redirect by TanStack Start. Pass
* `requiredScope` to additionally demand a capability of a token caller.
*/
async function requireSession(opts?: SessionOptions): Promise<NonNullable<Session>> {
const session = await getSession(opts);
if (!session) {
throw new Response(null, { status: 302, headers: { Location: "/login" } });
}
return session;
}
/**
* Throws unless the caller is an authenticated site admin (`isSiteAdmin` on
* the user row). 302 → /login when anonymous; 403 when signed in but not an
* admin, so a logged-in non-admin is not bounced to the login page. The
* generic library gate — apps wanting a richer policy (e.g. a `can()` check)
* can layer it on top of `requireSession`. Works identically for cookie and
* token callers because both carry `isSiteAdmin` on `session.user`.
*/
async function requireSiteAdmin(opts?: SessionOptions): Promise<NonNullable<Session>> {
const session = await requireSession(opts);
if (!session.user.isSiteAdmin) {
throw new Response("Forbidden", { status: 403 });
}
return session;
}
return { getSession, requireSession, requireSiteAdmin };
}Read resolveSession top to bottom:
getRequestHeaders()(from@tanstack/react-start/server) pulls the headers off the request currently being handled. This is the mechanism that makes resolution per-request: there is no module-level state, only "the request in scope right now". It works because Start runs each request inside its own server context.auth.api.getSession({ headers })is Better Auth reading the session cookie out of those headers, looking up thesessionrow, and returning the row plus its user (ornull). This is the cookie-to-user step.- Deactivation is enforced here, fail-closed: if an admin set
deactivatedAton the user, the helper returnsnulleven though the cookie is otherwise valid — so deactivating a user locks out their live sessions immediately, without waiting for expiry. - API tokens are a second path to the same shape. A non-browser caller (a script,
CI, an integration) sends
Authorization: Bearer sk_live_…instead of a cookie;verifyApiTokenresolves it to a user, shaped like a session so callers never branch on how you authenticated. That's the personal-access-token system infusion-auth/src/api-token.ts— think DRF'sTokenAuthenticationadded to the default authentication classes. The optionalrequiredScopeonSessionOptionsis what a token caller is checked against (cookie sessions carry full authority and are never scope-checked).
Then the three helpers createAuthSession returns:
getSession()— returns the session ornull. Use it when anonymous is a valid state (the nav, a public page, a "sign in?" prompt).requireSession()— returns the session or throws a 302 to/login. Use it at the top of any server function that must not run for an anonymous caller. Because it throws aResponse, TanStack Start turns the throw into an actual redirect — you don't write the redirect plumbing, you justawait requireSession()and the not-signed-in case takes care of itself.requireSiteAdmin()builds onrequireSession()and additionally checks theisSiteAdminflag — 302 to/loginwhen anonymous, but 403 when signed in yet not an admin (so a logged-in non-admin isn't bounced to the login page). That's the seam where this page hands off to Authorization.
The SSR path: fetchSession in beforeLoad
Server functions resolve the session when they run. But a route also needs to know
before it renders whether there's a user — to guard the route, and to put the user on
the route context for the nav and the page. That's what fetchSession is for, in
example/src/lib/auth-server.ts:
import { createServerFn } from "@tanstack/react-start";
import { loadSsoConfigFromEnv } from "@tikab-interactive/fusion-edge-sso";
import { getSession } from "#/lib/auth";
import { db } from "#/lib/db";
/**
* The current session for the UI, or null. Safe to call from `beforeLoad`
* guards and route loaders — runs server-side via createServerFn, so the
* server-only auth module never reaches the client bundle.
*
* `isSiteAdmin` lives on the user row (not in the Better Auth session), so we
* read it here for nav + route gating of the admin area.
*/
export const fetchSession = createServerFn({ method: "GET" }).handler(async () => {
const session = await getSession();
if (!session) return null;
const row = await db.query.user.findFirst({
where: (u, { eq }) => eq(u.id, session.user.id),
columns: { isSiteAdmin: true },
});
return {
user: {
id: session.user.id,
name: session.user.name,
email: session.user.email,
isSiteAdmin: row?.isSiteAdmin ?? false,
},
};
});
/**
* Whether edge SSO is switched on (drives the "Sign in via SSO" affordance on the
* login page). Reads the same `SSO_*` env fusion-edge-sso uses; the resolver itself
* is pure/isomorphic so this stays safe to import from the login route.
*/
export const getEdgeSsoEnabled = createServerFn({ method: "GET" }).handler(() => {
return loadSsoConfigFromEnv(process.env).enabled;
});fetchSession is a thin server-function wrapper over getSession() that returns a
UI-safe user object (id, name, email, and the isSiteAdmin flag read from the user
row). Wrapping it in createServerFn matters: it guarantees the server-only auth
module never gets bundled into the client. The browser calls fetchSession like an
RPC; the actual cookie reading happens on the server. (The companion
getEdgeSsoEnabled in the same file is what the login page reads to decide whether to
show the SSO button — see Plugins below.) For the full mechanics of server
functions and loaders, see The request lifecycle.
The route guard
Here's the payoff for everything above. The whole authenticated area of the app sits
under one pathless layout route, example/src/routes/_authed.tsx, and its
beforeLoad is the guard:
beforeLoad: async () => {
const session = await fetchSession();
if (!session) throw redirect({ to: "/login" });
return { user: session.user };
},(The same Route also declares a loader that fetches the viewer's projects and a
component for the app shell — elided here to keep the focus on the guard.)
What this buys you:
beforeLoadruns before the route (and its children) render. It resolves the session viafetchSession. No session →throw redirect({ to: "/login" }), and TanStack Router navigates away before any protected component mounts. An anonymous visitor never sees a flash of the page.- The resolved user is returned onto the route context.
return { user: session.user }makesuseravailable to every nested route viaRoute.useRouteContext()— this is the closest thing the stack has torequest.user, except it's typed and you opted into it. - Protection is structural, not per-route. Because the
_prefix makes_autheda layout that contributes no URL segment, any route file named_authed.*.tsx(e.g._authed.home.tsx,_authed.project.$projectKey.tsx, the whole_authed.admin.*tree) nests under it and inherits the guard automatically. You protect a route by naming it into the subtree, not by adding a check — so a new page can't accidentally ship unguarded.
The reverse guard lives on the public side: login.tsx and signup.tsx run their own
beforeLoad that redirects an already-authenticated visitor to /home, so a
signed-in user never sees the login form.
Signing out is the symmetric client action, in _authed.tsx:
async function signOut() {
await authClient.signOut(); // POST /api/auth/sign-out — clears the cookie
await router.invalidate(); // drop cached loader/context data
void router.navigate({ to: "/login" });
}router.invalidate() is important: it discards the cached route context (including the
now-stale user), so the app re-runs beforeLoad and the guard correctly sees an
anonymous request.
Plugins
Better Auth is extended through plugins — objects that add endpoints, columns, or
sign-in flows without forking the core. The createAuth config composes them:
tanstackStartCookies() is itself a plugin (the cookie adapter), and the consumer
appends its own via the plugins option.
The example's notable plugin is edge-SSO, for secure on-prem deployments where a
reverse proxy terminates Kerberos/SPNEGO or smartcard (mTLS) auth and forwards the
established identity as request headers. edgeSsoPlugin
(fusion-auth/src/edge-sso-plugin.ts) mounts a /api/auth/edge/sign-in endpoint that
resolves the trusted-header identity, finds (or optionally provisions) the user, and
mints a session + cookie — the same cookie the rest of this page describes, just issued
without a password. It's wired in example/src/lib/auth.ts via edgeSsoPlugins(),
which returns [] unless SSO_ENABLED is set, so a plain checkout keeps
email/password login untouched.
The full story — the trust model (why a header alone isn't enough), directory sync, and the configuration — is its own page: Enterprise SSO & directory.
Authentication is not authorization
To close the loop on the distinction this page opened with:
| Question | Where it lives | |
|---|---|---|
| Authentication | Who are you? | This page — Better Auth, the session cookie, requireSession(), the _authed guard. |
| Authorization | What may you do? | Authorization — the typescript-rules predicate registries, SQL WHERE filters, per-object checks. |
requireSession() establishes the who and stops there. The single exception baked
into auth itself is isSiteAdmin (and its requireSiteAdmin() helper) — the one
global role. Every finer-grained "may this user change this project?" decision is
authorization, and deliberately does not live here.
See also
- Authorization — what an authenticated user is allowed to do.
- Enterprise SSO & directory — the edge-SSO plugin and reverse-proxy sign-in.
- The request lifecycle — how server functions, loaders, and
beforeLoadactually run. - Data layer — the
user/sessiontables and how the schema is composed and migrated.