Concept
The beginner framing: authentication in a Next.js app breaks into three distinct concerns, verifying who a user is, tracking that verified state across requests, and deciding what they're allowed to access.
The precise mental model:
- Authentication, verifying identity (a username/password check, an OAuth provider).
- Session Management, tracking that verified state across requests, via either a stateless session (data/token in a cookie, verified on the server each time) or a database session (only an encrypted session ID in the cookie, actual data server-side).
- Authorization, deciding what a specific authenticated user can access, split into optimistic checks (cookie-only, fast, good for redirects/UI) and secure checks (database-verified, required before touching real data).
For all three, the docs recommend reaching for an established auth library (rather than a fully hand-rolled implementation) for production use, this repo's own admin flow, for instance, uses NextAuth v5's Credentials provider for exactly this reason.
Sign-up/login via a Server Action
"use client";
import { useActionState } from "react";
import { signup } from "@/app/actions/auth";
export function SignupForm() {
const [state, action, pending] = useActionState(signup, undefined);
return (
<form action={action}>
<input name="email" />
{state?.errors?.email && <p>{state.errors.email}</p>}
<input name
"use server";
import { SignupFormSchema } from "@/app/lib/definitions";
export async function signup(state: FormState, formData: FormData) {
const validated = SignupFormSchema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});
if (!validated.success) {
return { errors: validated.error.flatten().fieldErrors }; // return early, no DB call for invalid input
}
const hashedPassword = await bcrypt.hash(validated.data.password, 10);
Since Server Actions execute entirely server-side (see Server Actions), they're a genuinely secure place for this logic, but the same rule from that topic applies here without exception: validate and authorize inside the action itself, never assuming the form is the only caller.
Two session models
// Stateless: encrypt session data into a signed token stored in a cookie
import "server-only";
import { SignJWT, jwtVerify } from "jose";
export async function encrypt(payload: SessionPayload) {
return new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).setExpirationTime("7d").sign(encodedKey);
}
export async function decrypt(session: string) {
const { payload } = await jwtVerify(session, encodedKey, { algorithms: ["HS256"] });
return payload;
}A stateless session keeps the actual session data (encrypted/signed) in the cookie itself, simpler, but weaker if implemented carelessly. A database session keeps only an opaque session ID in the cookie, with the real data server-side, more secure, more infrastructure. The docs recommend a session-management library (iron-session, jose) either way, rather than hand-rolling the cryptography.
Optimistic vs. secure authorization, and where Proxy fits
// proxy.ts, an OPTIMISTIC check only
import { decrypt } from "@/app/lib/session";
export default async function proxy(req: NextRequest) {
const session = await decrypt((await cookies()).get("session")?.value);
if (protectedRoutes.includes(req.nextUrl.pathname) && !session?.userId) {
return NextResponse.redirect(new URL("/login", req.nextUrl));
}
return NextResponse.next();
}This is exactly the "optimistic check" use case from Proxy, since Proxy runs on every matched request, including prefetched ones, it should only ever read the cookie, never hit a database, to avoid turning every navigation into a database round-trip. "While Proxy can be useful for initial checks, it should not be your only line of defense", the majority of real authorization must happen as close as possible to the actual data.
The Data Access Layer (DAL): centralizing the secure check
// app/lib/dal.ts
import "server-only";
import { cache } from "react";
export const verifySession = cache(async () => {
const session = await decrypt((await cookies()).get("session")?.value);
if (!session?.userId) redirect("/login");
return { userId: session.userId };
});Wrapping verifySession in React.cache means every Server Component in a given render can call it directly, the actual verification runs once per request (see Data Fetching's deduplication pattern), while every caller still gets a genuine, secure, database-adjacent check, not just a cookie read.
Try It
Predict what happens before checking the solution.
// A team relies ONLY on this Proxy check to protect /dashboard/*
export default async function proxy(req: NextRequest) {
const session = await decrypt((await cookies()).get("session")?.value);
if (!session?.userId) return NextResponse.redirect(new URL("/login", req.nextUrl));
return NextResponse.next();
}The dashboard's Server Components and Server Actions perform no additional authorization checks of their own, trusting Proxy entirely. A user's session cookie is valid, but their account was suspended an hour ago (a fact that only exists in the database, not the cookie). What happens?
Solution
The suspended user still gets full access to /dashboard/* and anything it serves. Proxy's check is optimistic, it only reads the cookie, which still contains a validly-signed userId, with no way to know the account was suspended in the database afterward. Since nothing downstream re-verifies against the actual, current, authoritative account state, the suspension has no real effect until the session cookie itself expires or is somehow invalidated. This is exactly why Proxy "should not be your only line of defense", a genuine, database-backed check (via a DAL's verifySession, checked close to the actual protected data) is what would have caught this.
Implement It Yourself
Model the optimistic-vs-secure authorization split directly:
function optimisticCheck(cookieSession) {
// FAST, cookie only, no I/O. Good for redirects/UI, NOT for protecting real data.
return Boolean(cookieSession?.userId);
}
async function secureCheck(cookieSession, db) {
// SLOWER, verifies against the actual, current, authoritative source.
if (!cookieSession?.userId) return false;
const user = await db.user.findUnique({ where: { id: cookieSession.userId } });
return Boolean(user && !user.suspended); // catches state the cookie alone can't know about
}The optimistic check can only ever be as current as the cookie's signed contents; the secure check is what actually reflects real-time authorization state, this is the entire reason both exist, rather than one being simply "the better version" of the other.
Under the Hood
React.cache-wrapping verifySession is the identical request-scoped deduplication pattern from Data Fetching, one underlying check, shared by every Server Component in that render, rather than a separate database round-trip per caller. And the entire optimistic/secure split mirrors the "trust boundary" reasoning already covered for Server Actions: anything reachable from outside your own trusted server code (a cookie a client controls, a request that can be sent directly) can only ever inform an optimistic decision, the authoritative one has to happen against something the client can't forge or replay.
Common Mistakes
1. Treating Proxy's cookie check as sufficient protection
Covered in Try It, Proxy cannot know about account state changes that happened after the session cookie was issued. A genuine, database-adjacent check is still required for real data access.
2. Running database-backed authorization checks inside Proxy
export default async function proxy(req: NextRequest) {
const user = await db.user.findUnique(...); // ❌ Proxy runs on EVERY matched request
}Since Proxy runs on every matched request (including prefetches), a database call there adds real latency to every single navigation, exactly the "not for slow data fetching" warning from Proxy. Keep Proxy to cookie reads only.
3. Hand-rolling session cryptography instead of using a library
Signing and verifying tokens correctly (algorithm choice, expiration handling, secret rotation) is easy to get subtly wrong, the docs specifically recommend a session-management library (jose, iron-session) over custom cryptographic code.
4. Checking authorization in the UI but not in the Server Action itself
Exactly the Server Actions warning generalized: a "protected" form is not actually protected unless the Server Action it submits to independently verifies the user's session and permissions itself.
Best Practices
- Use an established auth library (NextAuth/Auth.js, Clerk, etc.) rather than a fully custom implementation for production apps.
- Reserve Proxy for optimistic, cookie-only checks, redirects, UI gating, never database-backed verification.
- Centralize the real, secure check in a Data Access Layer, wrapped in
React.cacheso it's cheap to call from every Server Component that needs it. - Verify authorization inside every Server Action and Route Handler independently, never assume Proxy or the surrounding UI already handled it.
- Use Data Transfer Objects (DTOs) to return only the specific fields a caller actually needs, rather than leaking an entire database record.
Performance Tips
React.cache-wrapping the DAL's session-verification function means a page with many components each needing to check auth pays for exactly one verification per request, not one per caller.- Keeping Proxy's checks cookie-only (never database-backed) is what keeps its cost negligible despite running on every single matched request, including prefetches.
