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.
According to Snyk's 2024 report on JavaScript framework security, 41% of Next.js applications have at least one unprotected server endpoint. Vercel's own 2024 Next.js Security Checklist specifically warns that "Server Actions should be treated as public API endpoints" and recommends authentication on every action.
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:
// POST /api/action with the 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().min(1).max(100).trim(),
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 (vibedoctor.io) scan your Next.js codebase for unprotected Server Actions, missing input validation, SQL injection in queries, and other security gaps specific to AI-generated code. 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 only difference is the developer experience (function call vs fetch). 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 runs on Edge and can check authentication before a request reaches the action. 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 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.