Session Handling

Protect routes and access session data in loaders, server functions, and components. Use getSession, requireAdmin, and the authAction middleware factory.

Session Management

How to access and protect user sessions.

Sessions are stored in the database and referenced via HTTP-only cookies. The kit provides multiple utilities for accessing session data and protecting routes based on your use case.

Routes are protected with beforeLoad guards, not a Next.js-style middleware. For data and endpoints, always authorize the session inside the loader or server function.

Route-Level Protection

There is no proxy or middleware.ts. Two layers protect the app:

  • Global request middleware (apps/web/src/start.ts) wires CSRF protection (scoped to server functions) and security headers via createStart / createMiddleware / createCsrfMiddleware.
  • beforeLoad guards (apps/web/src/lib/auth/guards.ts) gate navigation per route group: requireAuth (redirect anonymous users to sign-in), requireAdminAuth (redirect anonymous to sign-in, hide the admin surface with notFound() from authenticated non-admins), and redirectIfAuthenticated (keep signed-in users off the auth screens).

Attach a guard to a route or pathless layout route:

apps/web/src/routes/_authenticated/route.tsx

import { createFileRoute } from '@tanstack/react-router';
import { requireAuth } from '#/lib/auth/guards';
export const Route = createFileRoute('/_authenticated')({
beforeLoad: requireAuth,
});

Server-Side Session Access

Use getSession() from @kit/better-auth/context to read the current session inside a server function or loader. It reads the request headers, so it only runs on the server:

apps/web/src/lib/auth/session.functions.ts

import { createServerFn } from '@tanstack/react-start';
import { getSession } from '@kit/better-auth/context';
export const fetchSession = createServerFn({ method: 'GET' }).handler(() =>
getSession(),
);

Wrapping getSession() in a createServerFn lets beforeLoad/loader read the session even during client-side navigation, where getRequestHeaders() is unavailable. getSession() is memoized per request (a WeakMap keyed on the request headers), so multiple calls within the same request are efficient.

Session Data Structure

interface Session {
user: {
id: string;
name: string;
email: string;
image: string | null;
emailVerified: boolean;
createdAt: Date;
updatedAt: Date;
role: string | null;
};
session: {
id: string;
userId: string;
expiresAt: Date;
activeOrganizationId: string | null;
};
}

Account Context

For protected pages that need the user context (personal vs organization), use getAccountContext(). It calls requireSession() internally and throws if there is no session, so call it from a server function behind a requireAuth guard:

apps/web/src/lib/auth/account.functions.ts

import { createServerFn } from '@tanstack/react-start';
import { getAccountContext } from '@kit/better-auth/context';
export const fetchAccountContext = createServerFn({ method: 'GET' }).handler(
() => getAccountContext(),
);

apps/web/src/routes/_authenticated/dashboard/route.tsx

import { createFileRoute } from '@tanstack/react-router';
import { fetchAccountContext } from '#/lib/auth/account.functions';
export const Route = createFileRoute('/_authenticated/dashboard')({
loader: () => fetchAccountContext(),
component: DashboardPage,
});
function DashboardPage() {
const context = Route.useLoaderData();
if (context.isOrganization) {
return <OrgDashboard orgId={context.activeOrganizationId} />;
}
return <PersonalDashboard user={context.user} />;
}

The getAccountContext function returns the following structure:

Account Context Structure

interface ServerAccountContext {
isPersonal: boolean;
isOrganization: boolean;
activeOrganizationId: string | null;
user: {
id: string;
name: string;
email: string;
image: string | null;
createdAt: Date;
updatedAt: Date;
emailVerified: boolean;
};
}

Require Organization Context

For loaders that require an active organization, requireActiveOrganizationId() throws a redirect to the app home path when no organization is active:

members.functions.ts

import { createServerFn } from '@tanstack/react-start';
import { requireActiveOrganizationId } from '@kit/better-auth/context';
export const fetchMembers = createServerFn({ method: 'GET' }).handler(
async () => {
// Redirects to the app home path if not in org context
const orgId = await requireActiveOrganizationId();
return loadMembers(orgId);
},
);

Admin Protection

Require Admin in Routes

Gate the admin surface with the requireAdminAuth guard in beforeLoad. It redirects anonymous users to sign-in and throws notFound() for an authenticated non-admin (hiding the surface rather than leaking a 403):

apps/web/src/routes/admin/route.tsx

import { createFileRoute } from '@tanstack/react-router';
import { requireAdminAuth } from '#/lib/auth/guards';
export const Route = createFileRoute('/admin')({
beforeLoad: requireAdminAuth,
// ... layout loader + component
});

The guard delegates to requireAdmin() semantics (@kit/auth/require-admin). Because the guard only controls navigation, also authorize admin server functions with the adminAction() middleware factory - never rely on the guard alone.

Check Admin Status

Use isUserAdmin() when you need to check admin status without redirecting. Expose it to the UI through a server function:

apps/web/src/lib/auth/session.functions.ts

import { createServerFn } from '@tanstack/react-start';
import { isUserAdmin } from '@kit/auth/require-admin';
export const fetchAuthState = createServerFn({ method: 'GET' }).handler(
async () => ({ isAdmin: await isUserAdmin() }),
);

In a component, render the admin link with Link from @tanstack/react-router:

import { Link } from '@tanstack/react-router';
<nav>
<Link to="/dashboard">Dashboard</Link>
{isAdmin && <Link to="/admin">Admin</Link>}
</nav>;

Protected Server Functions

Use the middleware factories from @kit/action-middleware to protect server functions. authAction() pre-binds the authenticated middleware onto a createServerFn builder; continue the standard chain with .validator() and .handler(), and context.user is fully typed:

apps/web/src/lib/settings/update-profile.ts

import { z } from 'zod';
import { authAction } from '@kit/action-middleware';
const updateProfileSchema = z.object({
name: z.string().min(1),
});
export const updateProfileAction = authAction()
.validator(updateProfileSchema)
.handler(async ({ data, context }) => {
// context.user is guaranteed to exist
await updateUser(context.user.id, data);
return { success: true };
});

The middleware throws an "Unauthorized" error if the session is invalid. The package also exports organizationAction() (requires an active organization - context.organizationId, context.role) and adminAction() / createAdminAction() (requires an admin). Call any of them from a client component with TanStack Query: useMutation({ mutationFn: updateProfileAction }).

Client-Side Session

Use authClient.useSession() in client components:

Client component

'use client';
import { authClient } from '@kit/better-auth/client';
export function UserMenu() {
const session = authClient.useSession();
if (session.isPending) {
return <Skeleton />;
}
if (!session.data) {
return <SignInButton />;
}
return <Avatar name={session.data.user.name} />;
}

Session Hook Return Type

{
data: Session | null;
isPending: boolean;
error: Error | null;
}

Summary

FunctionUse CaseRedirects?
getSession()Get session data in server contextsNo
requireSession()Get session or throw errorNo (throws)
getAccountContext()Get user context for protected pagesNo (throws if anonymous)
requireActiveOrganizationId()Require org contextYes (to app home path)
requireAuth guardGate a route to authenticated usersYes (to sign-in)
requireAdmin() / requireAdminAuth guardRequire admin roleYes (sign-in) / 404 for non-admins
isUserAdmin()Check admin without redirectNo
authAction()Protect server functionsNo (throws)
authClient.useSession()Client-side sessionNo

When to Use Each Pattern

Use getSession() when:

  • You need to check if a user is logged in
  • You want to handle the unauthenticated state yourself
  • You're inside a server function or loader

Use getAccountContext() when:

  • Loading data for a protected page (behind a requireAuth guard)
  • You need to know if the user is in personal or organization context

Use requireActiveOrganizationId() when:

  • The loader requires an active organization
  • You want an automatic redirect to the app home path if no org is selected

Use the requireAdminAuth guard / requireAdmin() when:

  • Gating admin-only routes
  • You want anonymous users sent to sign-in and non-admins hidden with a 404

Use authAction() (and friends) when:

  • Building server functions that require authentication
  • You want automatic error throwing if not authenticated

Frequently Asked Questions

Is getSession() called multiple times per request?
getSession() is memoized per request via a WeakMap keyed on the request headers, so multiple calls within the same request share the same result. You can call it freely without performance concerns.
How do I sign out a user?
Use authClient.signOut() from the client. This clears the session cookie and removes the session from the database. The user is then redirected to the sign-in page.
How long do sessions last?
Sessions are configured in Better Auth. By default, sessions expire after a period of inactivity. The exact duration depends on your configuration.
Can I access the session in middleware?
Route protection uses TanStack Router beforeLoad guards (apps/web/src/lib/auth/guards.ts) plus global request middleware in apps/web/src/start.ts. Read the session inside a server function or loader via getSession(); guards call it through the fetchSession server function.
What happens when a session expires?
The next request with an expired session returns null from getSession(). Client components using useSession() will update automatically. Protected pages will redirect to sign-in.

Previous: Password Reset ← | Next: Multi-Factor Authentication →