Middleware
Redirecting unauthenticated requests away from protected pages before they render — and exactly what this check does and doesn't prove.
Middleware
Everything else in this folder runs in the browser, after a page has already loaded. This runs on the server, before that — a Next.js middleware that inspects the request and can redirect it away from a protected route without ever rendering the page.
There are two honest versions of this, trading latency for correctness. Pick one on purpose rather than assuming the cheap one does more than it does.
Option 1: cookie presence only
// middleware.ts (or proxy.ts, per Next.js's own naming)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const protectedRoutes = ["/dashboard"];
const adminRoutes = ["/admin"];
export function middleware(request: NextRequest) {
const sessionToken = request.cookies.get("goauth_session")?.value; // match CookieConfig.Name
const { pathname } = request.nextUrl;
const isProtected = protectedRoutes.some((route) => pathname.startsWith(route));
const isAdmin = adminRoutes.some((route) => pathname.startsWith(route));
if ((isProtected || isAdmin) && !sessionToken) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*"],
};goauth_session is the default cookie name from CookieConfig (see Configuration) — if you renamed it there, rename it here too.
This checks that a cookie exists, not that it's valid — and it doesn't check role at all
Two things this version cannot do, both by construction, not by bug:
-
It never validates the token. Middleware runs on the edge runtime, without a database connection, so it can't call
ValidateSession— it can only check whether a cookie with the right name is present. An expired, revoked, or entirely forged cookie value still passes this check and reaches the page. -
isAdmindoesn't check the user's role — only that some session cookie exists. A logged-in non-admin who navigates to/adminsails past this exactly like an admin would.
The page's API calls still go through server-side AuthMiddleware/adminMW. This redirect only avoids rendering a page before those checks; it is not a security boundary. Use Option 2 when the middleware itself must validate the session.
Option 2: validated via /auth/me
Server middleware can ask the API to validate the session. The example uses the same-origin /api reverse proxy described in Deployment, so cookies set by the API also belong to the browser-facing origin. It forwards refreshed cookies both to the browser and to the current page request. Use a server runtime whose Headers exposes getSetCookie(); keep each cookie header separate.
Set APP_PERMISSIONS_ENABLED to match the server configuration. Disabled mode checks /auth/me's fixed role. Enabled mode reads /auth/access and checks the library key required by this example's user-list dashboard. Choose each page's actual operation key; catalog provisioning and organization oversight require protected admin. These redirects only control page navigation: the API still enforces current permissions and required MFA.
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const APP_PERMISSIONS_ENABLED = false; // match WithAppPermissions
const ADMIN_PAGE_PERMISSION = "goauth.app.users.read"; // this page lists users
const protectedRoutes = ["/dashboard"];
const adminRoutes = ["/admin"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isProtected = protectedRoutes.some((route) => pathname.startsWith(route));
const isAdmin = adminRoutes.some((route) => pathname.startsWith(route));
if (!isProtected && !isAdmin) return NextResponse.next();
const cookies = new Map(request.cookies.getAll().map(({ name, value }) => [name, value]));
const setCookies: string[] = [];
const cookieHeader = () => Array.from(cookies, ([name, value]) => `${name}=${value}`).join("; ");
async function authGet(path: string) {
const res = await fetch(new URL(`/api${path}`, request.url), {
headers: { cookie: cookieHeader() },
cache: "no-store",
});
for (const header of res.headers.getSetCookie()) {
setCookies.push(header);
// Refresh cookies contain opaque token values. Keep attributes intact
// for the browser, and forward the new values on subsequent requests.
const pair = header.split(";", 1)[0];
const equals = pair.indexOf("=");
if (equals > 0) cookies.set(pair.slice(0, equals), pair.slice(equals + 1));
}
return res;
}
function finish(response: NextResponse) {
for (const header of setCookies) response.headers.append("Set-Cookie", header);
return response;
}
try {
const res = await authGet("/auth/me");
if (res.status >= 500) {
return finish(new NextResponse("Authentication service unavailable", { status: 503 }));
}
if (!res.ok) {
return finish(NextResponse.redirect(new URL("/login", request.url)));
}
if (isAdmin) {
let allowed: boolean;
if (APP_PERMISSIONS_ENABLED) {
const accessResponse = await authGet("/auth/access");
if (accessResponse.status >= 500) {
return finish(new NextResponse("Authentication service unavailable", { status: 503 }));
}
if (!accessResponse.ok) return finish(NextResponse.redirect(new URL("/", request.url)));
const access = await accessResponse.json();
allowed = access.isFullAccess || access.permissionKeys.includes(ADMIN_PAGE_PERMISSION);
} else {
const user = await res.json();
allowed = user.role === "admin";
}
if (!allowed) return finish(NextResponse.redirect(new URL("/", request.url)));
}
const headers = new Headers(request.headers);
headers.set("cookie", cookieHeader());
return finish(NextResponse.next({ request: { headers } }));
} catch {
// Preserve a completed rotation even if a later access request fails.
return finish(new NextResponse("Authentication service unavailable", { status: 503 }));
}
}
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*"],
};What this trades away
Every matched navigation waits for session validation; enabled-mode admin navigation also loads access. API server failures or network errors return 503 without admitting the page. /auth/me can transparently rotate an expired session using the forwarded refresh cookie. Its replacement cookies must reach the browser even if a later access check denies navigation or fails, or the browser will keep stale tokens. The example relays them on every returned response and updates the downstream request. For separately hosted API cookies that cannot be set from the frontend origin, use the browser to refresh or build a proxy with an explicit cookie-scope policy.
Next
- Provider — client state, fixed roles in disabled mode, and separate app access in enabled mode
- Deployment — proxy routing, required allowed origins, and cookie scope for each frontend/API topology
- Routes — which endpoints require
Authvs. admin role, enforced server-side regardless of this middleware - Security — backend session validation, refresh rotation, and authorization enforced by API middleware