Skip to content

NEXT.JS

Next.js 15 Edge Middleware Authentication with Web Crypto

node:crypto fails on the Edge runtime, and relying on middleware alone leaves route handlers exposed. Here is the Web Crypto HMAC session contract and defense-in-depth architecture.

In Next.js 15, Edge middleware authentication fails when developers import node:crypto because the Edge runtime only supports Web standards. Attempting to use crypto.createHmac or crypto.timingSafeEqual in middleware.ts throws Module not found: Can't resolve 'crypto'. The correct implementation uses the global Web Crypto API (crypto.subtle) for HMAC-SHA256 session verification. Furthermore, treating middleware as your sole security boundary leaves Server Actions and route handlers vulnerable to bypasses.

Key takeaways

  • Next.js middleware executes in a V8 Edge isolate where node:crypto is absent; use crypto.subtle for token signing and verification.
  • Authoritative session lifetimes must live inside the cryptographically signed exp claim, never in client-manipulated cookie Max-Age headers.
  • Constant-time verification is mandatory for signature checks to eliminate timing side-channel leaks.
  • Middleware is a UX acceleration layer for instant redirects; every protected Server Action and Route Handler must enforce independent verification (requireAdminSession).

Why node:crypto fails on the Edge runtime

Next.js App Router executes middleware on the Edge runtime. According to Next.js Edge Runtime documentation, this environment runs on a stripped-down V8 isolate rather than a traditional Node.js server process. Built-in Node modules—such as node:crypto, node:fs, and node:net—are deliberately unavailable to maintain cold start latencies below 10 milliseconds.

When authentication libraries rely on crypto.createHmac("sha256", secret) or crypto.randomBytes(), building your application for production fails with:

text
Type error: Cannot find module 'crypto' or its corresponding type declarations.
Module not found: Can't resolve 'crypto' in 'middleware.ts'

The solution is not to polyfill Node in Edge bundles. Polyfills inflate deployment size, introduce security surface, and often fail under Edge runtime constraints. Instead, rely on standard Web APIs: crypto.subtle, TextEncoder, and btoa.

The Web Crypto HMAC session contract

Rather than pulling in heavyweight JWT packages that parse complex headers and open attack vectors to alg: none confusion attacks, this site implements a transparent two-part signed token:

Signed session token
base64url(payload JSON) + "." + base64url(HMAC-SHA256(payload B64))

The signing key is imported through crypto.subtle.importKey, and signatures are computed over the serialized base64url payload:

lib/admin/session.ts
const encoder = new TextEncoder();
const decoder = new TextDecoder();

async function getHmacKey(secret: string): Promise<CryptoKey> {
  return crypto.subtle.importKey(
    "raw",
    encoder.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign", "verify"]
  );
}

export async function signSessionToken(
  payload: { sub: string; iat: number; exp: number },
  secret: string
): Promise<string> {
  const key = await getHmacKey(secret);
  const payloadB64 = toBase64Url(encoder.encode(JSON.stringify(payload)));
  const sigBuffer = await crypto.subtle.sign(
    "HMAC",
    key,
    encoder.encode(payloadB64)
  );
  const sigB64 = toBase64Url(new Uint8Array(sigBuffer));
  return `${payloadB64}.${sigB64}`;
}

When verifying the incoming token, the verifier reconstructs the expected signature and compares it against the provided signature using a constant-time comparison function:

lib/admin/session.ts
function safeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i += 1) {
    diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return diff === 0;
}

A standard JavaScript strict equality (===) short-circuits on the first mismatched byte, creating a measurable timing discrepancy that allows remote adversaries to iteratively deduce valid HMAC signatures byte by byte. A bitwise XOR accumulator guarantees identical execution time across arbitrary mismatched inputs.

Enforcing expiry via the signed exp claim

A common architectural trap in cookie authentication is delegating expiration to the browser via Max-Age or Expires cookie directives:

Anti-pattern: trusting client expiration
// INSECURE: An attacker can resend this cookie indefinitely
export async function verifyCookie(token: string) {
  const isValid = await verifyHmac(token);
  return isValid; // Trusts browser deletion
}

Browsers respect Max-Age during normal browsing sessions, but automated HTTP clients, curl scripts, and malicious actors do not. If session expiration is not sealed within the HMAC signature itself, a stolen or leaked session cookie remains valid forever until the server secret is rotated.

In our production contract, the payload explicitly embeds the exp Unix epoch timestamp:

lib/admin/session.ts
const payload = JSON.parse(decoder.decode(payloadBytes));
const nowSeconds = Math.floor(Date.now() / 1000);

if (typeof payload.exp !== "number" || nowSeconds >= payload.exp) {
  return { valid: false, reason: "expired" };
}

Because payload.exp is covered by the HMAC signature, any attempt by an attacker to extend the session lifetime alters the base64url payload, instantly invalidating the cryptographic signature.

Why middleware is UX and not the security boundary

Next.js developers often treat middleware.ts as a firewall: if middleware checks the session and redirects unauthenticated users to /login, developers assume internal Server Actions and API route handlers are safe.

This assumption is flawed for three distinct reasons:

  • 1. Routing and Config Drift: Middleware matchers (e.g., matcher: ["/admin/:path*"]) are configured via glob patterns. A subtle typo or regex omission (such as forgetting /admin itself when matching /admin/:path*) completely bypasses middleware execution.
  • 2. Framework Vulnerabilities: Real-world security incidents like CVE-2025-29927 demonstrated that request path manipulation in earlier Next.js releases could bypass authorization logic placed solely in middleware.
  • 3. Route Handler Isolation: Route handlers (route.ts) do not run layout wrappers and can be directly invoked by external POST requests without triggering user-facing navigation flows.

In this repository, middleware.ts is explicitly documented as a UX layer rather than the security boundary:

middleware.ts
/**
 * Gate for `/admin`.
 *
 * **This is UX, not the security boundary.** Next.js has had a middleware-bypass
 * bug class (CVE-2025-29927), and middleware is exactly the kind of thing a future
 * config change can silently stop invoking. Every admin page, Server Action and
 * route handler re-verifies the session for itself via `requireAdminSession()`.
 */

Middleware performs two jobs: issuing immediate redirects to /admin/login before Server Components begin execution, and injecting defensive headers:

middleware.ts
function harden(response: NextResponse): NextResponse {
  response.headers.set("X-Robots-Tag", "noindex, nofollow, noarchive");
  response.headers.set("Cache-Control", "no-store, max-age=0, must-revalidate");
  response.headers.set("Referrer-Policy", "same-origin");
  return response;
}

Defense in depth for route handlers and actions

To guarantee absolute security, every admin page, Server Action, and Route Handler implements defense-in-depth by calling requireAdminSession() at the top of its handler function:

app/admin/actions.ts
"use server";

import { requireAdminSession } from "@/lib/admin/session";

export async function deletePostAction(formData: FormData) {
  // Independent security check: throws or redirects if unauthorized
  await requireAdminSession();

  const slug = formData.get("slug");
  // Proceed with privileged database operation
}

Server Actions must also enforce origin verification against cross-site request forgery, as demonstrated in our guide to Next.js Server Actions CSRF verification.

If a developer accidentally misconfigures middleware.ts or a new deployment environment skips edge execution, the Server Action itself verifies the signed cookie via cookies().get(sessionCookieName()). Unauthorized requests are aborted immediately before touching Cloudflare D1 or mutating filesystem data. Furthermore, credential login actions on the Node runtime must defend against length oracles and username leaks; see our breakdown of timing-safe authentication in Next.js Server Actions.

Our automated verification harness, an internal check script, verifies this entire security contract in CI:

  • 1. Signature roundtrip: Tests Web Crypto HMAC signing and verification roundtrips with crypto.subtle.
  • 2. Tampering detection: Asserts that single-bit payload tamperings fail with bad_signature.
  • 3. Authoritative expiry: Asserts that expired exp timestamps reject even with valid signatures.
  • 4. Constant-time checks: Validates that constant-time string comparisons reject unequal lengths and byte mismatches.
  • 5. Zero Node crypto: Verifies that middleware.ts and lib/admin/session.ts maintain zero node:crypto imports.

Best practices

  • Prefix production cookies with __Host-: Use __Host-devventa_admin_session in production. Per the W3C Cookie specification, __Host- locks the cookie to the exact origin, enforces Secure, requires Path=/, and prevents subdomain injection.
  • Set httpOnly and sameSite: "lax": Keep session cookies inaccessible to client-side JavaScript (document.cookie), neutralizing cross-site scripting (XSS) token extraction. Layering this with Next.js Content Security Policy nonces in middleware ensures unauthorized inline scripts cannot execute even if HTML injection occurs.
  • Keep tokens self-contained: Avoid external session database roundtrips on edge middleware routes. A signed stateless token verifies in under 0.2 milliseconds using local CPU cycles.
  • Fail closed on missing configuration: If ADMIN_SESSION_SECRET is unset or shorter than 32 bytes, the session verifier must immediately refuse authentication rather than fallback to empty defaults.

Common mistakes

  • Importing node:crypto in Edge middleware: Calling import crypto from "crypto" causes fatal edge compilation errors. Always use the standard global crypto.subtle.
  • Relying solely on middleware redirects: Omitting server-side verification in Server Actions allows attackers to invoke mutating actions directly via crafted POST requests.
  • Using plain === to compare signatures: Standard equality checks expose your application to timing attacks. Accumulate character differences with a constant-time bitwise loop.
  • Trusting client-side cookie lifetimes: Setting a 1-day cookie without a signed exp claim allows malicious clients to reuse the token indefinitely.

Conclusion

Building resilient authentication in Next.js 15 requires embracing Edge runtime boundaries rather than fighting them. By implementing HMAC-SHA256 session signing using the native Web Crypto API (crypto.subtle), you achieve zero-dependency, sub-millisecond session validation that runs cleanly across Edge middleware and Node.js Server Components.

Pairing Edge middleware redirects with independent route handler authorization creates true defense-in-depth: middleware guarantees instantaneous user navigation, while server-side assertion functions guarantee that your privileged endpoints remain sealed against routing drift and platform bypasses. See our guide on deploying Next.js to Vercel for runtime environment configuration details.

Frequently asked questions

Why does node:crypto fail inside Next.js middleware?
Next.js middleware runs on the Vercel Edge Runtime (a V8 isolate environment based on Web standards), not the full Node.js runtime. Node.js built-in modules like node:crypto, node:fs, and node:path do not exist on the Edge runtime. Calling crypto.createHmac() throws a compilation or runtime Module not found error. You must use the global Web Crypto API (crypto.subtle) instead.
Can Next.js middleware act as the sole security boundary for protected routes?
No. Middleware is a UX gate for fast redirects and security header injection, not the complete security boundary. Next.js route handlers and Server Actions execute independently of layout rendering, and historical bypass vulnerabilities (such as CVE-2025-29927) prove that routing misconfigurations can skip middleware execution entirely. Every Server Action, route handler, and protected page must re-verify authentication independently via a server-side helper.
Why use payload.exp instead of relying on cookie Max-Age?
Clients control HTTP cookie persistence. An attacker or modified client can alter, strip, or ignore the Max-Age and Expires cookie headers, resending an expired session token indefinitely. By cryptographically signing the exp epoch timestamp inside the token payload, the server validates expiration through the HMAC signature, guaranteeing that tampered or expired tokens fail verification regardless of client headers.
Why choose a two-part base64url token over a standard JWT library?
Standard JWT libraries often carry heavy parsing baggage and expose algorithm negotiation risks (such as the alg: none confusion attack). A minimal two-part token (<base64url(payload)>.<base64url(signature)>) hardcodes the HMAC-SHA256 algorithm via crypto.subtle, eliminating header parsing, dependency bloat, and algorithm-confusion vulnerabilities in edge environments.

Muhammad Kashif

Founder and editor of Devventa, covering AI coding assistants, Next.js and the modern AI development stack.