DEV Community

JSGuruJobs
JSGuruJobs

Posted on

6 Next.js Server Action Security Patterns That Prevent Real Exploits in Production

React Server Components turned your frontend into a server attack surface. Most vulnerabilities now come from Server Actions.
Here are 6 patterns that close the most common holes immediately.


1. Validate Every Server Action Input With Zod

Server Actions receive untrusted input. Treat them like public APIs.

Before

'use server';

export async function createJob(formData: FormData) {
  const title = formData.get('title');
  const company = formData.get('company');

  await db.jobs.create({
    data: { title, company }
  });
}
Enter fullscreen mode Exit fullscreen mode

After

'use server';

import { z } from 'zod';

const schema = z.object({
  title: "z.string().min(3).max(200),"
  company: z.string().min(2).max(100),
});

export async function createJob(formData: FormData) {
  const parsed = schema.safeParse({
    title: "formData.get('title'),"
    company: formData.get('company'),
  });

  if (!parsed.success) {
    return { error: parsed.error.flatten() };
  }

  await db.jobs.create({
    data: parsed.data
  });
}
Enter fullscreen mode Exit fullscreen mode

You eliminate malformed payloads and injection vectors. This blocks an entire class of exploits with ~10 lines of code.


2. Add Explicit Auth Checks Inside Every Action

"use server" does not enforce authentication. Every action is a public endpoint.

Before

'use server';

export async function deleteJob(id: string) {
  await db.jobs.delete({ where: { id } });
}
Enter fullscreen mode Exit fullscreen mode

After

'use server';

import { getSession } from '@/lib/auth';

export async function deleteJob(id: string) {
  const session = await getSession();

  if (!session?.userId) {
    throw new Error('Unauthorized');
  }

  const job = await db.jobs.findUnique({ where: { id } });

  if (job?.authorId !== session.userId) {
    throw new Error('Forbidden');
  }

  await db.jobs.delete({ where: { id } });
}
Enter fullscreen mode Exit fullscreen mode

Without this, anyone can call your endpoint directly with fetch. This is the most common production mistake.


3. Never Leak Full Database Objects From Server Components

Server Components serialize data to the client. If you fetch everything, you expose everything.

Before

export default async function Profile() {
  const user = await db.users.findUnique({
    where: { id: session.userId }
  });

  return <ProfileView user={user} />;
}
Enter fullscreen mode Exit fullscreen mode

After

export default async function Profile() {
  const user = await db.users.findUnique({
    where: { id: session.userId },
    select: {
      name: true,
      avatar: true,
      role: true,
    }
  });

  return <ProfileView user={user} />;
}
Enter fullscreen mode Exit fullscreen mode

You reduce the attack surface and prevent accidental exposure of passwords, tokens, and internal flags.


4. Move Secrets Out of Source Code Completely

If source leaks, hardcoded secrets leak with it.

Before

export async function fetchData() {
  return fetch('https://api.example.com', {
    headers: {
      Authorization: 'Bearer sk_live_abc123'
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

After

export async function fetchData() {
  return fetch('https://api.example.com', {
    headers: {
      Authorization: `Bearer ${process.env.API_SECRET}`
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

This is basic, but still violated in real projects. One leak equals full compromise.


5. Add CSP Headers at the Proxy Layer

Next.js does not protect you by default. You must define security headers.

Before

export function proxy() {
  return NextResponse.next();
}
Enter fullscreen mode Exit fullscreen mode

After

import { NextResponse } from 'next/server';

export function proxy() {
  const res = NextResponse.next();

  res.headers.set(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self'; img-src 'self' data: https:;"
  );

  res.headers.set('X-Frame-Options', 'DENY');
  res.headers.set('X-Content-Type-Options', 'nosniff');

  return res;
}
Enter fullscreen mode Exit fullscreen mode

CSP alone blocks many XSS and injection attempts without touching your app code.


6. Detect Suspicious Server Action Requests

Real attacks look like weird payloads hitting your endpoints.

Before

export async function action(formData: FormData) {
  // logic
}
Enter fullscreen mode Exit fullscreen mode

After

import { headers } from 'next/headers';

export async function action(formData: FormData) {
  const contentType = (await headers()).get('content-type');

  if (!contentType?.includes('multipart/form-data')) {
    console.error('Suspicious request', {
      contentType,
      time: new Date().toISOString(),
    });
  }

  // normal logic
}
Enter fullscreen mode Exit fullscreen mode

This gives you early signals of exploitation attempts instead of discovering issues after damage.

This pattern becomes critical as AI-generated code increases attack surface, which is covered deeper in the web security risks introduced by AI generated JavaScript code.


Closing

Pick one action and harden it today. Add validation. Add auth. Add logging.
Security in Next.js is not a config. It is code you write or forget to write.

Top comments (1)

Collapse
 
freerave profile image
freerave

Exactly! I've recently moved to a strict Zod + React Hook Form workflow to ensure every bit of input is sanitized. But as you mentioned, the real danger is the 'Bridge'β€”the API. It’s wild how many devs forget to audit their API output. I'm now using extra layers to ensure the database doesn't leak personal info or sensitive fields in the JSON response. If you neglect API security, you're basically leaving the front door wide open for a total system compromise.