← Back to blog

Server Actions Auth Gaps in Next.js

6 min read
nextjssecurityreactengineering

The mental model most teams carry into a Next.js app is a castle with one gate. You put your auth check at the edge — historically in middleware, now in the proxy.ts file — it runs before every route, and once a request is past it you treat everything downstream as trusted. It's a clean model. It's also wrong in a way that doesn't show up until someone goes looking.

The gap is Server Actions. They don't enter through the gate. A Server Action is a function that gets compiled into an endpoint the browser can hit with a POST, and that endpoint is reachable directly — not just through the form you wired it to. If your only authorization check lives in the proxy, an attacker who knows the action exists can call it without ever touching the route it lives on.

I want to walk through why this happens, because the fix is easy once you see the shape, and the shape is easy to miss precisely because the framework makes actions feel like local function calls.

Server Actions are public endpoints wearing a function's clothes

When you write an action, it reads like ordinary code:

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

export async function deleteAccount(formData: FormData) {
  const userId = formData.get("userId");
  await db.user.delete({ where: { id: userId } });
}

You call it from a form, it runs on the server, it feels like you just invoked a method. But 'use server' doesn't mean "server-only" — it means "expose this over the network." Next.js generates an action ID, ships a reference to the client, and stands up a POST handler that dispatches to your function. Anyone who can observe the ID — it's in the client bundle — can craft the request themselves.

The framework's own docs are blunt about this now. The guidance on mutating data carries a warning in bold: Server Functions are reachable via direct POST requests, not just through your application's UI, so you must verify authentication and authorization inside every Server Function. That warning exists because enough people learned the lesson the expensive way.

So the deleteAccount above isn't a settings-page helper. It's an unauthenticated DELETE endpoint that trusts a user ID handed to it by the caller. Two holes: it never checks who's asking, and it never checks whether the asker owns the thing being deleted.

Why the proxy doesn't save you

The intuitive rescue is "but my proxy already blocks unauthenticated requests." Here's the thing that changed and the thing that didn't.

What changed: the file convention. middleware.ts is deprecated and renamed to proxy.ts. It's not just a rename for its own sake — the new name is a hint about what the layer is for. Proxy is designed to be invoked separately from your render code and, in optimized deployments, pushed out to the CDN for fast redirects and rewrites. The docs tell you not to rely on shared modules or globals in it.

What didn't change: the proxy is the wrong place for real authorization, and always was. The framework calls the checks you do there optimistic — read the session from the cookie, redirect if it's obviously missing, filter the clearly-unauthorized before they cost you a render. That's a UX optimization, not a security boundary. A determined caller hitting the action endpoint directly with a valid session cookie sails right through an optimistic check and lands in your unprotected function.

Leaning on the proxy for authorization is the same category error as hiding a button in the UI and calling the feature protected. The button is a suggestion. The proxy is a faster suggestion. Neither is a lock.

Put the check where the data is

The pattern that actually holds is boring and it's the one the docs push: a Data Access Layer. Centralize session verification in one module, call it at the point of every data access, and make the authorization check inseparable from the mutation itself.

// app/lib/dal.ts
import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";
import { decrypt } from "@/app/lib/session";

export const verifySession = cache(async () => {
  const cookie = (await cookies()).get("session")?.value;
  const session = await decrypt(cookie);
  if (!session?.userId) {
    throw new Error("Unauthorized");
  }
  return { userId: session.userId };
});

Then the action stops trusting its input and starts asking who's calling:

// app/settings/actions.ts
"use server";
import { verifySession } from "@/app/lib/dal";

export async function deleteAccount() {
  const { userId } = await verifySession();
  // Authorization, not just authentication:
  // act only on the caller's own resource.
  await db.user.delete({ where: { id: userId } });
}

Two changes carry all the weight. First, verifySession() runs inside the action, so authentication is checked on the actual endpoint an attacker would hit — not one layer up. Second, the function no longer accepts a userId from the caller. It derives identity from the verified session and acts on that. The old signature let anyone delete any account by passing a different ID; that's a broken-object-level-authorization bug, and it's depressingly common in action code because the FormData argument feels like trusted local state when it's actually attacker-controlled input.

The server-only import is a small but real guard — it makes the build fail loudly if this module ever gets pulled into a client bundle, which is exactly how session-verification logic leaks.

The habit worth building

None of this is exotic. It's the same discipline we apply to any HTTP endpoint: authenticate the caller, authorize the specific operation, never trust identifiers the client supplies. The only reason Server Actions trip teams up is that they're dressed as function calls, and function calls don't usually need an auth check. The abstraction hides the network boundary, and the security bug lives exactly at the boundary you were encouraged to forget.

So I've started reviewing every 'use server' file with one question: if this function's endpoint were listed in our API docs as a public POST, would I be comfortable? If the answer is no — if it trusts a caller-supplied ID, or assumes some upstream check already ran — that's the bug, sitting there in plain sight.

The takeaway

Treat every Server Action as a public API endpoint, because that's what the compiler makes it. The proxy is for optimistic redirects and lives too far from your data to be a real gate. Put authentication and authorization inside the action, derive identity from the verified session rather than the request payload, and keep that logic in a Data Access Layer so no one has to remember to add it. The gate at the edge was never protecting the actions — it was just making you feel like it was.