TLDR: Middleware gates your pages. It doesn't gate your server actions. Put requireAuth() on the first line of every mutation — fail-closed — or you're leaving a POST endpoint open.

the assumption I was running on

I built a whole fleet of Next.js apps for a health-and-wellness ecommerce client — webinar dashboards, a project-management tool, an affiliate-payouts platform, a lead importer.

Every one of them had auth middleware.

So naturally I assumed: users hit a wall at the edge, everything behind it is gated.

That felt right. It was wrong.

what the audit found

I ran a CSO pass (my term for a systematic security review) across the client's fleet in June.

One of the first findings was labeled P0 — the highest severity bucket I use.

createWebinarV2 — a server action that creates a new webinar in the webinar dashboard app — had no auth check inside the action itself.

None.

The middleware was gating the page that renders the "Create Webinar" button. But a Next.js server action compiles to a POST endpoint with a wire ID baked into the client bundle. The framework does give it CSRF and forge resistance — so a random cross-site attacker can't easily replay it. What it does NOT do is authenticate the caller or authorize them to run that specific mutation.

A caller who knew the endpoint could fire it directly. The middleware was completely irrelevant to them.

I found the same gap in two more apps on that same pass.

So what's the fix? GREAT question.

two layers — different jobs

Don't conflate them.

Layer 1 — Authentication: requireAuth() as the first line of every mutating action, fail-closed. If there's no valid session, throw — before touching the DB, before hitting any external API, before anything.

export async function createWebinarV2(formData: FormData) {
  const session = await requireAuth(); // throws if no valid session
  // ... rest of the mutation
}

Layer 2 — Authorization: a fail-closed allowlist for sensitive operations. ALLOWED_EMAILS checking (closed by default, not open), admin/super_admin role gates on admin-only mutations, deny-by-default seeds so new actions are LOCKED until you explicitly open them.

Authentication is the floor. Authorization is the richer layer on top. But without the floor, the rest is irrelevant.

why the framework doesn't just handle this

Next.js's action-ID forge resistance is real — it stops cross-site callers from crafting a valid POST. But:

  • It's not authentication. A logged-out user from your own origin can still fire the action.
  • It's not authorization. A logged-in user with no privileges can still reach mutations they shouldn't touch.

The framework gives you CSRF protection. It hands the rest to you.

the confirmation I needed

After shipping the fixes, the client's technical lead replied to my security update: "agreed that's the real one. Deny-by-default, gating all-access on an explicit admin/super_admin role, and a default-deny seed."

"Deny-by-default." That's the phrase I'm keeping. Every action is locked until I explicitly unlock it — not the other way around.

why this matters to me

I build these apps fast. That's the whole point of the Next.js Server Actions model — mutations that feel like function calls, no API layer, no route files, almost no ceremony.

The speed is real. And so is the risk of assuming the framework handles security at the right layer, when it doesn't.

The rule I'm running now: every server action is a public POST endpoint until proven otherwise. Auth check, first line, fail-closed. Then authorization on top of that.

If the action mutates data, it earns its check. No exceptions.