Authentication in the App Router is mostly straightforward, with one conceptual trap that produces real vulnerabilities. This is the mental model we use.
Cookie plus JWT basics
For a session, a signed JWT in an httpOnly cookie is a reasonable default.
const token = signToken({ sub: user.id, email: user.email, typ: "customer" }, { expiresIn: "12h" });
res.cookies.set("session_token", token, {
httpOnly: true, // not readable by JavaScript
sameSite: "lax", // CSRF mitigation, survives normal navigation
secure: process.env.NODE_ENV === "production", // HTTPS only in production
path: "/",
maxAge: 60 * 60 * 12,
});
httpOnly is the important one: it means XSS cannot exfiltrate the session. Storing a token in localStorage gives that ability away for convenience you do not need.
Keep the payload minimal — a user id, a type, an expiry. A JWT is signed, not encrypted: anyone can decode it. Nothing sensitive goes in.
Middleware is for routing, not authorization
This is the trap, and it is worth being precise about.
Middleware runs on the edge before a request is handled. It is the natural place to redirect unauthenticated users, so people put authorization there:
export function middleware(req: NextRequest) {
const token = req.cookies.get("session_token")?.value;
if (!token) return NextResponse.redirect(new URL("/login", req.url));
return NextResponse.next(); // ⚠️ presence checked, nothing verified
}
That checks a cookie exists. It does not verify the signature, check expiry, confirm the user still exists, or that they are still active. Any string in that cookie passes.
Even with full verification in middleware, it is the wrong sole line of defence: middleware does not run on every path you might expect, matcher config changes, and route handlers can be reached in ways that bypass your assumptions.
The rule: middleware decides where a request goes. The page or route handler decides whether this user may have this data. Middleware is UX; the server component is security.
Server component session checks
Real authorization happens where the data is read:
export async function requireSession() {
const token = (await cookies()).get("session_token")?.value;
if (!token) redirect("/login");
const claims = verifyToken(token); // signature + expiry
if (!claims || claims.typ !== "customer") redirect("/login");
const user = await db.user.findUnique({ where: { id: claims.sub } });
if (!user || !user.isActive) redirect("/login"); // revocation
return user;
}
Calling this in a layout protects everything beneath it:
export default async function DashboardLayout({ children }) {
const user = await requireSession();
return <Shell user={user}>{children}</Shell>;
}
The database lookup matters. A JWT is valid until it expires — without checking the user record, a deactivated account keeps working until the token lapses.
Apply the same check in every API route that returns user data. A page being protected says nothing about the endpoint behind it.
Route groups for separate auth realms
Route groups organise without affecting URLs. With several audiences — customers, admins, partners — give each its own group, layout and cookie:
app/
(auth)/login, sign-up → public
(dashboard)/ → requireCustomerSession() in layout
admin/ → requireAdminSession()
Separate cookie names per realm (customer_token, admin_token) prevent a token for one surface being accepted by another, and let someone hold two sessions without conflict.
OAuth callbacks
Three details matter.
Validate state. Generate a random value, store it in a short-lived cookie, and compare on return. Skipping this is a CSRF hole.
Only trust verified emails. email_verified === false from the provider means you cannot treat that address as proof of identity — otherwise account takeover via an unverified address becomes possible.
A server redirect cannot run client code. The callback sets a cookie and redirects. Any client-side work that should follow a login — analytics, a welcome state — has to be signalled:
const dest = new URL("/dashboard", origin);
dest.searchParams.set("auth", isNewUser ? "signup" : "login");
const res = NextResponse.redirect(dest);
res.cookies.set("session_token", token, { httpOnly: true, /* … */ });
The destination reads the parameter, acts on it, and strips it with history.replaceState so a refresh does not repeat it.
Common holes
Presence-only middleware treated as authorization — the big one
Unprotected API routes behind protected pages
No revocation path — never checking the user still exists and is active
Tokens in
localStorage— XSS becomes account takeoverMissing
statevalidation in OAuthOver-stuffed JWTs carrying data that is readable by anyone
No
sameSiteon the session cookie
If you check one thing after reading this: open a protected API route directly, with no session, and confirm it returns 401 rather than data.