Skip to content

Next.js — Middleware

OWNER: floris, floor
ALSO_USED_BY: urszula, maxim, alexander
LAST_VERIFIED: 2026-03-26
GE_STACK_VERSION: next@15.5.x


Overview

Middleware runs BEFORE route handlers, layouts, and pages. Used for auth redirects,
header manipulation, and request rewriting. Runs in Edge Runtime by default (Next.js 15),
but Node.js runtime is stable as of 15.5. GE uses Node.js middleware runtime.


File Location

src/middleware.ts   # Single file, project root of src/

CHECK: only ONE middleware file exists in the project
CHECK: file is at src/middleware.ts (not inside app/)
IF: upgrading to Next.js 16
THEN: rename middleware.ts to proxy.ts (Next.js 16 convention)


GE Auth Middleware Pattern

// src/middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";

const PUBLIC_PATHS = ["/", "/login", "/register", "/api/auth"];

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Skip public paths
  if (PUBLIC_PATHS.some((path) => pathname.startsWith(path))) {
    return NextResponse.next();
  }

  // Skip static assets and internal Next.js paths
  if (
    pathname.startsWith("/_next") ||
    pathname.startsWith("/favicon") ||
    pathname.includes(".")
  ) {
    return NextResponse.next();
  }

  // Verify JWT from cookie
  const token = request.cookies.get("session-token")?.value;
  if (!token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  try {
    const secret = new TextEncoder().encode(process.env.JWT_SECRET);
    const { payload } = await jwtVerify(token, secret);

    // Add user info to headers for downstream consumption
    const response = NextResponse.next();
    response.headers.set("x-user-id", payload.sub as string);
    response.headers.set("x-user-role", payload.role as string);
    return response;
  } catch {
    // Token expired or invalid
    const response = NextResponse.redirect(new URL("/login", request.url));
    response.cookies.delete("session-token");
    return response;
  }
}

export const config = {
  matcher: [
    // Match all paths except static files and api/auth
    "/((?!_next/static|_next/image|favicon.ico).*)",
  ],
  runtime: "nodejs", // GE: always Node.js, not Edge
};

CHECK: runtime: "nodejs" is set in config (stable since Next.js 15.5)
CHECK: JWT secret is read from env var, never hardcoded
CHECK: token is from httpOnly cookie, never from Authorization header for browser sessions
CHECK: middleware does NOT make database calls (use JWT claims instead)


WebAuthn + NextAuth Pattern (GE Specific)

GE uses WebAuthn (passkeys) for admin authentication.

// Challenge flow (from admin-ui experience):
// 1. Client calls /api/auth/challenge → server generates challenge, stores in httpOnly cookie
// 2. Client performs WebAuthn ceremony with browser API
// 3. Client calls /api/auth/verify with assertion → server reads challenge from cookie

// CRITICAL: Challenge MUST be in httpOnly cookie, NOT in response body
// Old code used body.challenge which was undefined — broke login entirely

IF: implementing WebAuthn
THEN: store challenge in httpOnly cookie between challenge and verify endpoints
ANTI_PATTERN: returning challenge in response body and expecting client to send it back
FIX: set challenge as httpOnly cookie — client cannot read it, server reads it on verify

READ_ALSO: wiki/docs/stack/nextjs/pitfalls.md (WebAuthn section)


Middleware Patterns

Rate Limiting

import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, "10 s"),
});

// Inside middleware:
const ip = request.headers.get("x-forwarded-for") ?? "127.0.0.1";
const { success } = await ratelimit.limit(ip);
if (!success) {
  return new NextResponse("Too Many Requests", { status: 429 });
}

GE_CONVENTION: rate limiting at middleware level for API routes
GE_CONVENTION: use sliding window, not fixed window

Security Headers

// Add CSP and security headers
const response = NextResponse.next();
response.headers.set(
  "Content-Security-Policy",
  "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';"
);
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("X-Content-Type-Options", "nosniff");
return response;

CHECK: security headers are set (CSP, X-Frame-Options, X-Content-Type-Options)
NOTE: prefer setting static headers in next.config.ts headers() — middleware for dynamic headers only

Geolocation / EU Data Sovereignty

// Verify requests originate from or are routed through EU
const country = request.headers.get("x-vercel-ip-country") ??
                request.headers.get("cf-ipcountry") ?? "unknown";

// Log non-EU access for compliance audit (do not block — client may use VPN)
if (!EU_COUNTRIES.includes(country)) {
  console.warn(`Non-EU access detected: ${country} for ${pathname}`);
}

GE_CONVENTION: log non-EU access, do not block (VPN users, travelers)
GE_CONVENTION: all data processing stays in EU regardless of access origin


Edge Runtime Limitations (Next.js 15)

IF: runtime is "edge" (default in Next.js 15)
THEN: these are NOT available:
- Node.js fs module
- node:crypto (use Web Crypto API)
- Database drivers (postgres, mysql — no TCP sockets)
- Most npm packages that use Node.js APIs
- process.env is limited to build-time values

IF: middleware needs Node.js features
THEN: set runtime: "nodejs" in config (stable since Next.js 15.5)

GE_CONVENTION: always use runtime: "nodejs" — eliminates all Edge limitations
GE_CONVENTION: never deploy to Edge (EU data sovereignty — Edge locations may be outside EU)


Matcher Configuration

export const config = {
  matcher: [
    // Match all except static files
    "/((?!_next/static|_next/image|favicon.ico).*)",
  ],
};
Pattern Effect
"/dashboard/:path*" Match /dashboard and all sub-paths
"/((?!api|_next).*)" Match all except /api and /_next
"/api/:function*" Match all API routes

CHECK: matcher excludes _next/static, _next/image, favicon.ico
CHECK: matcher excludes public auth endpoints (/api/auth/*)
ANTI_PATTERN: running middleware on every request including static assets
FIX: use matcher to exclude static asset paths


Auth Defense-in-Depth (GE Mandatory)

Middleware is a FIRST LINE of defense, NOT the only check.

Layer 1: Middleware → redirects unauthenticated users (fast, optimistic)
Layer 2: Layout/Page → verifies session in Server Component (authoritative)
Layer 3: Server Action → re-verifies before mutation (critical)
Layer 4: Database → row-level security or query filtering (ultimate)

CRITICAL: CVE-2025-29927 proved middleware can be bypassed (header manipulation).
Self-hosted apps (GE on k3s) MUST verify auth at every sensitive operation.

CHECK: Server Actions re-verify auth before mutations
CHECK: database queries filter by authenticated user's ID/org
ANTI_PATTERN: relying solely on middleware for auth
FIX: verify session in Server Components AND Server Actions


Anti-Patterns

ANTI_PATTERN: database queries in middleware
FIX: use JWT claims or cached session data — middleware runs on every matched request

ANTI_PATTERN: heavy computation in middleware
FIX: middleware adds ~1-5ms per request — keep it fast (JWT verify, header checks)

ANTI_PATTERN: middleware modifying response body
FIX: middleware can only set headers, cookies, redirect, or rewrite — not change body

ANTI_PATTERN: using request.headers.get("cookie") to parse cookies manually
FIX: use request.cookies.get("name") — built-in API


Cross-References

READ_ALSO: wiki/docs/stack/nextjs/index.md
READ_ALSO: wiki/docs/stack/nextjs/app-router.md
READ_ALSO: wiki/docs/stack/nextjs/pitfalls.md
READ_ALSO: wiki/docs/stack/nextjs/checklist.md