CreativeCape

Auth Patterns in the Next.js App Router

Cookie and JWT sessions, why middleware is for routing rather than authorization, and the server-component checks that actually protect a route.

August 8, 2026·4 min read

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 takeover

  • Missing state validation in OAuth

  • Over-stuffed JWTs carrying data that is readable by anyone

  • No sameSite on 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.


→ Web Application Development

Tagged with
#next.js#authentication#middleware#jwt#cookies#app router

Found this useful? Share it.

Keep Reading

Related articles

Booking Q2 2026 Projects

Ready to Build Something Great?

From idea to launch — let our senior engineers build, ship and scale your next product. No commitment, just a conversation.

Senior Engineers
On-Time Delivery
Enterprise-Grade
Free Consultation

Free 30-min discovery call

Talk to a senior engineer — not a salesperson.

We'll review your goals, suggest the leanest path forward, and send a clear proposal within 24 hours.

24h

Response Time

100+

Projects Delivered

No commitment · No automated bots · Fully transparent