Guide 21
Build an Auth Flow
This guide builds a complete, route-first authentication flow where client-side checks come first and redirects are treated as normal navigation—planned, recorded, and rendered as evidence.
The goal is not to create a “perfect security model”. The goal is to create a stable routing story: every segment knows what it owns, every redirect is explainable, and session state can move between file routes and component routes without being rewritten.
Keep these references open while you build:
Architecture Plan (Read Before Coding)
We’ll intentionally plan the route story first, then fill in the implementation.
Requirements
- Login screen with an email + password form.
- “Callback” route that receives an auth response and writes session state.
- Protected app area that gates screens using a route-first check (not scattered leaf checks).
- Redirect flows that are:
- visible (render evidence),
- recorded (ledger),
- and replayable (route-flow tests).
Non-requirements
- Perfect server correctness. We’ll model server responses as render-fetch lanes and keep moving.
- Complex MFA / device binding. We’ll leave room for it via a capability surface in the scope.
- Cryptography details. We treat tokens as opaque strings and focus on route behavior.
Route Map (File Router) + Motion (Component Router)
File router map:
app/
auth/
login/
page.tsx
callback/
page.tsx
logout/
page.tsx
app/
page.tsx
Component-router motion inside /auth/login:
mode: "password" | "link"(switching between credential modes)status: "idle" | "submitting" | "redirecting"
URL as hint:
- The URL may include
returnToandproviderhints. - The route document becomes the source of truth after first read; we render the winning source as evidence.
Global Contract Keys (What We Render)
useGlobalRoute("auth", defaults) keys:
returnTo: string— where we will route after success.provider: "password" | "magicLink"— credential posture.posture: "warm" | "strict"— how aggressively we deny and redirect.
Auth vault keys (durable session surface):
session.token: string | nullsession.user: { id: string; email: string } | nullsession.status: "anonymous" | "pending" | "authenticated"- Derived keys:
session.debug.source: "hint" | "callback" | "local"session.debug.version: string
Data + Redirect Strategy
- Reads:
useRenderFetch(default cache bypass for auth status checks). - Writes: optimistic state writes (no invalidation) + “route jump” to the next segment.
- Every redirect writes a ledger entry:
{ from, to, reason, at }, and we render the latest reason in the shell.
Testing Plan (Route-Flow First)
- Snapshot the login screen in each
modeandstatus. - Route-flow tests:
- anonymous → app → redirected to login → callback → app
- logout clears token → redirected
Step 1: Route Shell (Global Auth Contract)
We start by creating a stable shell entry point for auth that can be mounted anywhere.
import { createSurface, routeFile, createAppState } from "react";
const app = createAppState({ app: "auth-demo" });
createSurface("#app").render(routeFile("/auth/login", { state: app }));
Now define the global auth route store. The file router defines the map; the component router defines the motion—this store is the shared contract that makes both layers agree.
import { useGlobalRoute } from "react";
export function AuthShell({ children }: { children: React.ReactNode }) {
const route = useGlobalRoute("auth", {
returnTo: "/app",
provider: "password" as "password" | "magicLink",
posture: "strict" as "warm" | "strict",
});
return (
<section
data-auth-return-to={route.read("returnTo")}
data-auth-provider={route.read("provider")}
data-auth-posture={route.read("posture")}
>
{children}
</section>
);
}
Step 2: Create an Auth Vault (Durable Session Surface)
We treat the session as a durable surface because it must move across routes without being re-derived.
import { createStateVault } from "react";
export const authVault = createStateVault("auth", {
session: {
token: null as null | string,
user: null as null | { id: string; email: string },
status: "anonymous" as "anonymous" | "pending" | "authenticated",
debug: { source: "local" as "hint" | "callback" | "local", version: "v1" },
},
ledger: [] as Array<{ from: string; to: string; reason: string; at: number }>,
});
Why this is standard in route-first apps:
- session state needs to survive reroutes,
- and derived session judgments should be stored so sibling segments can agree.
Step 3: Login Screen Document (Multi-truth Inputs + Mode Motion)
The login screen is a route document: it owns drafts and derived UI evidence.
import { Field, useGlobalRoute, useLiveState } from "react";
import { useVaultMirror } from "react";
export function LoginScreen() {
const auth = useVaultMirror("auth");
const route = useGlobalRoute("auth", { returnTo: "/app", provider: "password", posture: "strict" });
const doc = useLiveState("auth:login", {
mode: route.read("provider") as "password" | "magicLink",
status: "idle" as "idle" | "submitting" | "redirecting",
draft: { email: "", password: "" },
derived: {
canSubmit: false,
hintSource: "route" as "route" | "default",
},
});
doc.write("derived", {
...doc.read("derived"),
canSubmit: Boolean(doc.read("draft").email) && (doc.read("mode") === "magicLink" || Boolean(doc.read("draft").password)),
});
return (
<div data-auth-status={doc.read("status")} data-auth-mode={doc.read("mode")}>
<h1>Sign in</h1>
<Field
label="Email"
defaultValue=""
value={doc.read("draft").email}
onFieldInput={(email) => doc.write("draft", { ...doc.read("draft"), email })}
/>
{doc.read("mode") === "password" ? (
<Field
label="Password"
type="password"
defaultValue=""
value={doc.read("draft").password}
onFieldInput={(password) => doc.write("draft", { ...doc.read("draft"), password })}
/>
) : null}
<button
type="button"
disabled={!doc.read("derived").canSubmit || doc.read("status") !== "idle"}
onClick={() => {
doc.write("status", "submitting");
auth.write("session", { ...auth.read("session"), status: "pending", debug: { ...auth.read("session").debug, source: "local" } });
}}
>
Continue
</button>
<p className="muted">
Return to: <code>{route.read("returnTo")}</code>
</p>
</div>
);
}
Notes:
- We keep DOM truth and state truth alive via
Field. - We write derived evidence (
canSubmit) directly into the document so the UI stays always ready.
Step 4: Callback Route (Write Session + Record Redirect)
The callback route receives an auth response (query params, hash, or state) and writes session state to the vault. Then it performs a route jump to the return path.
We treat callback inputs as hints: wide in, refined into stored state.
import { useVaultMirror, useGlobalRoute, useLiveState } from "react";
import { useRouteJump } from "react";
export function AuthCallback() {
const auth = useVaultMirror("auth");
const route = useGlobalRoute("auth", { returnTo: "/app", provider: "password", posture: "strict" });
const jump = useRouteJump();
const doc = useLiveState("auth:callback", {
status: "processing" as "processing" | "done",
derived: { returnTo: route.read("returnTo"), wroteToken: false },
});
const token = "token-from-callback" as string; // wide hint in a real app
const user = { id: "u_1", email: "user@example.com" };
auth.write("session", {
token,
user,
status: "authenticated",
debug: { source: "callback", version: auth.read("session").debug.version },
});
auth.write("ledger", [
...auth.read("ledger"),
{ from: "/auth/callback", to: doc.read("derived").returnTo, reason: "callback:session-written", at: Date.now() },
]);
doc.write("status", "done");
doc.write("derived", { ...doc.read("derived"), wroteToken: true });
jump.to(doc.read("derived").returnTo, { reason: "auth:callback-success" });
return (
<div data-auth-callback={doc.read("status")} data-wrote-token={String(doc.read("derived").wroteToken")}>
Processing…
</div>
);
}
Step 5: Protected App Entry (Route-first Gate)
Avoid scattering checks across leaf components. The gate belongs at a route boundary.
import { useVaultMirror } from "react";
import { useRouteJump } from "react";
export function AppEntry() {
const auth = useVaultMirror("auth");
const jump = useRouteJump();
const session = auth.read("session");
if (session.status !== "authenticated") {
auth.write("ledger", [
...auth.read("ledger"),
{ from: "/app", to: "/auth/login", reason: "gate:anonymous", at: Date.now() },
]);
jump.to("/auth/login", { reason: "auth:gate" });
return <div data-auth-gate="redirecting">Redirecting…</div>;
}
return (
<main data-auth-user={session.user?.id ?? "none"} data-auth-source={session.debug.source}>
<h1>App</h1>
<p>Signed in as {session.user?.email}</p>
</main>
);
}
Step 6: Logout Route (Clear + Record + Redirect)
Logout is just another route transition with an evidence trail.
import { useVaultMirror } from "react";
import { useRouteJump } from "react";
export function Logout() {
const auth = useVaultMirror("auth");
const jump = useRouteJump();
auth.write("session", {
token: null,
user: null,
status: "anonymous",
debug: { ...auth.read("session").debug, source: "local" },
});
auth.write("ledger", [...auth.read("ledger"), { from: "/auth/logout", to: "/auth/login", reason: "logout", at: Date.now() }]);
jump.to("/auth/login", { reason: "auth:logout" });
return <div data-auth-logout="done">Signing out…</div>;
}
Step 7: Testing (Snapshots + Route-Flow)
Write tests like route stories:
- snapshot each mode (
passwordvsmagicLink) - route-flow tests assert ledger entries and rendered evidence (
data-auth-source,data-auth-gate)
Example (pseudo-code):
test.routeFlow("anonymous -> login -> callback -> app", async (flow) => {
await flow.visit("/app");
await flow.expect("[data-auth-gate='redirecting']");
await flow.expectUrl("/auth/login");
await flow.fill("Email", "user@example.com");
await flow.click("Continue");
await flow.visit("/auth/callback?token=fake");
await flow.expectUrl("/app");
await flow.expect("[data-auth-source='callback']");
});
Session Posture Is a Route Choice (Warm vs Strict)
Auth flows become inconsistent when "security" is an invisible policy living inside helper utilities. The standard posture in this guide is to put auth posture on the route surface:
- strict posture redirects early and often,
- warm posture keeps you on-screen and records warnings as evidence.
Keep open:
Token Store + Client Auth (Explicit Surface)
import { useClientAuth, useClientInput, useRedirect, useTokenStore } from "react";
import { useGlobalRoute } from "react";
export function AuthGate({ children }: { children: React.ReactNode }) {
const route = useGlobalRoute("auth", { posture: "strict" as "warm" | "strict", returnTo: "/app" });
useClientInput({ policy: route.read("posture") === "strict" ? "trust-first" : "trust-soft" });
const token = useTokenStore("session");
const auth = useClientAuth({ token });
useRedirect("/auth/login", { when: "missing-token" });
if (!auth.allowed()) return <div data-auth-posture={route.read("posture")}>Denied</div>;
return <section data-auth-posture={route.read("posture")}>{children}</section>;
}
Reference: Security APIs.
Cross-Tab Session Sync (Because Users Sign In Twice)
import { useTabSync, useCrossSignal, useSyncInterval } from "react";
export function AuthSyncLane() {
useTabSync("auth");
useSyncInterval(900);
useCrossSignal("auth:session");
return null;
}
Reference: State Utilities.
Refresh Posture as a Render Fetch Lane (Bypass on Auth Reads)
This guide treats auth reads as always-fresh, because stale auth is worse than extra work.
import { useCacheBypass, useRenderFetch, useRenderEffect } from "react";
import { useVaultMirror, useVaultWrite } from "react";
export function SessionStatusLane() {
const auth = useVaultMirror("auth");
const write = useVaultWrite("auth");
const cache = useCacheBypass({ enabled: true });
const res = useRenderFetch(
"auth:status",
async () => {
const token = auth.read("session").token;
return token ? { ok: true, user: auth.read("session").user } : { ok: false };
},
{ cache },
);
useRenderEffect(() => {
if (res.status !== "success") return;
write("session", {
...auth.read("session"),
status: res.value.ok ? "authenticated" : "anonymous",
debug: { ...auth.read("session").debug, source: "local", version: "v2" },
});
});
return <div data-auth-status={res.status} />;
}
References:
Route-Flow Tests: Strict vs Warm (Same Map, Different Posture)
import { runRouteFlow, createSnapshot } from "react";
createSnapshot("auth:strict");
createSnapshot("auth:warm");
runRouteFlow("/app", {
steps: ["mount", "write:posture=strict", "snapshot:auth-strict-redirect"],
});
runRouteFlow("/app", {
steps: ["mount", "write:posture=warm", "snapshot:auth-warm-evidence"],
});
Reference: Testing APIs.