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.

Create a free account to save this page, take notes and track your progress. Sign in

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 React cache) 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

Login, signed session cookie, Proxy and DAL
// 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.forged

Output

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.

Was this page helpful?