Quick Answer
Next.js Server Actions are server-side functions that can be called directly from client components. AI code generators create them without authentication checks, input validation, or rate limiting, because the "use server" directive makes the function look like a simple async call. In reality, every Server Action is a publicly accessible API endpoint. Treat them exactly like REST endpoints: validate input, check authentication, and authorize access on every call.
Server Actions Are Public API Endpoints
The biggest misconception about Server Actions is that they are somehow protected because they live in server-side code. They are not. When you mark a function with "use server", Next.js creates a POST endpoint that any HTTP client can call. The pleasant-looking function call in your component is syntactic sugar over a network request.
It is easy to ship a Next.js server endpoint with no authentication check. The Next.js authentication guide says to "treat Server Actions with the same security considerations as public-facing API endpoints", and the data security guide says to verify authentication and authorization inside each one.
AI code generators make this worse because they present Server Actions as simple function calls, stripping away the mental model that this code runs over the network and can be called by anyone.
The 5 Critical Security Gaps
Gap 1: No Authentication
// ❌ BAD - Anyone can call this, no auth check
"use server";
export async function deleteUser(userId: string) {
await db.query('DELETE FROM users WHERE id = $1', [userId]);
return { success: true };
}
// An attacker can call this directly:
// a POST to the page that uses the action, with its action ID and any userId
// ✅ GOOD - Verify the session before doing anything
"use server";
import { auth } from "@/lib/auth";
export async function deleteUser(userId: string) {
const session = await auth();
if (!session?.user) {
throw new Error("Unauthorized");
}
// Authorization: can this user delete this account?
if (session.user.id !== userId && session.user.role !== 'admin') {
throw new Error("Forbidden");
}
await db.query('DELETE FROM users WHERE id = $1', [userId]);
return { success: true };
}
Gap 2: No Input Validation
// ❌ BAD - FormData used directly without validation
"use server";
export async function updateProfile(formData: FormData) {
const name = formData.get('name');
const email = formData.get('email');
const role = formData.get('role'); // User can set their own role!
await db.query(
'UPDATE users SET name = $1, email = $2, role = $3 WHERE id = $4',
[name, email, role, formData.get('userId')]
);
}
// ✅ GOOD - Validate and sanitize every field
"use server";
import { z } from 'zod';
import { auth } from "@/lib/auth";
const updateProfileSchema = z.object({
name: z.string().trim().min(1).max(100),
email: z.string().email().max(255),
// 'role' is NOT accepted from the client
});
export async function updateProfile(formData: FormData) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const data = updateProfileSchema.parse({
name: formData.get('name'),
email: formData.get('email'),
});
await db.query(
'UPDATE users SET name = $1, email = $2 WHERE id = $3',
[data.name, data.email, session.user.id] // ID from session, not form
);
}
Gap 3: SQL Injection via String Interpolation
// ❌ BAD - AI-generated string interpolation in queries
"use server";
export async function searchUsers(query: string) {
const results = await db.query(
`SELECT * FROM users WHERE name LIKE '%${query}%'`
);
return results;
}
// ✅ GOOD - Parameterized query
export async function searchUsers(query: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const sanitized = z.string().max(100).parse(query);
const results = await db.query(
'SELECT id, name, email FROM users WHERE name LIKE $1',
[`%${sanitized}%`]
);
return results;
}
Gap 4: No Rate Limiting
Server Actions have no built-in rate limiting. Without it, an attacker can call your actions thousands of times per second - useful for brute-forcing login, spamming forms, or running up your database costs.
Gap 5: Leaking Sensitive Data in Return Values
// ❌ BAD - Returns full user object including password hash
"use server";
export async function getUser(id: string) {
const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
return user; // Includes password_hash, internal_notes, etc.
}
// ✅ GOOD - Return only what the client needs
export async function getUser(id: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const user = await db.query(
'SELECT id, name, email, avatar_url FROM users WHERE id = $1',
[id]
);
return user;
}
Server Action Security Checklist
| Check | What To Verify | How |
|---|---|---|
| Authentication | Is the user logged in? | Check session/JWT at the top of every action |
| Authorization | Can this user do this action? | Role check + ownership check (e.g., own resources only) |
| Input Validation | Is the input safe and expected? | Zod schema on all parameters |
| SQL Safety | Are queries parameterized? | No string interpolation in queries |
| Return Data | Is the response minimal? | SELECT only needed columns, never return * |
| Rate Limiting | Can this be abused? | IP-based or user-based rate limits on sensitive actions |
The Pattern AI Should Generate (But Does Not)
// ✅ Secure Server Action template
"use server";
import { z } from 'zod';
import { auth } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';
const schema = z.object({
// Define expected input shape
});
export async function secureAction(input: z.infer<typeof schema>) {
// 1. Rate limit
await rateLimit('action-name', { max: 10, window: '1m' });
// 2. Authenticate
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
// 3. Validate input
const data = schema.parse(input);
// 4. Authorize (check ownership/role)
// 5. Execute with parameterized queries
// 6. Return minimal data
}
Tools like VibeDoctor's Vibe Check scan your Next.js codebase for SQL built with string interpolation, leaked secrets and dependencies with known CVEs, Next.js itself included. Its unprotected-route and input-validation checks cover Express, Fastify and NestJS routes, not Server Actions, so the auth and validation checks in this post are still yours to add. Free to sign up.
FAQ
Are Server Actions more secure than API routes?
No. Server Actions are functionally identical to API routes from a security perspective. Both are server-side code accessible over HTTP. The main difference is the developer experience (function call vs fetch); Next.js adds a few framework protections to Server Actions, such as an Origin check, but none of them check who the caller is. Apply the same security measures to both: authentication, authorization, input validation, and rate limiting.
Can Server Actions be called by external clients?
Yes. Server Actions create POST endpoints. While Next.js adds CSRF protection via the Origin header check, the action ID can be extracted from the client bundle. Never rely on obscurity. Always authenticate and validate inside the action itself.
Does Next.js middleware protect Server Actions?
Next.js middleware (renamed Proxy in Next.js 16) runs before the request reaches the action and can check authentication. However, middleware should be a first line of defense, not the only one. Always add auth checks inside the Server Action too, because middleware can be bypassed if misconfigured (a matcher that skips a path also skips the Server Actions on that page) and does not validate action-specific authorization (e.g., "can this user delete this specific resource?").
How do I add rate limiting to Server Actions?
Use an in-memory store (for single-server) or Redis (for multi-server). Libraries like @upstash/ratelimit work well with Next.js. Check the rate limit at the top of the action and throw a 429-equivalent error if exceeded. Rate limit by IP for unauthenticated actions, by user ID for authenticated ones.
Should I use Server Actions or API routes?
Both are valid. Server Actions are more convenient for form submissions and mutations in Server Components. API routes are better for public APIs consumed by external clients. From a security standpoint, both need the same protections. Choose based on your use case, not security assumptions.