Next.js Server Actions allow client components to invoke asynchronous server-side functions directly. This simplifies full-stack React development by eliminating the need to write custom API fetch routes for every mutation. However, this convenience introduces a critical security assumption that many developers miss: Server Actions are public HTTP endpoints.
Under the hood, Next.js compiles each Server Action into a hashed POST route accessible over the network. Any authenticated or unauthenticated visitor who intercepts the request payload can replay it directly using curl or Burp Suite—bypassing your React UI entirely.
This post explains the mechanics of Server Action endpoints, provides a code comparison between a vulnerable and a secure action, and details how to write Playwright automation tests to verify authorization controls.
The Underlying Mechanism of Server Actions
When you mark a function with 'use server', the Next.js compiler does not keep that function hidden inside server memory. Instead, it:
- Generates an internal hashed endpoint identifier (e.g.,
/next/action/abc123ef). - Exposes an HTTP POST route accepting serialized JSON arguments.
- Automatically triggers client-side fetches targeting this endpoint when the function is called from a component.
SERVER ACTION COMMUNICATOR:
┌─────────────────┐ action(data) ┌──────────────────┐
│ Client Browser ├────────────────►│ HTTP POST Route │
│ (React Page) │◄────────────────┤ (Hashed ID Link) │
└─────────────────┘ JSON Response └────────┬─────────┘
│
▼
┌──────────────────┐
│ DB Write / Audit │
└──────────────────┘Because these are real HTTP POST routes, anyone with network access to your application can replay them. A developer who passes userRole: 'ADMIN' as a function argument—trusting the client to send the correct value—has opened a critical privilege escalation hole.
Code Comparison: Vulnerable vs. Secure Actions
Let's examine how security can fail and how to write a hardened action using TypeScript and Zod.
The Vulnerable Server Action
This action trusts the client to provide the correct userRole, making it trivially exploitable via parameter tampering:
// ❌ Unsafe Server Action
'use server';
import { db } from '@/lib/db';
// VULNERABILITY: An attacker can simply send userRole: 'ADMIN' in the POST body
export async function deleteDocument(documentId: string, userRole: string, userId: string) {
if (userRole !== 'ADMIN') {
throw new Error('Access denied');
}
await db.query('DELETE FROM documents WHERE id = $1', [documentId]);
return { success: true };
}The Secure Refactored Action
This action extracts the user session from secure, HTTP-only authentication cookies on the server, and validates all input schemas with Zod before touching the database:
// ✅ Secure Server Action
'use server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getAuthSession } from '@/lib/auth';
const DeleteSchema = z.object({
documentId: z.string().uuid(),
});
export async function deleteDocumentSecure(rawInput: unknown) {
// 1. Verify User Authentication & Roles server-side (not from client input)
const session = await getAuthSession();
if (!session || session.user.role !== 'ADMIN') {
return { success: false, error: 'Unauthorized operation' };
}
// 2. Validate and parse parameters with Zod
const validation = DeleteSchema.safeParse(rawInput);
if (!validation.success) {
return { success: false, error: 'Invalid document identifier' };
}
try {
// 3. Execute parameterized database query (no string interpolation)
await db.query('DELETE FROM documents WHERE id = $1', [validation.data.documentId]);
return { success: true };
} catch (error) {
console.error('Delete Document Failure:', error);
return { success: false, error: 'Database write error' };
}
}The two-step pattern—authenticate from session, then validate inputs—is the foundational rule for all Server Actions. Neither step can be skipped.
CSRF Considerations with Server Actions
Next.js 14+ automatically includes CSRF protection for Server Actions by requiring the Content-Type: text/plain header on direct POST requests and verifying the request origin header matches the application's Host. However, this protection only applies when actions are called through the Next.js client runtime.
If you expose Server Actions to external consumers or build custom fetch calls to them, you must validate the Origin header manually or use a CSRF token library. For most applications, using Server Actions exclusively through React's <form action={...}> or the useActionState hook is sufficient to stay protected.
Testing Server Actions with Playwright
To verify that your Server Actions behave correctly and fail securely under adversarial conditions, write integration tests in Playwright. These tests exercise the full request lifecycle—including authorization failures and validation boundaries—through the real UI:
import { test, expect } from '@playwright/test';
test.describe('Server Action Security Tests', () => {
test('should reject deletion for unauthenticated users', async ({ page }) => {
// Navigate to documents page without logging in
await page.goto('/documents');
const deleteButton = page.locator('[data-testid="delete-btn-101"]');
await expect(deleteButton).toBeVisible();
// Click button and wait for the action to resolve
const [response] = await Promise.all([
page.waitForResponse(response =>
response.url().includes('/documents') && response.status() === 200
),
deleteButton.click()
]);
// Assert the UI surfaces the authorization error
const errorBanner = page.locator('[data-testid="error-banner"]');
await expect(errorBanner).toContainText('Unauthorized operation');
});
test('should reject malformed document IDs', async ({ page }) => {
await page.goto('/documents');
const customInput = page.locator('[data-testid="document-input"]');
await customInput.fill('invalid-non-uuid-string');
const submitBtn = page.locator('[data-testid="submit-action-btn"]');
await submitBtn.click();
const validationMessage = page.locator('[data-testid="validation-error"]');
await expect(validationMessage).toBeVisible();
await expect(validationMessage).toContainText('Invalid document identifier');
});
test('should succeed for authorized admin users', async ({ page }) => {
// Sign in as admin using a stored auth state
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill(process.env.TEST_ADMIN_PASSWORD!);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
const deleteButton = page.locator('[data-testid="delete-btn-101"]');
await deleteButton.click();
const successBanner = page.locator('[data-testid="success-banner"]');
await expect(successBanner).toContainText('Document deleted');
});
});Testing all three paths—unauthenticated rejection, invalid input rejection, and valid success—gives you confidence that neither the authorization nor the validation layer has regressed.
Conclusion
Server Actions streamline client-server architecture, but they do not eliminate security requirements—they relocate them. Because every Server Action compiles to a real HTTP endpoint, the only safe assumption is that any caller is adversarial. Always authenticate users from the server session, parse every input schema before it touches business logic, and maintain Playwright integration tests that verify both the happy path and the security failure states. The frontend UI is optional; your Server Action must be hardened as if it were a standalone API.