Skip to main content

Security in Server Actions: Protecting Mutations

Server Actions are encrypted and signed by Next.js to prevent CSRF (Cross-Site Request Forgery) attacks automatically. However, Server Actions still require careful security design: validate all inputs, check user permissions before mutating data, avoid exposing sensitive information, and rate-limit to prevent abuse. A Server Action that doesn't verify the current user's identity can let any authenticated user modify anyone's data.

The security model is simple: treat Server Actions like API endpoints. Assume the client is untrusted. Every Server Action should check: (1) Is the user authenticated? (2) Does the user have permission to perform this action? (3) Are the inputs valid and safe? (4) Is the action rate-limited to prevent brute-force abuse?

CSRF Protection (Built-in)

Next.js automatically protects Server Actions against CSRF by requiring a token. When you use a form with a Server Action, Next.js embeds a CSRF token in the form. The token is verified on the server before the action executes. This happens transparently—you don't need to do anything.

However, this protection only works when the form originates from your own domain. Cross-origin requests (e.g., a form submitted from attacker.com to your site) cannot include your CSRF token and are rejected by Next.js.

CSRF attacks that Server Actions prevent:

// Attacker's website (attacker.com)
<form action="https://yoursite.com/api/transfer-money" method="POST">
<input type="hidden" name="amount" value="1000" />
<input type="hidden" name="toAccount" value="attacker-account" />
<script>
// Auto-submit the form
document.querySelector('form').submit();
</script>
</form>

If your site used traditional API endpoints, a user visiting attacker.com might unknowingly submit this form and transfer money. Server Actions prevent this because Next.js includes a CSRF token that the attacker cannot obtain.

Authorization Checks

Every Server Action must verify the user's identity and permissions:

// app/actions.ts
'use server';

import { getCurrentUser } from '@/lib/auth';
import { db } from '@/lib/db';

export async function deletePost(postId: string) {
// 1. Check if user is authenticated
const user = await getCurrentUser();
if (!user) {
throw new Error('Unauthorized: Please log in');
}

// 2. Fetch the post
const post = await db.posts.findById(postId);
if (!post) {
throw new Error('Post not found');
}

// 3. Check if user owns the post
if (post.userId !== user.id) {
throw new Error('Unauthorized: You can only delete your own posts');
}

// 4. Perform the mutation
await db.posts.delete(postId);

return { success: true };
}

Never trust the client to provide the user id or resource id without verification. Always fetch the resource and check ownership before mutating.

Wrong:

// BAD: Trusts the client to specify userId
export async function updateUser(userId: string, data: any) {
const user = await db.users.update(userId, data);
return user;
}

Right:

// GOOD: Gets the current user from session, not the client
export async function updateUser(data: any) {
const user = await getCurrentUser();
if (!user) throw new Error('Unauthorized');

const updated = await db.users.update(user.id, data);
return updated;
}

Input Validation and Sanitization

Validate and sanitize all inputs to prevent injection attacks (SQL injection, NoSQL injection, command injection):

// app/actions.ts
'use server';

import { z } from 'zod';
import { db } from '@/lib/db';

// Define schemas for all inputs
const updatePostSchema = z.object({
id: z.string().uuid('Invalid post ID'),
title: z.string().min(1).max(200),
content: z.string().min(1).max(10000),
tags: z.array(z.string().max(50)).max(10),
});

export async function updatePost(data: any) {
const user = await getCurrentUser();
if (!user) throw new Error('Unauthorized');

// Validate input shape and types
const result = updatePostSchema.safeParse(data);
if (!result.success) {
throw new Error('Invalid input: ' + result.error.message);
}

const { id, title, content, tags } = result.data;

// Verify ownership
const post = await db.posts.findById(id);
if (!post || post.userId !== user.id) {
throw new Error('Not found or unauthorized');
}

// Perform update
await db.posts.update(id, { title, content, tags });

return { success: true };
}

Zod automatically sanitizes inputs: strings are trimmed, numbers are coerced, enums are validated. This prevents both type mismatches and common injection patterns.

Preventing Sensitive Data Leaks

Never return sensitive information to the client:

// BAD: Leaks the user's password hash
export async function getUserProfile(userId: string) {
const user = await db.users.findById(userId);
return user; // Includes password hash, email verification tokens, etc.
}

// GOOD: Returns only safe fields
export async function getUserProfile(userId: string) {
const user = await getCurrentUser();
if (!user || user.id !== userId) {
throw new Error('Unauthorized');
}

return {
id: user.id,
name: user.name,
avatar: user.avatar,
createdAt: user.createdAt,
// Omit: password, tokens, email (if private), subscription keys
};
}

Rate Limiting

Prevent brute-force attacks by rate-limiting sensitive actions:

// lib/rate-limit.ts
import { RateLimiter } from 'limiter';

const loginLimiter = new RateLimiter({
tokensPerInterval: 5, // 5 attempts
interval: 'minute',
});

export async function checkLoginLimit(email: string) {
const remaining = await loginLimiter.tryRemoveTokens(1);
if (remaining < 0) {
throw new Error('Too many login attempts. Try again in 1 minute.');
}
}
// app/actions.ts
'use server';

import { checkLoginLimit } from '@/lib/rate-limit';

export async function login(email: string, password: string) {
// Rate limit
await checkLoginLimit(email);

const user = await db.users.findByEmail(email);
if (!user || !await verifyPassword(password, user.passwordHash)) {
throw new Error('Invalid credentials');
}

// Create session
const session = await createSession(user.id);
return { sessionId: session.id };
}

Use a Redis-backed rate limiter in production (e.g., redis-rate-limit) for distributed rate limiting across multiple servers.

Avoiding Timing Attacks

When comparing sensitive values (passwords, tokens), use a constant-time comparison to prevent timing attacks:

// lib/security.ts
import crypto from 'crypto';

export function constantTimeCompare(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
// app/actions.ts
'use server';

import { constantTimeCompare } from '@/lib/security';

export async function verifyOTP(email: string, otp: string) {
const storedOTP = await db.otps.findByEmail(email);
if (!storedOTP) {
throw new Error('OTP not found or expired');
}

// Use constant-time comparison
if (!constantTimeCompare(otp, storedOTP.code)) {
throw new Error('Invalid OTP');
}

// OTP is correct
return { success: true };
}

Logging and Monitoring

Log security-relevant events for auditing and incident response:

// lib/audit-log.ts
export async function logSecurityEvent(event: {
action: string;
userId?: string;
resource?: string;
success: boolean;
error?: string;
timestamp: Date;
}) {
await db.auditLogs.create(event);

// Alert on suspicious activity
if (!event.success && event.action === 'delete_account') {
await notifySecurityTeam(`Failed account deletion attempt: ${event.userId}`);
}
}
// app/actions.ts
'use server';

import { logSecurityEvent } from '@/lib/audit-log';

export async function deleteAccount(password: string) {
const user = await getCurrentUser();

try {
if (!user) throw new Error('Unauthorized');

const verified = await verifyPassword(password, user.passwordHash);
if (!verified) {
await logSecurityEvent({
action: 'delete_account',
userId: user.id,
success: false,
error: 'Invalid password',
timestamp: new Date(),
});
throw new Error('Invalid password');
}

await db.users.delete(user.id);

await logSecurityEvent({
action: 'delete_account',
userId: user.id,
success: true,
timestamp: new Date(),
});

return { success: true };
} catch (error) {
throw error;
}
}

Security Checklist

CheckDescriptionExample
AuthenticationIs the user logged in?const user = await getCurrentUser(); if (!user) throw ...
AuthorizationDoes the user have permission?Check post.userId === user.id before updating
Input ValidationAre all inputs safe and expected type?Use Zod schema for every action parameter
Sensitive DataAm I leaking passwords, tokens, or PII?Return only safe fields; omit password hashes
Rate LimitingAre sensitive actions rate-limited?Limit login attempts, password resets, OTP verification
Timing AttacksAm I using constant-time comparison?Use crypto.timingSafeEqual() for tokens/passwords
LoggingCan I audit this action later?Log user id, action, timestamp, success/failure
Error MessagesAm I exposing internal details?Return "Invalid credentials" not "Email not found"

Key Takeaways

  • Server Actions are protected against CSRF by Next.js automatically; trust the built-in protection.
  • Always verify the current user and their permissions before mutating data. Never trust client-provided user ids.
  • Validate and sanitize all inputs with Zod. Never concatenate user input into queries.
  • Return only safe fields from actions; omit sensitive data like password hashes and API keys.
  • Rate-limit sensitive actions (login, password reset) to prevent brute-force attacks.
  • Log security events for auditing and incident response.

Frequently Asked Questions

How do I verify the user in a Server Action?

Call await getSession() or await getCurrentUser() (your auth library). This reads the session from cookies or headers, which the client cannot forge. Always check the result is not null before proceeding.

Can an attacker bypass CSRF protection with curl?

No. Curl cannot obtain the CSRF token embedded in your HTML forms. Even if an attacker makes a direct POST request with curl, Next.js validates the token and rejects it.

Should I log every Server Action?

Log security-relevant actions (login, password change, deletion, privilege escalation) and failures. For high-volume actions (like, comment), log only failures or sampled successes. Excessive logging is slow and expensive.

How do I implement role-based access control?

Check the user's role before allowing the action: if (user.role !== 'admin') throw new Error('Forbidden'). Store roles in the session or database and verify on the server every time.

What's the difference between authentication and authorization?

Authentication: verifying the user is who they claim (login). Authorization: verifying the user has permission to perform the action (role check). Always do both.

Further Reading