Authentication answers "who is this?"; authorization answers "what may they do?". In the App Router these questions are asked in three places, and the division matters. After this lesson you will be able to issue and verify session cookies, protect data in the right layer, enforce roles, and decide when a library is the better choice.
| Layer | Role | Strength |
|---|---|---|
| Proxy (proxy.ts) | Redirect anonymous users early | Optimistic; can be bypassed |
| Data Access Layer | Verify the session before every query or mutation | Authoritative |
| UI | Hide controls the user cannot use | Cosmetic |
The DAL is the source of truth; the proxy and the UI improve experience but never replace a check next to the data.
A stateless session is a signed token in an httpOnly cookie; the jose library signs and verifies it:
// lib/session.ts
import "server-only";
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
const key = new TextEncoder().encode(process.env.SESSION_SECRET);
export async function encrypt(payload: { userId: string; role: string }) {
return new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).setExpirationTime("7d").sign(key);
}
export async function decrypt(token: string) {
try {
return (await jwtVerify(token, key, { algorithms: ["HS256"] })).payload;
} catch {
return null;
}
}
export async function createSession(userId: string, role: string) {
const token = await encrypt({ userId, role });
(await cookies()).set("session", token, { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 604800 });
}A login Server Action verifies the password with a slow hash such as bcrypt or Argon2, then creates the session:
"use server";
export async function login(formData: FormData) {
const user = await findUserByEmail(String(formData.get("email")));
if (!user || !(await verifyPassword(String(formData.get("password")), user.hash))) {
return { error: "Invalid credentials" };
}
await createSession(user.id, user.role);
redirect("/dashboard");
}Logging out deletes the cookie. For server-side revocation, store a session id in the cookie and the record in the database.
The verifySession function in the sample at the top of this lesson reads the cookie, verifies it, and redirects if invalid. Wrapping it in React's cache means many components calling it in one request verify once. Every DAL function starts with it, and every query is scoped to the caller, so a user can never fetch another user's orders by guessing an id.
Enforce roles where the action happens:
// lib/dal.ts
export async function deleteUser(targetId: string) {
const { role } = await verifySession();
if (role !== "admin") throw new Error("Forbidden");
await db.user.delete({ where: { id: targetId } });
}Return data transfer objects rather than raw records: map the row to { id, name, avatarUrl } and drop the password hash. Do not rely on a layout calling verifySession() to protect its children; layouts do not re-run on every navigation, so sensitive pages call the DAL themselves.
Production systems usually adopt a library for OAuth, email verification and passkeys:
| Library | Style |
|---|---|
| Auth.js (NextAuth) | Open source, provider-driven, auth() helper |
| Better Auth | Open source, TypeScript-first, plugin system |
| Clerk, Kinde, WorkOS | Hosted services with prebuilt UI |
Whichever you choose, the DAL pattern stays: one function returns the verified session and every query goes through it.
localStorage, exposing it to any script on the page.Which layer is the authoritative place to verify a session?
httpOnly, secure, sameSite cookies; jose signs and verifies them.verifySession in cache so each request verifies once, and scope every query to the caller.Next lesson: State Management in the App Router ā decide what belongs in the URL, on the server and in client stores.