Authentication
Protect routes with session-based auth using middleware, loaders, and API routes. This recipe covers login/logout flows, session management, and route guards.
Architecture
Auth in pracht follows a simple pattern: middleware checks the session before any loader runs. If there's no valid session, redirect to login. Loaders can read the authenticated user. API routes handle login/logout mutations.
- Middleware — gate access, redirect unauthenticated users
- Loaders — read session data, pass user to components
- API routes — handle login/logout mutations
- Cookies — store session tokens (set via API route response headers)
1. Session Utilities
Create a small session module that reads/writes signed cookies. This example uses a simple HMAC approach — swap in your preferred session library.
The signing secret is read through serverEnv, which keeps it out of the client bundle and resolves per adapter. Read it inside the function rather than at module top level so it works on Cloudflare, where env bindings only exist per request.
import { serverEnv } from "@pracht/core/env/server";
export interface Session {
userId: string;
email: string;
}
export async function getSession(request: Request): Promise<Session | null> {
const cookie = request.headers.get("cookie") ?? "";
const match = cookie.match(/session=([^;]+)/);
if (!match) return null;
try {
const [payload, signature] = match[1].split(".");
const expected = await sign(payload);
if (signature !== expected) return null;
return JSON.parse(atob(payload));
} catch {
return null;
}
}
export async function createSessionCookie(session: Session): Promise<string> {
const payload = btoa(JSON.stringify(session));
const signature = await sign(payload);
return `session=${payload}.${signature}; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800`;
}
export function clearSessionCookie(): string {
return "session=; Path=/; HttpOnly; Max-Age=0";
}
async function sign(data: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(serverEnv.SESSION_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
return btoa(String.fromCharCode(...new Uint8Array(sig)));
}2. Auth Middleware
This middleware redirects unauthenticated users to the login page. Apply it to any route group that requires auth.
import { redirect, type MiddlewareFn } from "@pracht/core";
import { getSession } from "../server/session";
export const middleware: MiddlewareFn = async ({ request }, next) => {
const session = await getSession(request);
if (!session) {
const loginUrl = `/login?redirect=${encodeURIComponent(new URL(request.url).pathname)}`;
return redirect(loginUrl, { request });
}
// Pass user info downstream via a header (loaders can read it)
request.headers.set("x-user-id", session.userId);
request.headers.set("x-user-email", session.email);
return next();
};3. Login Page
The login page renders the form, while an API route handles credential validation and sets the session cookie:
import { createSessionCookie } from "../../server/session";
export async function POST({ request }: ApiRouteArgs) {
const form = await request.formData();
const email = String(form.get("email") ?? "");
const password = String(form.get("password") ?? "");
const redirectTo = String(form.get("redirect") ?? "/dashboard");
// Replace with your actual auth logic
const user = await verifyCredentials(email, password);
if (!user) {
return Response.json({ error: "Invalid email or password" }, { status: 401 });
}
const cookie = await createSessionCookie({
userId: user.id,
email: user.email,
});
return new Response(null, {
status: 302,
headers: {
location: redirectTo,
"set-cookie": cookie,
},
});
}
async function verifyCredentials(email: string, password: string) {
// Your DB lookup here
return null as any;
}import type { LoaderArgs, RouteComponentProps } from "@pracht/core";
import { Form } from "@pracht/core";
export async function loader({ url }: LoaderArgs) {
return { redirect: url.searchParams.get("redirect") ?? "/dashboard" };
}
export function Component({ data }: RouteComponentProps<typeof loader>) {
return (
<div class="login-page">
<h1>Log in</h1>
<Form method="post" action="/api/auth/login">
<input type="hidden" name="redirect" value={data.redirect} />
<label>
Email
<input type="email" name="email" required />
</label>
<label>
Password
<input type="password" name="password" required />
</label>
<button type="submit">Log in</button>
</Form>
</div>
);
}4. Logout
import { clearSessionCookie } from "../../server/session";
export async function POST(_args: ApiRouteArgs) {
return new Response(null, {
status: 302,
headers: {
location: "/",
"set-cookie": clearSessionCookie(),
},
});
}Trigger logout from anywhere with a form:
<Form method="post" action="/api/auth/logout">
<button type="submit">Log out</button>
</Form>5. Reading the User in Loaders
Behind the auth middleware, loaders can safely read user info from the headers set by middleware:
import type { LoaderArgs, RouteComponentProps } from "@pracht/core";
export async function loader({ request }: LoaderArgs) {
const userId = request.headers.get("x-user-id")!;
const projects = await db.projects.findMany({ userId });
return { userId, projects };
}
export function Component({ data }: RouteComponentProps<typeof loader>) {
return (
<div>
<h1>Dashboard</h1>
<ul>
{data.projects.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
);
}6. Wire It Up
import { defineApp, group, route } from "@pracht/core";
export const app = defineApp({
shells: {
public: "./shells/public.tsx",
app: "./shells/app.tsx",
},
middleware: {
auth: "./middleware/auth.ts",
},
routes: [
// Public routes — no auth
group({ shell: "public" }, [
route("/", "./routes/home.tsx", { render: "ssg" }),
route("/login", "./routes/login.tsx", { render: "ssr" }),
]),
// Protected routes — auth middleware applied
group({ shell: "app", middleware: ["auth"] }, [
route("/dashboard", "./routes/dashboard.tsx", { render: "ssr" }),
route("/settings", "./routes/settings.tsx", { render: "ssr" }),
]),
],
});Tips
- Use
render: "ssr"for all auth-related routes — they depend on cookies which are per-request. - For OAuth flows, handle the callback in an API route (
src/api/auth/callback.ts) that sets the session cookie and redirects. - For role-based access, extend the middleware to check permissions and return a
403or redirect. - Never store passwords or secrets in loader data — it gets serialized to the client. Only return what the component needs.
CSRF Protection
Session cookies are the ambient credential a CSRF attack abuses: a malicious site submits a form to your API and the browser attaches the cookie automatically.
Built in: same-origin enforcement (on by default)
Pracht ships this defense in the runtime. State-changing API requests (POST/PUT/PATCH/DELETE) are rejected with a 403 unless the browser signals an exact same-origin request — Sec-Fetch-Site: same-origin, or an Origin/Referer header matching the request URL's origin. Sec-Fetch-Site: same-site is deliberately not accepted, because sibling subdomains can be attacker-controlled. Requests with no browser provenance headers at all (curl, server-to-server, tests) are allowed — a browser form can't produce those.
This runs before API middleware, is controlled by ApiConfig.requireSameOrigin, and defaults to true. Opt out only if you build your own CSRF protection into middleware:
defineApp({
api: {
middleware: ["auth"],
requireSameOrigin: false, // default: true
},
routes: [...],
});So for a first-party app, cross-site form CSRF is blocked out of the box. The layers below still matter — here's when:
1. SameSite on the session cookie
The session cookie in the snippet above already sets SameSite=Lax. That keeps the cookie off cross-site POST/PUT/PATCH/DELETE submissions in every modern browser, so the attack fails even before the server-side check runs. Use SameSite=Strict if you don't need inbound links from other sites to arrive authenticated. Keep this layer — cookie scoping and origin enforcement protect against different failure modes.
`session=${payload}.${signature}; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800`;2. Custom origin middleware (allowlists)
The built-in check accepts exactly one origin: your own. If trusted cross-origin callers need to hit your mutation endpoints (e.g. an admin app on another domain), or you disabled requireSameOrigin, add a middleware with an explicit allowlist:
import type { MiddlewareFn } from "@pracht/core";
const UNSAFE = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const ALLOWED = new Set<string>([
// add trusted cross-origin callers here (e.g. "https://admin.example.com")
]);
export const middleware: MiddlewareFn = ({ request, url }, next) => {
if (!UNSAFE.has(request.method)) return next();
const origin = request.headers.get("origin");
if (origin === null) {
// No Origin header: either a non-browser client or an attacker dodging
// the check. Sec-Fetch-Site tells us when the browser itself marked the
// request as same-origin or user-initiated.
const site = request.headers.get("sec-fetch-site");
if (site === "same-origin" || site === "none") return next();
return new Response("Forbidden: missing Origin", { status: 403 });
}
if (origin === url.origin) return next();
if (ALLOWED.has(origin)) return next();
return new Response(`Forbidden: origin ${origin} not allowed`, { status: 403 });
};Wire it to the whole API (or just mutation groups) in routes.ts, and turn the stricter built-in check off since it would reject the allowlisted origins first:
defineApp({
middleware: {
auth: "./middleware/auth.ts",
originCheck: "./middleware/origin-check.ts",
},
api: { middleware: ["originCheck"], requireSameOrigin: false },
routes: [...],
});This is a pure header check — it doesn't issue or validate tokens. Pair it with SameSite cookies; skip synchronizer tokens unless you explicitly need them (e.g. you allow SameSite=None for embedding).
3. Token-based CSRF
Only reach for per-request CSRF tokens if your cookies must be SameSite=None (e.g. you embed the app in a third-party iframe). For first-party apps, the layers above are sufficient.