Next.js & React

Server Actions Security: CSRF, Origins, and the Gaps

By Technspire Team
February 5, 2026
11 views

Server Actions make state-changing endpoints nearly invisible. A function call that happens to cross the network. That ergonomic simplicity is also the security risk: teams ship actions thinking the framework protects them, and discover at audit time which protections are real and which are assumed. js actually does for you, what it does not, and the hardening layer every production application should have.

What Next.js Does Automatically

  • Same-origin request check. Server Actions are rejected if the Origin header does not match the host. This defeats the common cross-site POST attack without your code involved.
  • Action ID obfuscation. Server Action endpoints are not discoverable as stable URLs. They are referenced by cryptographic IDs that rotate per build. This raises the bar on opportunistic enumeration.
  • Unused-action stripping. Dead-code elimination means actions you do not reference are not exposed. This is a correctness feature that happens to reduce attack surface.

What Next.js Does Not Do

Authentication and authorisation

The framework does not know who your users are. Every mutation must verify the caller before acting. The first line of any Server Action that changes state should be an auth check, not business logic.

'use server';

export async function deleteInvoice(invoiceId: string) {
  const session = await auth();
  if (!session?.user) throw new Error('UNAUTHENTICATED');

  const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
  if (!invoice) throw new Error('NOT_FOUND');
  if (invoice.ownerId !== session.user.id) throw new Error('FORBIDDEN');

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

Input validation

Server Actions accept whatever the client sends. The TypeScript types on the signature do not survive the network boundary. Anyone can post anything. Every action input must be validated with a runtime schema (Zod is the default choice).

import { z } from 'zod';

const CreateCustomer = z.object({
  name: z.string().min(1).max(200),
  email: z.string().email(),
  orgNumber: z.string().regex(/^\d{6}-\d{4}$/).optional(),
});

export async function createCustomer(raw: unknown) {
  const input = CreateCustomer.parse(raw);   // throws on bad input
  // input is now typed AND validated
  return prisma.customer.create({ data: input });
}

Rate limiting

Server Actions are as abusable as any endpoint. An anonymous form action that sends an email can be called a thousand times a minute by a single attacker. Apply rate limiting at the middleware or edge layer. Upstash, Vercel KV, or Redis patterns all work.

File-upload safety

  • Size limits. Next.js has a default body size, but you should still enforce per-action limits that match the feature.
  • MIME type validation. Never trust the client's declared content type. Check magic bytes with a server-side library.
  • Storage separation. Uploaded files should never land in the same origin as your application. Use Azure Blob with a signed URL handoff, not a direct write to public/.
  • Antivirus scan. For files that will be served back to users, scan on upload (Defender for Cloud, ClamAV).

XSS on displayed output

React escapes by default, which covers the common case. The trap is dangerouslySetInnerHTML. Any rich-text field that round-trips through a Server Action and back to a rendered page must be sanitised server-side. Never trust the output of a markdown or WYSIWYG editor to be safe HTML without running it through a sanitiser (DOMPurify or sanitize-html).

The Helper Every App Should Have

Stop repeating the auth + parse + authorise pattern in every action. Wrap it in a helper:

type AuthedAction<Input, Output> = (session: Session, input: Input) => Promise<Output>;

export function action<Schema extends z.ZodTypeAny, Output>(
  schema: Schema,
  fn: AuthedAction<z.infer<Schema>, Output>,
) {
  return async (raw: unknown) => {
    const session = await auth();
    if (!session?.user) throw new Error('UNAUTHENTICATED');
    await rateLimit(session.user.id);
    const input = schema.parse(raw);
    return fn(session, input);
  };
}

// usage
export const createCustomer = action(CreateCustomer, async (session, input) => {
  return prisma.customer.create({ data: { ...input, ownerId: session.user.id } });
});

The Security Checklist

  • Every state-changing action has an auth check as its first line.
  • Every action validates input with a runtime schema.
  • Rate limits are applied at the edge or in middleware.
  • File uploads go to a separate storage origin with MIME and size validation.
  • All rich-text rendered via dangerouslySetInnerHTML is sanitised server-side.
  • Error messages do not leak whether a resource exists for a user who cannot access it.

Ready to Transform Your Business?

Let's discuss how we can help you implement these solutions and achieve your goals with AI, cloud, and modern development practices.

No commitment required • Expert guidance • Tailored solutions