Next.js App Router in Production · Streaming, Security and Operations
Authentication: Sessions, Proxy and a Data Access Layer
Implement login with a signed, httpOnly session cookie, optimistic redirects in Proxy, and authoritative checks in a data access layer.
What you'll build
Email and password login with a signed session cookie (httpOnly, Secure, SameSite), set by a Server Action. Proxy redirects requests without a valid session away from /dashboard (an optimistic check), and a data access layer (DAL) verifies the session again wherever data is read (the authoritative check). Logout deletes the cookie.
The idea
- Store a session as a signed (or encrypted) cookie, or a random ID referencing a database row. Never trust an unsigned cookie value such as
userId=u1. - Cookie flags:
httpOnly(no JavaScript access),secure(HTTPS only),sameSite: "lax"(CSRF protection for most requests), and an expiry. - Proxy runs before routes; use it for fast redirects based on the cookie, not for database lookups or as the only check.
- The DAL (
verifySession()wrapped in Reactcache) checks the session wherever data is read and in every Server Action. Production apps often use a library such as Auth.js or Better Auth for this; the structure is the same.
The code
// file: next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true };
export default nextConfig;
// file: app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body><main>{children}</main></body>
</html>
);
}
// file: .env
SESSION_SECRET=example-secret-use-32-random-bytes-in-production
// file: app/lib/session.ts
import { createHmac, timingSafeEqual } from "node:crypto";
export type Session = { userId: string; expiresAt: number };
const mac = (payload: string) => createHmac("sha256", process.env.SESSION_SECRET!).update(payload).digest("base64url");
export function signSession(session: Session) {
const payload = Buffer.from(JSON.stringify(session)).toString("base64url");
return `${payload}.${mac(payload)}`;
}
export function readSession(token: string | undefined): Session | null {
const [payload, signature] = token?.split(".") ?? [];
if (!payload || !signature) return null;
const expected = mac(payload);
if (signature.length !== expected.length || !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) return null;
const session = JSON.parse(Buffer.from(payload, "base64url").toString()) as Session;
return session.expiresAt > Date.now() ? session : null;
}
// file: app/lib/users.ts
import "server-only";
import { scryptSync, timingSafeEqual } from "node:crypto";
const hash = (password: string) => scryptSync(password, "per-user-salt", 32);
const users = [{ id: "u1", name: "Ana", email: "[email protected]", passwordHash: hash("correct-horse-battery") }];
export function checkCredentials(email: string, password: string) {
const user = users.find((u) => u.email === email);
const ok = user !== undefined && timingSafeEqual(hash(password), user.passwordHash);
return ok ? user : null;
}
export function findUser(id: string) {
return users.find((u) => u.id === id) ?? null;
}
// file: app/lib/dal.ts
import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { readSession } from "./session";
import { findUser } from "./users";
export const verifySession = cache(async () => {
const session = readSession((await cookies()).get("session")?.value);
if (!session) redirect("/login");
return session;
});
export async function getCurrentUser() {
const { userId } = await verifySession();
const user = findUser(userId);
return user ? { id: user.id, name: user.name } : null; // only what the UI needs
}
// file: app/login/actions.ts
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { signSession } from "../lib/session";
import { checkCredentials } from "../lib/users";
export async function login(_prev: { error?: string }, formData: FormData) {
const user = checkCredentials(String(formData.get("email")), String(formData.get("password")));
if (!user) return { error: "Invalid email or password" };
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000;
(await cookies()).set("session", signSession({ userId: user.id, expiresAt }), {
httpOnly: true, secure: true, sameSite: "lax", path: "/", expires: new Date(expiresAt),
});
redirect("/dashboard");
}
export async function logout() {
(await cookies()).delete("session");
redirect("/login");
}
// file: app/login/form.tsx
"use client";
import { useActionState } from "react";
import { login } from "./actions";
export function LoginForm() {
const [state, action, pending] = useActionState(login, {});
return (
<form action={action}>
<input name="email" type="email" aria-label="Email" />
<input name="password" type="password" aria-label="Password" />
<button disabled={pending}>Log in</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
);
}
// file: app/login/page.tsx
import { LoginForm } from "./form";
export default function Page() {
return <><h1>Log in</h1><LoginForm /></>;
}
// file: app/dashboard/page.tsx
import { Suspense } from "react";
import { getCurrentUser } from "../lib/dal";
import { logout } from "../login/actions";
async function Welcome() {
const user = await getCurrentUser();
return <p>Welcome back, {user?.name}</p>;
}
export default function Page() {
return (
<>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading...</p>}><Welcome /></Suspense>
<form action={logout}><button>Log out</button></form>
</>
);
}
// file: proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { readSession } from "./app/lib/session";
export function proxy(request: NextRequest) {
const session = readSession(request.cookies.get("session")?.value); // optimistic: cookie only, no database
const path = request.nextUrl.pathname;
if (path.startsWith("/dashboard") && !session) return NextResponse.redirect(new URL("/login", request.nextUrl));
if (path === "/login" && session) return NextResponse.redirect(new URL("/dashboard", request.nextUrl));
return NextResponse.next();
}
export const config = { matcher: ["/dashboard/:path*", "/login"] };
// request: GET /dashboard
// request: SUBMIT /login [email protected]&password=wrong
// request: SUBMIT /login [email protected]&password=correct-horse-battery
// request: GET /dashboard
// request: GET /login
// request: SUBMIT /dashboard
// request: GET /dashboard
// request: GET /dashboard cookie:session=eyJ1c2VySWQiOiJ1MiIsImV4cGlyZXNBdCI6NDEwMjQ0NDgwMDAwMH0.forgedOutput
GET /dashboard -> 307 (Location: /login) SUBMIT /login [email protected]&password=wrong -> 200 Log in Log in Invalid email or password SUBMIT /login [email protected]&password=correct-horse-battery -> 303 (Location: /dashboard) GET /dashboard -> 200 Dashboard Welcome back, Ana Log out GET /login -> 307 (Location: /dashboard) SUBMIT /dashboard -> 303 (Location: /login) GET /dashboard -> 307 (Location: /login) GET /dashboard -> 307 (Location: /login)
Run this on your computer — see “Get Started” for setup.
The forged cookie claims to be user u2 with a far-future expiry, but its signature doesn't match, so it's rejected. Use a per-user random salt (or a library like argon2) for real password hashes; the fixed salt keeps the example short.
Common mistakes
- Relying on Proxy alone; it's skipped for some requests (matchers, Server Actions posted to other paths) and shouldn't hit the database. Check in the DAL too.
- Checking auth in a layout and assuming child pages and Server Actions are protected; layouts don't re-run on every navigation, and actions are separate endpoints.
- Session cookies without
httpOnly/secure, or storing unsigned user IDs. - Returning whole user records (password hashes, emails) from the DAL to components.
Key points
- Signed or database-backed sessions in httpOnly, Secure, SameSite cookies.
- Proxy for optimistic redirects; the DAL for authoritative checks near the data.
- Verify the session in every Server Action and data read.
Further reading: Authentication guide · Proxy · OWASP Session Management Cheat Sheet
Practice questions
Why shouldn't Proxy be your only authorization check?
Answer
It's meant for fast optimistic checks; matchers can miss paths and it shouldn't query the database. Authoritative checks belong in the data access layer and every Server Action.
What do the httpOnly, secure and sameSite cookie flags do?
Answer
httpOnly hides the cookie from JavaScript (limiting XSS theft), secure sends it only over HTTPS, and sameSite=lax stops it being sent on most cross-site requests (CSRF protection).
Why wrap verifySession in React's cache?
Answer
Several components and functions call it during one request; cache deduplicates the work so the session is read and verified once per render.
Open in the app to mark lessons complete, take notes and track your progress.