Next.js Server Actions Security: 5 Vulnerabilities You Must Fix

Server Actions are public HTTP endpoints. Learn to secure them with authentication, Zod validation, rate limiting, and authorization patterns. Updated for Next.js 16.

Server Actions look like regular functions, but they're public HTTP endpoints. Every 'use server' function you write creates an endpoint that anyone can call with any payload. If you're not validating input, checking authentication, and verifying authorization, you have a security hole.

Server Actions are public HTTP POST endpoints. Every function marked with 'use server' creates an endpoint that bypasses your middleware, type guards, and component-level protections. Without explicit validation, authentication, and authorization in each action, attackers can call them directly with any payload.

This guide covers the five security vulnerabilities you need to fix in every Server Action, with patterns we use across MakerKit's SaaS boilerplates. Tested with Next.js 16.1, React 19.0, and Zod 3.24.

Why Server Actions Are a Security Risk

When you write a Server Action like this:

'use server';
export async function deleteUser(userId: string) {
await db.users.delete({ where: { id: userId } });
}

Next.js compiles this into a POST endpoint. Anyone can find this endpoint in your JavaScript bundle and call it directly:

curl -X POST https://yourapp.com/action-endpoint \
-H "Content-Type: application/json" \
-d '{"userId": "any-user-id"}'

The TypeScript types disappear at runtime. The userId: string annotation doesn't prevent someone from sending {"userId": {"$ne": null}} or any other payload. Server Actions need the same security treatment as API routes.

The 5 Server Action Vulnerabilities (And How to Fix Each)

Every Server Action should address these vulnerabilities:

  1. Input validation - Reject malformed or malicious data
  2. Authentication - Verify the user is logged in
  3. Authorization - Verify the user can perform this action
  4. Rate limiting - Prevent abuse and brute force attacks
  5. Closure data exposure - Don't leak sensitive data through closures

Apply these protections based on context:

  • All actions: Input validation (non-negotiable)
  • User data actions: Authentication + Authorization
  • High-value actions (billing, deletion): Rate limiting + Captcha
  • Sensitive operations: Move to separate files (no closures)

Let's fix each one.

1. Input Validation with Zod

Never trust input. TypeScript types don't exist at runtime, so you need runtime validation:

'use server';
import { z } from 'zod';
const DeleteUserSchema = z.object({
userId: z.string().uuid(),
});
export async function deleteUser(input: unknown) {
// TypeScript types are erased at runtime - validate with Zod
const result = DeleteUserSchema.safeParse(input);
if (!result.success) {
return { error: 'Invalid input' };
}
const { userId } = result.data;
// Now userId is guaranteed to be a valid UUID
await db.users.delete({ where: { id: userId } });
}

This blocks injection attacks, malformed data, and type confusion. Always validate:

  • String lengths - Prevent memory exhaustion
  • Number ranges - Block overflow attacks
  • Enum values - Reject unexpected options
  • Nested objects - Validate every level

2. Authentication Verification

Check authentication in every protected action. This example shows a generic Next.js auth pattern:

'use server';
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
export async function deleteUser(input: unknown) {
const session = await auth();
if (!session?.user) {
redirect('/login');
}
// User is authenticated
const userId = session.user.id;
// ... rest of the action
}

Don't assume authentication just because the action is called from an authenticated page. The action itself is a public endpoint.

In MakerKit, we use getSession() from Better Auth:

'use server';
import { getSession } from '@kit/better-auth/context';
export async function deleteUser(input: unknown) {
const session = await getSession();
if (!session) {
throw new Error('Unauthorized');
}
// session.user is available
}

3. Authorization Beyond Authentication

Authentication confirms who the user is. Authorization confirms what they can do. These are different concerns:

'use server';
export async function deleteTask(input: unknown) {
const session = await auth();
if (!session?.user) {
redirect('/login');
}
const { taskId } = DeleteTaskSchema.parse(input);
// WRONG: Only checks authentication
// await db.tasks.delete({ where: { id: taskId } });
// RIGHT: Checks authorization (ownership)
const deleted = await db.tasks.deleteMany({
where: {
id: taskId,
ownerId: session.user.id, // User can only delete their own tasks
},
});
if (deleted.count === 0) {
return { error: 'Task not found or access denied' };
}
return { success: true };
}

Authorization patterns to consider:

  • Ownership - User owns the resource
  • Team membership - User belongs to the team that owns the resource
  • Role-based - User has the required role (admin, editor, viewer)
  • Permission-based - User has the specific permission for this action

In MakerKit's kits, we use composable middleware for these patterns. Here's how role-based authorization looks in production:

'use server';
import { authenticatedActionClient, withMinRole } from '@kit/action-middleware';
// Only organization owners can delete the organization
export const deleteOrganizationAction = authenticatedActionClient
.use(withMinRole('owner'))
.inputSchema(DeleteOrganizationSchema)
.action(async ({ parsedInput, ctx }) => {
// ctx.user is guaranteed to be an owner
await deleteOrganization(ctx.user.id, parsedInput.organizationId);
});

And permission-based authorization for fine-grained control:

'use server';
import { authenticatedActionClient, withFeaturePermission } from '@kit/action-middleware';
// Only users with member:update permission can change roles
export const updateMemberRoleAction = authenticatedActionClient
.use(withFeaturePermission({ member: ['update'] }))
.inputSchema(UpdateMemberRoleSchema)
.action(async ({ parsedInput, ctx }) => {
await updateMemberRole(parsedInput.memberId, parsedInput.role);
});

This middleware approach means authorization is declarative and impossible to forget.

4. Rate Limiting

Without rate limiting, attackers can:

  • Brute force passwords or tokens
  • Exhaust your database connections
  • Run up your serverless bills
  • Perform denial of service attacks

Add rate limiting to sensitive actions. This example uses Upstash Redis, but you can substitute any rate limiting library:

'use server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
import { headers } from 'next/headers';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 requests per minute
});
export async function resetPassword(input: unknown) {
const headersList = await headers();
const ip = headersList.get('x-forwarded-for') ?? 'anonymous';
const { success } = await ratelimit.limit(ip);
if (!success) {
return { error: 'Too many requests. Please try again later.' };
}
// Process password reset
}

Rate limit by:

  • IP address - For unauthenticated actions
  • User ID - For authenticated actions
  • Action + resource - For specific operations (e.g., "edit post 123")

5. The Closure Security Vulnerability

This is the most commonly overlooked vulnerability. When you define a Server Action inside a component, it can capture variables from the surrounding scope:

// DANGEROUS: Secret key captured in closure
async function ServerComponent() {
const secretKey = process.env.API_SECRET;
async function submitForm(formData: FormData) {
'use server';
// secretKey is captured and serialized!
// An attacker can potentially extract it
await callApi(secretKey, formData);
}
return <form action={submitForm}>...</form>;
}

Next.js serializes closure variables and sends them to the client in an encrypted form. While encrypted, this increases attack surface. If the encryption is ever compromised, your secrets are exposed.

The fix: Never capture sensitive data in Server Action closures. Move Server Actions to separate files:

// actions.ts
'use server';
export async function submitForm(formData: FormData) {
// Access secrets directly on the server
const secretKey = process.env.API_SECRET;
await callApi(secretKey, formData);
}
// page.tsx
import { submitForm } from './actions';
export default function Page() {
return <form action={submitForm}>...</form>;
}

Only capture non-sensitive data that the user already has access to, like IDs from the URL.

Common Pitfalls

Watch out for these common Server Action security mistakes:

  1. Trusting TypeScript types at runtime - Types are erased, always validate with Zod
  2. Checking auth in page but not action - The action is a separate endpoint
  3. Passing secrets through closures - Move actions to separate files
  4. Using user-provided IDs without authorization - Always verify ownership or permissions
  5. Returning internal error messages - Leak information about your system

Building Secure Actions with Middleware

Instead of adding security checks to every action manually, use a middleware pattern. In MakerKit's newer kits (Drizzle, Prisma), we use next-safe-action with composable middleware:

// packages/action-middleware/src/require-user-action-middleware.ts
import 'server-only';
import { createSafeActionClient } from 'next-safe-action';
import { getSession } from '@kit/better-auth/context';
const actionClient = createSafeActionClient();
// Base authenticated client
export const authenticatedActionClient = actionClient.use(async ({ next }) => {
const result = await getSession();
if (!result) {
throw new Error('Unauthorized');
}
return next({
ctx: {
user: result.user,
session: result.session,
},
});
});

Then build on it for specific authorization needs:

import { forbidden } from 'next/navigation';
// Admin-only actions
export const adminActionClient = authenticatedActionClient.use(
async ({ next, ctx }) => {
const isAdmin = ctx.user.role === 'super-admin';
if (!isAdmin) {
return forbidden();
}
return next({ ctx });
},
);

Using these clients in your actions:

'use server';
import { authenticatedActionClient } from '@kit/action-middleware';
import { z } from 'zod';
const CreateTaskSchema = z.object({
title: z.string().min(1).max(200),
accountId: z.string().uuid(),
});
export const createTaskAction = authenticatedActionClient
.inputSchema(CreateTaskSchema)
.action(async ({ parsedInput, ctx }) => {
// parsedInput is validated
// ctx.user is authenticated
await db.tasks.create({
data: {
title: parsedInput.title,
accountId: parsedInput.accountId,
createdBy: ctx.user.id,
},
});
return { success: true };
});

To verify your middleware is working, add a console.log in the auth check and call the action without being logged in. You should see the error thrown before your action logic runs.

This pattern gives you:

  1. Type-safe input - Zod schema validation built-in
  2. Guaranteed authentication - Can't reach the handler without a valid session
  3. Composable authorization - Chain middleware for roles, permissions, organization context
  4. Centralized error handling - One place to handle and log errors
  5. Impossible to forget - Security is in the base client, not opt-in

For the Supabase kit, we have a similar pattern with enhanceAction that includes captcha support:

import { enhanceAction } from '@kit/next/actions';
export const submitContactForm = enhanceAction(
async (data) => {
await sendEmail(data);
return { success: true };
},
{
schema: ContactFormSchema,
auth: false,
captcha: true, // Cloudflare Turnstile verification
}
);

Best Practices Checklist

Before deploying any Server Action, verify:

  • [ ] Input is validated with Zod or similar runtime validation
  • [ ] Authentication is checked (unless intentionally public)
  • [ ] Authorization is verified (user can perform this action on this resource)
  • [ ] Rate limiting is applied to sensitive or expensive operations
  • [ ] No sensitive data is captured in closures
  • [ ] Error messages don't leak internal details
  • [ ] Actions are in separate files marked with 'use server'

What About CSRF?

Next.js Server Actions have built-in CSRF protection. They:

  • Only accept POST requests
  • Verify the Origin header matches your domain
  • Use a server-generated action ID that attackers can't guess

You don't need to add CSRF tokens manually. The protection is automatic.

Frequently Asked Questions

Are Server Actions secure by default?
No. Server Actions are public HTTP endpoints with no built-in authentication, authorization, or input validation. You must add these security layers yourself.
Do I need CSRF protection for Server Actions?
Next.js provides built-in CSRF protection for Server Actions in modern browsers. They only accept POST requests and verify the Origin header automatically.
Should I validate input on both client and server?
Yes. Client-side validation improves UX with instant feedback. Server-side validation is the security layer that can't be bypassed. Never trust client-side validation alone.
How do I protect Server Actions from bots?
Use rate limiting for all actions and captcha verification (like Cloudflare Turnstile) for sensitive operations like sign-ups, password resets, and contact forms.
What's the difference between enhanceAction and next-safe-action?
Both solve the same problem. MakerKit's Supabase kit uses enhanceAction with built-in captcha support. The newer Drizzle and Prisma kits use next-safe-action with composable middleware for more flexible authorization patterns. Choose based on your needs.
How do I audit existing Server Actions for security issues?
Search your codebase for 'use server' directives. For each action, verify it has input validation (Zod schema), authentication check (unless public), and authorization check (ownership or permission). Check for closures capturing sensitive data.

Next Steps

Now that your Server Actions are secure, learn:

Server Actions are powerful, but that power comes with responsibility. Treat every 'use server' function as a public API endpoint, and you'll avoid the security pitfalls that catch most developers.