docs/auth/sessions.md
Session state is managed exclusively through TanStack Query. The auth client fetches session data, TanStack Query caches it, and route guards use the cache to protect pages – no direct auth.useSession() calls or local storage.
The session query is defined in apps/app/lib/queries/session.ts:
export function sessionQueryOptions() {
return queryOptions<SessionData | null>({
queryKey: ["auth", "session"],
queryFn: async () => {
const response = await auth.getSession();
if (response.error) throw response.error;
return response.data;
},
staleTime: 30_000, // 30 seconds
retry(failureCount, error) {
const status = getErrorStatus(error);
if (status === 401 || status === 403) return false;
return failureCount < 3;
},
});
}
Key behaviors:
null when unauthenticated (not an error)gcTime, refetchOnWindowFocus, and refetchOnReconnect from QueryClient defaultsinterface SessionData {
user: User; // id, name, email, emailVerified, image, ...
session: Session; // id, token, expiresAt, activeOrganizationId, ...
}
Both user and session must be present for valid auth state. Partial data (only user, only session) is treated as unauthenticated.
// In components – triggers fetch if stale
const { data } = useSessionQuery();
// With Suspense
const { data } = useSuspenseSessionQuery();
// Sync check of cache only – no network request
const session = getCachedSession(queryClient);
const loggedIn = isAuthenticated(queryClient);
The (app)/route.tsx layout route protects all app pages with a cache-first auth check:
// apps/app/routes/(app)/route.tsx
export const Route = createFileRoute("/(app)")({
beforeLoad: async ({ context, location }) => {
// Check cache first – instant navigation if session is cached
let session = getCachedSession(context.queryClient);
// Fetch only if cache is empty (first load or after cache clear)
if (session === undefined) {
session = await context.queryClient.fetchQuery(sessionQueryOptions());
}
if (!session?.user || !session?.session) {
throw redirect({
to: "/login",
search: { returnTo: location.href },
});
}
return { user: session.user, session };
},
component: AppLayout,
});
This pattern means:
/login if unauthenticatedreturnTo → preserves the original URL so users land back after loginThe session data is returned from beforeLoad and available to all child routes via Route.useRouteContext().
The login route ((auth)/login.tsx) handles the inverse – redirecting authenticated users away:
// apps/app/routes/(auth)/login.tsx
export const Route = createFileRoute("/(auth)/login")({
validateSearch: searchSchema,
beforeLoad: async ({ context, search }) => {
try {
const session = await context.queryClient.fetchQuery(
sessionQueryOptions(),
);
if (session?.user && session?.session) {
throw redirect({ to: search.returnTo ?? "/" });
}
} catch (error) {
if (isRedirect(error)) throw error;
// Fetch errors → show login form
}
},
});
After successful authentication, the login page revalidates the session and navigates:
async function handleSuccess() {
await revalidateSession(queryClient, router);
await router.navigate({ to: search.returnTo ?? "/" });
}
revalidateSession removes the cached session (forcing a fresh fetch) and invalidates the router so beforeLoad re-runs with new data.
The signOut function clears the server session, updates the cache, and performs a hard redirect:
// apps/app/lib/queries/session.ts
export async function signOut(
queryClient: QueryClient,
options?: { redirect?: boolean },
) {
try {
await auth.signOut();
} finally {
queryClient.setQueryData(sessionQueryKey, null);
if (options?.redirect !== false) {
window.location.href = "/login";
}
}
}
The hard redirect (window.location.href) resets all in-memory state – Jotai atoms, component state, TanStack Query cache – ensuring a clean slate between user sessions. Pass { redirect: false } for programmatic sign-out without navigation. setQueryData(null) is used instead of invalidateQueries to avoid a wasted refetch of a session that no longer exists.
The AuthErrorBoundary wraps protected route layouts and catches authentication errors that occur during rendering (e.g., a tRPC call returns 401):
// apps/app/components/auth/auth-error-boundary.tsx
export function AuthErrorBoundary({ children }) {
return (
<ErrorBoundary
FallbackComponent={AuthAwareErrorFallback}
onError={(error) => {
if (isUnauthenticatedError(error)) {
queryClient.removeQueries({ queryKey: sessionQueryKey });
}
}}
>
{children}
</ErrorBoundary>
);
}
The fallback UI shows two options:
/login with returnToAuth errors (401) get the auth-specific fallback. Other errors (500, network) get a generic error fallback with a retry button.
The API worker manages a lightweight routing cookie alongside the session. On sign-in, it sets __Host-auth=1 (HTTPS) or auth=1 (HTTP dev). On sign-out or invalid session, it clears it.
The web edge worker reads this cookie to decide how to route /:
// apps/web/worker.ts
const hasAuthHint =
getCookie(c, "__Host-auth") === "1" || getCookie(c, "auth") === "1";
const upstream = hasAuthHint ? c.env.APP_SERVICE : c.env.ASSETS;
This cookie is not a security boundary – it's a performance optimization. False positives (stale cookie after session expiry) cause one extra redirect to /login. The app worker is always the authority for session validation.
The cookie lifecycle is managed by Better Auth hooks:
| Event | Action |
|---|---|
| New session (sign-in, sign-up, OAuth) | Set cookie |
| Sign-out | Clear cookie |
| Session check with no valid session | Clear stale cookie |
See ADR-001 for the design rationale.