The session lives in a cookie
Cookies can only be set in a server action or a route handler — not while rendering a page, because the HTML may already be streaming. That restriction is a feature: it forces the login to be a real request.
import { cookies } from "next/headers";
(await cookies()).set("sid", sessionId, {
httpOnly: true, // no script can read it — blunts XSS
secure: true, // HTTPS only
sameSite: "lax", // not sent on cross-site POSTs — blunts CSRF
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
Reading it, everywhere
// lib/auth.js
import { cache } from "react";
import { cookies } from "next/headers";
export const auth = cache(async () => {
const sid = (await cookies()).get("sid")?.value;
if (!sid) return null;
return await getSession(sid); // deduplicated across the whole render
});
// a server component
const session = await auth();
// a client component — it cannot read the cookie, so pass what it needs
<Avatar user={session ? { name: session.name } : null} />
Any page that checks a session is rendered per request. That is correct — it just means the marketing pages should not call auth() for a header avatar. Put the avatar in a small client component fed by a cached endpoint, or accept the dynamic render deliberately.
Guard where the data is
- A layout does not re-run on every navigation, and a route handler or an action is reached without it at all.
- Check in the page, the action and the handler — wherever the data is touched.
- Middleware is a gate that saves a render, not a permission system.
// app/(app)/layout.tsx — convenient, and NOT enough on its own
const session = await auth();
if (!session) redirect("/login");
// app/(app)/doors/[id]/page.tsx — the check that actually protects the data
const session = await auth();
if (!session) redirect("/login");
const door = await db.doors.byId(id);
if (door.ownerId !== session.userId) notFound(); // not 403: do not confirm it exists
Three layers, on purpose
Only the last one is a security boundary. The first two exist so the experience is good.
| Layer | Question | Cost of getting it wrong |
|---|---|---|
| Middleware | Is there a cookie at all? | A wasted render |
| Page / layout | Who is this, and may they see this screen? | A leaked screen |
| Action / handler / query | May they do this, to this row? | A leaked or destroyed record |
Signing in and out
"use server";
export async function signIn(prevState, formData) {
const user = await verify(formData.get("email"), formData.get("password"));
if (!user) return { error: "Invalid email or password" }; // the same message either way
const sid = await createSession(user.id);
(await cookies()).set("sid", sid, { httpOnly: true, secure: true, sameSite: "lax", path: "/" });
redirect(formData.get("next") || "/dashboard");
}
export async function signOut() {
const sid = (await cookies()).get("sid")?.value;
if (sid) await destroySession(sid); // delete it on the SERVER, not just the cookie
(await cookies()).delete("sid");
redirect("/login");
}
Libraries
Whatever you use, the rule above does not change: the check that matters happens next to the data.
- Auth.js (NextAuth) — OAuth providers, sessions and callbacks. The obvious default for "sign in with Google".
- Clerk / WorkOS / Supabase Auth — hosted, with the flows (reset, verification, MFA, organisations) already built.
- Lucia, or your own — a session table and a cookie. Perfectly reasonable, and you own every edge case.
Never trust the client half
// this is a nicety
{session.role === "admin" && <DeleteButton id={door.id} />}
// this is the control
"use server";
export async function deleteDoor(formData) {
const session = await auth();
if (session?.role !== "admin") throw new Error("Not allowed");
…
}