Skip to content
← Journal
13 min readAta Mohammadi

The Senior Frontend Engineering Blueprint, Part 5: Edge Proxies, Auth.js Credentials and a Server-Side Data Access Layer

A redirect from the edge feels like security, but an attacker can skip it entirely by calling your Server Action directly. Here is the request pipeline Next.js 16 actually runs, a Credentials-based Auth.js v5 setup with Prisma and RBAC, and the data access layer that is the only boundary that actually holds.

A redirect that fires before your page renders feels like security. It isn't. If your only authentication check lives in an edge file that inspects a cookie and bounces the browser back to /login, an attacker who already knows the internal action ID doesn't need the browser at all — they can POST straight to the endpoint your Server Action compiles down to, skip the redirect, and never see your check. This is the question senior Next.js interviews keep circling back to: where does access control actually live, and why is the obvious answer wrong?

This is Part 5 of the Blueprint series — a continuation of the enterprise-grade platform we've been building since Part 1. Before we get into it: if you're looking for how to wire Auth.js v5 to a third-party identity provider, that's a different, already-answered question — see Self-Hosted SSO: Wiring authentik to Next.js 16 and Auth.js v5 for federating identity out to a self-hosted OIDC provider, including the logout mechanics. This article is the opposite shape of problem: an app that owns its own user table, authenticates against it directly with a Credentials provider, and has to decide — correctly — where the security boundary actually sits.

The request-interception pipeline

Every request to a modern Next.js app passes through a narrow perimeter file before any Server Component, Route Handler, or Server Action executes:

Incoming Request


┌─────────────────────────────────────────────────────────────┐
│ Perimeter Interceptor (proxy.ts)                             │
│  - Non-blocking optimistic JWT inspection (Cookie header)     │
│  - Request header injection (x-user-id, x-trace-id)           │
│  - URL rewrites (A/B testing, multi-tenant subdomains)         │
│  - Fast redirects for obviously unauthenticated routes         │
└──────────────┬──────────────────────────────────────────────┘

       Pass / Next()

┌─────────────────────────────────────────────────────────────┐
│ Next.js App Router                                            │
│  - Layout tree resolution ((marketing) vs (dashboard))         │
│  - React Server Components (RSC payload generation)            │
│  - Data Access Layer — the authoritative database security gate│
└─────────────────────────────────────────────────────────────┘

Until Next.js 16, this file was called middleware.ts, and it ran exclusively on an isolated Edge runtime — which meant bundling polyfills for ordinary Node APIs, and it quietly encouraged people to put database session lookups in there, which exhausts connection pools under load and adds latency to every single request, including ones that don't need it.

Next.js 16 deprecated middleware.ts outright and renamed the convention to proxy.ts, formalising its contract as a lightweight routing proxy: fast, low-IO tasks only — header mutation, optimistic cookie inspection, internal rewrites. And here's the detail that catches people who still think of this file as "the edge layer" out of habit: Next.js's own documentation states plainly that "proxy defaults to using the Node.js runtime." The isolated V8 Edge sandbox that made middleware.ts awkward to work with is gone by default. You get the full Node.js standard library here now — which makes the old excuse for keeping this file starved of logic disappear, and makes the discipline of keeping it starved of logic a deliberate architectural choice rather than a runtime limitation.

A production-grade proxy.ts

// proxy.ts
import { NextRequest, NextResponse } from "next/server";

// Exclude static assets, images, and public manifests from the perimeter entirely
export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};

const PUBLIC_ROUTES = new Set(["/", "/login", "/register", "/api/auth"]);
const AUTH_ONLY_ROUTES = new Set(["/login", "/register"]);

export function proxy(req: NextRequest): NextResponse {
  const { nextUrl } = req;
  const pathname = nextUrl.pathname;

  // Correlation ID for distributed tracing across RSC and the DAL
  const traceId = crypto.randomUUID();
  const requestHeaders = new Headers(req.headers);
  requestHeaders.set("x-trace-id", traceId);

  // Optimistic check: presence of a session cookie only, never a DB lookup
  const sessionToken =
    req.cookies.get("__Secure-authjs.session-token")?.value ??
    req.cookies.get("authjs.session-token")?.value;

  const isAuthenticated = Boolean(sessionToken);
  const isPublicRoute = PUBLIC_ROUTES.has(pathname) || pathname.startsWith("/api/auth");
  const isAuthOnlyRoute = AUTH_ONLY_ROUTES.has(pathname);

  // Authenticated user hitting /login or /register -> send them to the dashboard
  if (isAuthenticated && isAuthOnlyRoute) {
    return NextResponse.redirect(new URL("/dashboard", req.url));
  }

  // Unauthenticated user hitting a private route -> bounce to /login with a return URL
  if (!isAuthenticated && !isPublicRoute) {
    const loginUrl = new URL("/login", req.url);
    if (pathname !== "/") {
      loginUrl.searchParams.set("callbackUrl", encodeURI(pathname));
    }
    return NextResponse.redirect(loginUrl);
  }

  // Multi-tenant subdomain rewriting: tenant.domain.com -> /_tenants/tenant
  const hostname = req.headers.get("host") ?? "";
  const rootDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? "localhost:3000";

  if (hostname.includes(".") && !hostname.startsWith("www.") && hostname !== rootDomain) {
    const subdomain = hostname.replace(`.${rootDomain}`, "");
    return NextResponse.rewrite(
      new URL(`/_tenants/${subdomain}${pathname}${nextUrl.search}`, req.url),
      { request: { headers: requestHeaders } },
    );
  }

  return NextResponse.next({ request: { headers: requestHeaders } });
}

Two failure modes worth knowing before you ship this:

  • The infinite redirect loop. It happens when /login itself gets swept up by the "unauthenticated → redirect to /login" branch, or your matcher forgets to exclude /api/auth/*. Always isolate auth endpoints and public assets in their own condition, before the generic gate.
  • The static-bypass trap. If you put your optimistic auth guard inside a Server Component instead of the proxy, calling cookies() or headers() there forces Next.js to opt that route out of static rendering entirely, at build time, for every visitor — authenticated or not. Doing the optimistic check at the perimeter lets protected pages stay statically rendered (or partially prerendered) while unauthorised requests get bounced before they ever reach the renderer.

Auth.js v5, Prisma, and a typed session

Add the Auth.js models to the schema from Part 1:

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
  @@index([userId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
}

Then run npx prisma migrate dev --name add_authjs_models.

Auth.js's default Session and User types don't know your app has roles. Module augmentation fixes that everywhere at once, rather than casting at every call site:

import { type Role } from "@prisma/client";
import { type DefaultSession } from "next-auth";
import "next-auth/jwt";

declare module "next-auth" {
  interface Session {
    user: {
      id: string;
      role: Role;
    } & DefaultSession["user"];
  }

  interface User {
    role: Role;
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    id: string;
    role: Role;
  }
}

Now the Credentials provider itself, with the Prisma adapter and a JWT session strategy — JWT is the mandatory choice here, since a database-lookup session strategy would put the exact I/O cost we just excluded from the proxy right back into it:

import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
  });

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import Credentials from "next-auth/providers/credentials";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
import crypto from "node:crypto";

const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

// Constant-time comparison to defend the credential check against timing attacks
function verifyPassword(provided: string, storedHash: string, salt: string): boolean {
  const hash = crypto.pbkdf2Sync(provided, salt, 10000, 64, "sha512").toString("hex");
  return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(storedHash));
}

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: {
    strategy: "jwt",
    maxAge: 30 * 24 * 60 * 60, // 30 days
  },
  pages: {
    signIn: "/login",
    error: "/login",
  },
  providers: [
    Credentials({
      name: "Credentials",
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      authorize: async (credentials) => {
        const parsed = LoginSchema.safeParse(credentials);
        if (!parsed.success) return null;

        const { email, password } = parsed.data;
        const user = await prisma.user.findUnique({ where: { email } });

        if (!user) {
          // Burn the same CPU time on a miss as on a hit, so timing can't reveal which emails exist
          crypto.pbkdf2Sync(password, "static_salt_leak_protection", 10000, 64, "sha512");
          return null;
        }

        // Swap in your real stored hash + salt lookup and call verifyPassword() here
        return {
          id: user.id,
          name: user.name,
          email: user.email,
          role: user.role,
        };
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      // Runs once, on sign-in: persist custom claims into the encrypted JWT
      if (user) {
        token.id = user.id!;
        token.role = user.role;
      }
      return token;
    },
    async session({ session, token }) {
      // Forward the persisted claims into the session object every request reads
      if (session.user) {
        session.user.id = token.id;
        session.user.role = token.role;
      }
      return session;
    },
  },
});

Mount it in app/api/auth/[...nextauth]/route.ts:

import { handlers } from "@/auth";
export const { GET, POST } = handlers;

The authoritative security gate

Here's the sentence worth writing on a sticky note over your monitor: the proxy is an optimistic UX guard, not an access-control boundary. It routes users to the right place quickly. It does not — cannot — stop a request that skips it.

Next.js Server Actions run as POST requests to a build-time-generated, opaque action ID. If an attacker already has that ID — from reading your client bundle, or from a previous legitimate response — they can call it directly over HTTP, with no browser, no cookie header your proxy would even see attached the way you expect, and no redirect to intercept. Your proxy never runs for that request in any way that changes the outcome, because the Server Action's own endpoint is what actually executes your mutation.

Security has to be enforced where the data access actually happens — the Data Access Layer, on the server, on every call, with no exceptions for internal trust:

import "server-only"; // Fails the build if this file is ever imported into client code
import { auth } from "@/auth";
import { cache } from "react";
import { redirect } from "next/navigation";
import { Role } from "@prisma/client";

/**
 * Request-memoised session retrieval: multiple Server Components calling
 * verifySession() within one render pass only run the token check once.
 */
export const verifySession = cache(async () => {
  const session = await auth();

  if (!session?.user?.id) {
    redirect("/login");
  }

  return {
    isAuth: true,
    userId: session.user.id,
    role: session.user.role,
  };
});

export const assertRole = async (requiredRole: Role) => {
  const session = await verifySession();

  if (session.role !== requiredRole) {
    throw new Error("UNAUTHORIZED: insufficient permission clearance");
  }

  return session;
};

Every Server Action, every Route Handler, every Server Component that touches sensitive data calls verifySession() or assertRole() directly — never relies on the fact that the proxy "already checked." react's cache() means this costs nothing extra even when five Server Components on one page all need the same session.

Route topology: three layout trees, one URL space

Route groups — folders in parentheses — let each area of the app get its own layout tree without touching the public URL:

app/
├── (marketing)/                <- Public route group; parens are invisible in the URL
│   ├── layout.tsx              <- Public header, landing nav, footer
│   ├── page.tsx                <- Maps to '/'
│   └── pricing/page.tsx        <- Maps to '/pricing'

├── (dashboard)/                <- Protected route group
│   ├── layout.tsx              <- Authenticated sidebar, calls verifySession()
│   ├── template.tsx            <- Re-mounts on every navigation; see below
│   ├── dashboard/page.tsx      <- Maps to '/dashboard'
│   └── settings/page.tsx       <- Maps to '/settings'

└── (auth)/                     <- Minimal centred layout, no header/footer
    ├── login/page.tsx          <- Maps to '/login'
    └── register/page.tsx       <- Maps to '/register'
import { verifySession } from "@/lib/dal/auth";
import Link from "next/link";
import { signOut } from "@/auth";

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  // The authoritative gate for the entire (dashboard) tree — not the proxy
  const session = await verifySession();

  return (
    <div className="flex min-h-screen bg-neutral-950 text-neutral-200">
      <aside className="w-64 border-r border-neutral-800 p-6">
        <div className="text-sm font-bold tracking-wider text-white">WORKSPACE</div>
        <div className="mt-2 text-xs text-neutral-500">User: {session.userId}</div>
        <div className="mt-1 inline-block rounded bg-neutral-800 px-2 py-0.5 text-[10px] text-neutral-400">
          Role: {session.role}
        </div>

        <nav className="mt-8 space-y-2">
          <Link href="/dashboard" className="block rounded px-3 py-2 text-sm hover:bg-neutral-900">
            Overview
          </Link>
          {session.role === "ADMIN" && (
            <Link
              href="/dashboard/admin"
              className="block rounded px-3 py-2 text-sm text-red-400 hover:bg-red-950/30"
            >
              Admin console
            </Link>
          )}
        </nav>

        <form
          action={async () => {
            "use server";
            await signOut({ redirectTo: "/login" });
          }}
          className="mt-12"
        >
          <button
            type="submit"
            className="w-full rounded border border-neutral-800 py-2 text-xs font-semibold text-neutral-400 hover:bg-neutral-900"
          >
            Terminate session
          </button>
        </form>
      </aside>

      <div className="flex-1 p-8">{children}</div>
    </div>
  );
}

layout.tsx and template.tsx look interchangeable until you check what they preserve across a navigation, and this distinction comes up constantly in senior interviews:

Capability layout.tsx template.tsx
Component remounting Preserved — no remount across sibling route navigations Remounted — a fresh instance mounts on every transition
State retention Retains React state (open sidebar, form drafts) Resets state completely on navigation
useEffect Doesn't re-fire on transitions within the same tree Re-runs on every navigation
Typical use Persistent shells, sidebars, stateful providers Page-transition animation, scroll reset, per-page telemetry
"use client";

import { useEffect } from "react";
import { usePathname } from "next/navigation";

export default function DashboardTemplate({
  children,
}: {
  children: React.ReactNode;
}) {
  const pathname = usePathname();

  useEffect(() => {
    console.info(`[TELEMETRY] Navigated to: ${pathname}`);
  }, [pathname]);

  return <div className="animate-in fade-in duration-200">{children}</div>;
}

Revoking a token you already gave out

A stateless JWT session is cheap to verify — no database round trip — and expensive to revoke, because there's nothing to delete. Three real strategies, in ascending order of how often production teams actually reach for them:

1. Sliding window re-issuance
   Every request that arrives past the halfway point of the token's life
   gets a freshly re-signed token with a new expiry. Simple, but a stolen
   token stays valid until someone notices and rotates the signing secret.

2. Redis invalidation blacklist
   Revoked tokens go into an in-memory store with a TTL matching the
   token's remaining lifetime. The proxy checks it on every request —
   which means you've reintroduced exactly the network round trip the
   proxy model exists to avoid, so this only works with a cache fast
   enough to disappear into the noise (sub-millisecond, co-located).

3. Hybrid short-lived access token + opaque refresh token (what production
   teams actually run)
   Keep the JWT lifespan short — 15 minutes. Pair it with an opaque
   refresh token in an HTTP-only, SameSite=Strict cookie, backed by a
   database row you CAN delete. Revocation means deleting that row; the
   short-lived access token simply expires on its own within minutes.

CSRF: what Server Actions get for free, and what they don't

A question worth having a precise answer for: does Next.js need explicit CSRF tokens for Server Actions? No, but with real conditions attached. Server Actions get two browser-enforced checks automatically:

  • Origin header inspection. The framework validates that the incoming Origin (or Referer) header matches the server's own Host header before running the action.
  • Opaque action-ID dispatch. A Server Action is only reachable via the internal, build-time-generated Next-Action hash — there's no predictable, guessable URL for an attacker's form to target.

What this protection does not cover: a plain Route Handler under app/api/.../route.ts. Those get no automatic Origin check on POST, PUT, or DELETE. If you're accepting cross-site webhooks or a standard REST payload through a Route Handler, verifying Origin yourself is on you.

Verification

npx prisma validate
pnpm tsc --noEmit
pnpm run build

A correct build should show / and /pricing as static (), and /dashboard and /api/auth/[...nextauth] as dynamic (ƒ) — the split between the public marketing group and the session-gated dashboard group made visible in the build output itself.

The compressed version

  • middleware.ts is gone in Next.js 16; proxy.ts replaces it, and — contrary to the mental model most people still carry — it runs on the Node.js runtime by default, not an isolated Edge sandbox.
  • The proxy is an optimistic UX router. It is not, and cannot be, your access-control boundary — a Server Action's opaque endpoint can be called directly, bypassing it entirely.
  • The Data Access Layer, not the proxy and not even the layout, is where verifySession()/assertRole() actually have to run — on every sensitive read and write, memoised per request with react's cache().
  • layout.tsx persists state across navigations; template.tsx deliberately doesn't. Pick based on whether re-running effects on every transition is a feature or a bug for that piece of UI.
  • Server Actions get CSRF protection for free from Origin checks and opaque action IDs; plain Route Handlers do not — verify Origin yourself there.

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.