Next.js Server Actions automatically expose every exported function in a use-server file as a public HTTP endpoint, assigning it a unique identifier that the client uses to invoke the function. The endpoint exists whether or not a button, link, or any other UI element is rendered, meaning anyone who discovers the identifier can call the function directly.

The framework’s design treats Server Actions like ordinary helper functions, but at runtime they become reachable URLs. For developers who assumed the UI was the only gatekeeper, this creates an invisible attack surface that can be exploited with a single crafted request.

Why Server Actions seemed safe

Server Actions were introduced to let developers write server-side code right next to their components, avoiding the boilerplate of separate API routes. A typical usage looks like:

<form action={deleteInvoice}>
  <button type="submit">Delete</button>
</form>

Because the form is the only visible way to trigger deleteInvoice, many developers hide the button for unauthorised users, think that TypeScript signatures will stop malformed data, and rely on the fact that the function lives in a server-only module. None of those assumptions provide real protection.

The hidden exposure

When a file contains “use server”, Next.js compiles each exported function into an endpoint such as:

POST /_next/data/<build-id>/<page>.json?__rsc=<action-id>

The <action-id> is a stable hash that the client bundle embeds. An attacker can obtain it by:

  • Inspecting the page’s network traffic.
  • Reading the bundled JavaScript (the ID is a plain string).
  • Guessing based on naming conventions if the project follows predictable patterns.

Once the ID is known, a request can be sent from any tool—cURL, Postman, or a malicious script—bypassing any UI-level checks.

Three concrete risks

Risk Why it matters
UI checks are ineffective Hiding a button or link does not delete the underlying endpoint. The endpoint remains reachable, just like a hidden admin page that still exists on the server.
TypeScript offers no runtime safety Types are stripped when the code runs. A function declared as deleteInvoice(id: number) can receive a massive string, an array, or even malicious JSON, leading to logic errors or injection attacks.
No default authentication or authorization Server Actions look like local helpers, so developers often forget to add session checks, CSRF protection, or row-level permission checks that are standard in traditional API routes.

How to secure a Server Action

  1. Authenticate the caller – Verify that a valid session or token exists before any business logic runs.
  2. Validate the payload – Use a schema library (e.g., Zod, Yup) to enforce data types and value constraints at runtime.
  3. Authorize the operation – Beyond “is the user logged in?”, confirm that the user owns the specific record they are trying to modify or delete.

A minimal example:

'use server';
import { getSession } from '@/auth';
import { z } from 'zod';
import { db } from '@/db';

const DeleteInvoiceSchema = z.object({
  id: z.number().int().positive(),
});

export async function deleteInvoice(formData: FormData) {
  const session = await getSession();
  if (!session) throw new Error('Unauthenticated');

  const parsed = DeleteInvoiceSchema.safeParse({
    id: Number(formData.get('id')),
  });
  if (!parsed.success) throw new Error('Invalid input');

  const invoice = await db.invoice.findUnique({ where: { id: parsed.data.id } });
  if (!invoice || invoice.ownerId !== session.userId) {
    throw new Error('Unauthorized');
  }

  await db.invoice.delete({ where: { id: invoice.id } });
}

The code explicitly checks authentication, validates the incoming id, and ensures the logged-in user actually owns the invoice before performing the delete.

Other subtle Next.js pitfalls

Issue Symptom Fix
NEXT_PUBLIC_ env vars Anything prefixed with NEXT_PUBLIC_ is bundled into the client, exposing secrets. Keep secrets in plain env vars, never prefix them with NEXT_PUBLIC_.
Open redirects Accepting a redirect query parameter and naively concatenating it can send users to //evil.com. Validate the target against a whitelist or enforce same-origin checks.
Server-Side Request Forgery (SSRF) Fetching a URL supplied by a user can let attackers reach internal services or cloud metadata endpoints. Allow-list hostnames, block private IP ranges, and set timeouts.
dangerouslySetInnerHTML Rendering user-provided HTML without sanitisation opens XSS. Use a library like DOMPurify or avoid raw HTML altogether.

Scanning for these patterns automatically

Static-analysis tools can flag the risky constructs listed above. One lightweight option is Semgrep, which runs quickly and can be integrated into CI pipelines:

npx --yes semgrep --config https://raw.githubusercontent.com/catidegla/stacksec/main/rules .

The rule set includes checks for exported server functions, misuse of NEXT_PUBLIC_, open redirects, SSRF patterns, and unsafe HTML insertion.

What to watch next

  • Framework updates – Keep an eye on Next.js releases; the team may introduce built-in authentication hooks or sandbox the generated endpoints.
  • Community tooling – New ESLint plugins and Next.js-specific Semgrep rules are emerging to automate the safeguards described here.
  • Real-world incidents – As more projects adopt Server Actions, watch for disclosed exploits that illustrate the risk in practice. Early detection can inform internal security reviews before a breach occurs.

Takeaway: A Next.js Server Action is not a private helper; it is a public HTTP endpoint the moment you export it. Treat it like any other API route—authenticate, validate, and authorize—otherwise the convenience of writing server code next to UI can quickly become a security liability.