Protected Routes with Auth
This recipe protects a dashboard with a session cookie:
- A login API route verifies credentials and sets an HttpOnly session cookie
- Middleware guards
/dashboard/*and redirects anonymous visitors to/login - Pages and API routes read the session from the request
No auth library is required — the pattern works with any token store.
# 1. Session helper
// src/app/lib/session.ts
import { createSign } from "node:crypto";
const SECRET = process.env.SESSION_SECRET!;
const MAX_AGE = 60 * 60 * 24 * 7; // 7 days
export interface Session {
userId: string;
name: string;
}
// A signed session token, e.g. "userId.signature"
export function signSession(session: Session): string {
const payload = Buffer.from(JSON.stringify(session)).toString("base64url");
const signature = createSign("sha256").update(payload).update(SECRET).digest("base64url");
return `${payload}.${signature}`;
}
export function verifySession(token: string | undefined): Session | null {
if (!token) return null;
const [payload, signature] = token.split(".");
const expected = createVerify("sha256").update(payload).update(SECRET).digest("base64url");
if (signature !== expected) return null;
try {
return JSON.parse(Buffer.from(payload, "base64url").toString()) as Session;
} catch {
return null;
}
}
export const SESSION_COOKIE = "nix_session";// src/app/lib/session.ts — imports
import { createSign, createVerify } from "node:crypto";# 2. Login page
The form posts to an API route — API handlers return full Response objects, so they can set the session cookie:
// src/app/login/page.ts
import { html } from "@deijose/nix-js";
import type { PageProps } from "@deijose/nix-js-kit";
export default function LoginPage({ searchParams }: PageProps) {
const error = searchParams.get("error");
return html`
<h1>Log in</h1>
${error ? html`<p class="error">Invalid email or password</p>` : null}
<form action="/api/login" method="POST">
<label>Email <input name="email" type="email" required /></label>
<label>Password <input name="password" type="password" required /></label>
<button type="submit">Log in</button>
</form>
`;
}# 3. The login API route
// src/app/api/login/route.ts
import { verifyPassword, findUser } from "../../lib/users";
import { signSession, SESSION_COOKIE } from "../../lib/session";
export async function POST(request: Request) {
const form = await request.formData();
const email = String(form.get("email") ?? "");
const password = String(form.get("password") ?? "");
const user = await findUser(email);
if (!user || !(await verifyPassword(user, password))) {
return new Response(null, {
status: 302,
headers: { Location: "/login?error=1" },
});
}
return new Response(null, {
status: 302,
headers: {
Location: "/dashboard",
"Set-Cookie": `${SESSION_COOKIE}=${signSession({ userId: user.id, name: user.name })}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 7}`,
},
});
}The plain HTML form posts here, the browser follows the 302, and the HttpOnly cookie is set — no client JavaScript involved.
# 4. Middleware guard
// src/middleware.ts
import type { Middleware } from "@deijose/nix-js-kit";
import { verifySession, SESSION_COOKIE } from "./src/app/lib/session";
export default (async (request, ctx) => {
const url = new URL(request.url);
// Allow the login flow through.
if (url.pathname === "/login" || url.pathname.startsWith("/api/")) {
return ctx.next();
}
// Only guard /dashboard.
if (url.pathname.startsWith("/dashboard")) {
const session = verifySession(request.headers.get("Cookie")?.split(`${SESSION_COOKIE}=`)[1]?.split(";")[0]);
if (!session) {
return new Response(null, {
status: 302,
headers: { Location: "/login" },
});
}
// Attach the session to loaders via response headers.
return ctx.next({ headers: { "x-session-user": session.userId } });
}
return ctx.next();
}) satisfies Middleware;With config.matcher, the same guard can run only for matching paths — see Middleware.
# 5. Reading the session in a dashboard page
// src/app/dashboard/page.data.ts
import type { PageDataLoad } from "@deijose/nix-js-kit";
export const load: PageDataLoad = async ({ request }) => {
const userId = request?.headers.get("x-session-user");
const profile = await getUserProfile(userId);
return { profile };
};// src/app/dashboard/page.ts
import { html } from "@deijose/nix-js";
import type { PageProps } from "@deijose/nix-js-kit";
import { load } from "./page.data";
export default function DashboardPage({ data }: PageProps<typeof load>) {
return html`
<h1>Dashboard</h1>
<p>Welcome back, ${data.profile.name}!</p>
<form action="/api/logout" method="POST">
<button type="submit">Log out</button>
</form>
`;
}// src/app/api/logout/route.ts
import { SESSION_COOKIE } from "../../lib/session";
export function POST() {
return new Response(null, {
status: 302,
headers: {
Location: "/login",
"Set-Cookie": `${SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly`,
},
});
}# 6. Reading the session in an API route
// src/app/api/me/route.ts
import { verifySession, SESSION_COOKIE } from "../../lib/session";
export function GET(request: Request) {
const token = request.headers
.get("Cookie")
?.split(`${SESSION_COOKIE}=`)[1]?.split(";")[0];
const session = verifySession(token);
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
return Response.json(session);
}# Security notes
- The cookie is
HttpOnly+SameSite=Lax; the token never reaches client JS - CSRF is handled automatically for server actions by the origin check (see Security); API routes that mutate state should verify the
Origin/Refererheader themselves, or rely on SameSite cookies + content-type checks - Middleware runs before API routes, so the API check above is defense-in-depth
- Use a long random
SESSION_SECRETand rotate it on deploy
# Flow summary
- Anonymous user visits
/dashboard→ middleware 302 →/login?error=1 - Form posts to
/api/login→ API route verifies, 302 to/dashboardwithSet-Cookie - Browser lands on
/dashboardwith the session cookie → middleware validates → page loads withx-session-userheader → loader fetches the profile - Logout POSTs to
/api/logout, clears the cookie, redirects to/login