Skip to content
Fusion

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

Loading diagram...

Three moving parts, each its own section below:

  1. Sign-in mints a session and sets the cookie (/api/auth/*).
  2. Resolving the session turns that cookie back into a user, on the server, per request (getSession / requireSession / fetchSession).
  3. 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:

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. CreateAuthOptions takes the db client plus four optional features — a shared secondaryStorage (rate-limit counters / sessions, e.g. Valkey via fusion-cache), sendResetPassword (wire fusion-mailer here), and extra plugins. Omit one and core auth simply never imports that capability, so the auth slice depends only on the db it's handed.
  • emailAndPassword.enabled is the only sign-in method turned on by default; autoSignIn means a successful sign-up immediately establishes a session (no separate sign-in round-trip). minPasswordLength is 8.
  • drizzleAdapter points Better Auth at the four tables it owns. They're plain Drizzle tables in fusion-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.isSiteAdmin extends Better Auth's user with a column it doesn't know about. input: false means 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 companion deactivatedAt field (also input: false) is what the session helpers read to lock out a deactivated user.
  • trustedOrigins / rateLimit are the production knobs: parseTrustedOrigins() whitelists which origins may drive the auth API (BETTER_AUTH_URL plus any comma-separated TRUSTED_ORIGINS), and rate-limiting is on in production — backed by the shared store when a secondaryStorage is supplied, per-process memory otherwise.

The example wires this up once and binds the session helpers to it, in example/src/lib/auth.ts:

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:

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:

fusion-auth/src/client.ts
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:

example/src/routes/login.tsx
		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.

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):

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:

fusion-auth/src/session.ts
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 the session row, and returning the row plus its user (or null). This is the cookie-to-user step.
  • Deactivation is enforced here, fail-closed: if an admin set deactivatedAt on the user, the helper returns null even 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; verifyApiToken resolves it to a user, shaped like a session so callers never branch on how you authenticated. That's the personal-access-token system in fusion-auth/src/api-token.ts — think DRF's TokenAuthentication added to the default authentication classes. The optional requiredScope on SessionOptions is 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 or null. 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 a Response, TanStack Start turns the throw into an actual redirect — you don't write the redirect plumbing, you just await requireSession() and the not-signed-in case takes care of itself.
  • requireSiteAdmin() builds on requireSession() and additionally checks the isSiteAdmin flag — 302 to /login when 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:

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:

example/src/routes/_authed.tsx
	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:

  • beforeLoad runs before the route (and its children) render. It resolves the session via fetchSession. 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 } makes user available to every nested route via Route.useRouteContext() — this is the closest thing the stack has to request.user, except it's typed and you opted into it.
  • Protection is structural, not per-route. Because the _ prefix makes _authed a 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:

example/src/routes/_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:

QuestionWhere it lives
AuthenticationWho are you?This page — Better Auth, the session cookie, requireSession(), the _authed guard.
AuthorizationWhat 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