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:
- Input validation - Reject malformed or malicious data
- Authentication - Verify the user is logged in
- Authorization - Verify the user can perform this action
- Rate limiting - Prevent abuse and brute force attacks
- 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 organizationexport 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 rolesexport 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 closureasync 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.tsximport { 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:
- Trusting TypeScript types at runtime - Types are erased, always validate with Zod
- Checking auth in page but not action - The action is a separate endpoint
- Passing secrets through closures - Move actions to separate files
- Using user-provided IDs without authorization - Always verify ownership or permissions
- 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.tsimport 'server-only';import { createSafeActionClient } from 'next-safe-action';import { getSession } from '@kit/better-auth/context';const actionClient = createSafeActionClient();// Base authenticated clientexport 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 actionsexport 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:
- Type-safe input - Zod schema validation built-in
- Guaranteed authentication - Can't reach the handler without a valid session
- Composable authorization - Chain middleware for roles, permissions, organization context
- Centralized error handling - One place to handle and log errors
- 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?
Do I need CSRF protection for Server Actions?
Should I validate input on both client and server?
How do I protect Server Actions from bots?
What's the difference between enhanceAction and next-safe-action?
How do I audit existing Server Actions for security issues?
Next Steps
Now that your Server Actions are secure, learn:
- When to use Server Actions vs Route Handlers for choosing the right approach
- Next.js Security Best Practices for comprehensive application security
- MakerKit's Server Actions documentation for production patterns
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.