Server Actions are public endpoints
I see this mistake almost every week in production codebases: developers treating Server Actions like private utility functions when they're actually public HTTP endpoints. Your Next.js Server Action is exposed to the network just like a traditional API route—except without the obvious `/api` prefix that makes you paranoid. If you can call it from your client component, so can anyone with a browser console and your site's URL. This is Day 29 of the React & Next.js series, and today we're covering the security posture that separates hobby projects from production platforms.
Why Server Actions feel deceptively safe
Server Actions live in your codebase alongside your UI logic. They're co-located with your form components. They run on the server. The combination creates a false sense of security—like they're internal implementation details. They're not. Every Server Action is serialized and exposed as an endpoint your client calls via HTTP. An attacker can craft requests to these endpoints just as easily as you do from your React component.
I learned this the hard way building a trading platform. We had a Server Action that executed limit orders. It took a price parameter from the form, validated it client-side, and submitted. Looked fine. Until I realized someone could open DevTools, copy the action call, modify the price to something cheaper, and submit it directly. No form required. No UI safeguards. The action only trusted what the client sent.
Validate every input with Zod
Your first defense is validation. Not client-side validation—that's for UX. Server-side validation with a schema library like Zod is your security boundary. Every single input parameter must be validated before you use it, regardless of whether you "controlled" it from your form.
Here's a real example from an e-commerce platform. We had a Server Action that applied discount codes:
// ❌ WRONG: No validation
export async function applyDiscount(code: string, cartId: string) {
const discount = await db.discount.findUnique({
where: { code }
});
return {
percentage: discount.percentage, // What if discount is null?
cartId
};
}
// ✅ CORRECT: Schema validation
import { z } from 'zod';
const ApplyDiscountSchema = z.object({
code: z.string().min(1).max(50),
cartId: z.string().uuid()
});
export async function applyDiscount(
rawCode: unknown,
rawCartId: unknown
) {
const result = ApplyDiscountSchema.safeParse({
code: rawCode,
cartId: rawCartId
});
if (!result.success) {
return {
error: 'Invalid input',
success: false
};
}
const { code, cartId } = result.data;
const discount = await db.discount.findUnique({
where: { code }
});
if (!discount) {
return {
error: 'Discount not found',
success: false
};
}
return {
percentage: discount.percentage,
cartId,
success: true
};
}This catches type mismatches, unexpected field names, and values outside your expected range. Zod will reject a CartId that isn't a valid UUID. It'll reject a code longer than 50 characters. When validation fails, you fail fast and don't proceed.
Check authentication and authorization inside
Every Server Action must verify who is calling it and what they're allowed to do. Never assume the client will only call actions on their own data. An authenticated user is not the same as an authorized user.
This is where I see the most dangerous patterns. Developers write:
// ❌ WRONG: Only checks if user exists
export async function updateUserProfile(
userId: string,
name: string
) {
const session = await getSession();
if (!session) {
throw new Error('Not authenticated');
}
// Updates ANY user's profile because we don't check ownership
await db.user.update({
where: { id: userId },
data: { name }
});
}Someone with a valid session could update any other user's profile by changing the `userId` parameter. The action only verified authentication, not authorization. I caught this in a ride-hailing platform where riders could update driver profiles because we made this exact mistake.
The fix is explicit authorization checks:
// ✅ CORRECT: Verifies ownership
export async function updateUserProfile(
userId: string,
name: string
) {
const session = await getSession();
if (!session) {
throw new Error('Not authenticated');
}
// Verify the user can only update their own profile
if (session.userId !== userId) {
throw new Error('Not authorized');
}
await db.user.update({
where: { id: userId },
data: { name }
});
}Always ask: "Can this user perform this action on this resource?" not just "Is this user logged in?"
Never trust hidden form fields
Hidden form fields are data you don't display to the user. You might think they're safe because you set them server-side. They're not. An attacker sees them in the HTML source and can modify them in DevTools before submitting.
I've seen this in a CRM we built. We had a form for creating support tickets with a hidden `departmentId` field. The idea was to prevent users from creating tickets in the wrong department:
// ❌ WRONG: Hidden field is visible and modifiable
export default function CreateTicketForm() {
return (
Create Ticket
);
}
// Action trusts the departmentId from the form
export async function createTicket(formData: FormData) {
const departmentId = formData.get('departmentId');
await db.ticket.create({
data: {
departmentId, // User can change this to any department
title: formData.get('title')
}
});
}An attacker opens DevTools, inspects the hidden field, changes it from "sales" to "executive", and creates tickets in the wrong queue. This is trivial to exploit.
Instead, derive sensitive values from the session or URL path:
// ✅ CORRECT: Department from session/route
export default function CreateTicketForm() {
return (
Create Ticket
);
}
// Get department from session, not from form data
export async function createTicket(formData: FormData) {
const session = await getSession();
if (!session) {
throw new Error('Not authenticated');
}
const userDepartment = await db.user.findUnique({
where: { id: session.userId },
select: { department: true }
});
if (!userDepartment) {
throw new Error('User has no department');
}
await db.ticket.create({
data: {
departmentId: userDepartment.department, // From verified session
title: formData.get('title')
}
});
}Any data that determines access or affects business logic must come from your server's trusted state, not from form submission.
Rate limit sensitive actions
A Server Action without rate limiting is an open invitation to abuse. Password reset endpoints get hammered. Payment actions get retried thousands of times. Invite sending gets exploited for spam. You need to know how many times a given user or IP performed an action in a time window.
I built a trading platform where we didn't rate-limit order placement. Someone wrote a bot that placed and cancelled orders thousands of times per minute, consuming our database connections and making the platform unusable for everyone else. We learned rate limiting the hard way.
Here's a practical pattern using Redis:
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '1 h'),
analytics: true
});
// Rate limit password resets to 5 per hour per user
export async function requestPasswordReset(email: string) {
const { success } = await ratelimit.limit(`password-reset:${email}`);
if (!success) {
// Don't reveal how many attempts they had
return {
error: 'Too many requests. Try again later.',
success: false
};
}
const user = await db.user.findUnique({
where: { email }
});
if (!user) {
// Still return success to prevent user enumeration
return { success: true };
}
await sendPasswordResetEmail(user.email);
return { success: true };
}Notice we don't differentiate whether the user exists when rate limited. That prevents attackers from using the rate limit response to enumerate valid email addresses. We rate limit by identifier (email, user ID, IP) depending on the action sensitivity.
Common mistakes
- Assuming authentication equals authorization: Checking `if (session)` is not enough. You need `if (session.userId === targetId)` for ownership-based actions.
- Validating only the types, not the values: TypeScript tells you `userId` is a string, but Zod confirms it's a valid UUID and not a SQL injection attempt.
- Skipping validation on "internal-only" fields: If it comes from the client, validate it. Period. Doesn't matter if you generated it in the same request.
- Forgetting to rate limit write operations: Reads can be cached. Writes should be rate limited—especially password resets, payments, and invites.
- Logging sensitive data in errors: A Server Action error that returns the database error message is an information leak. Log internally, return generic messages to the client.
- Using the user's input as database IDs without normalization: Even with validation, normalize IDs. A UUID might come in uppercase—trim and lowercase it before querying.
Key takeaways
- Server Actions are public HTTP endpoints. Every input is untrusted and modifiable by an attacker.
- Use Zod schemas to validate the shape and range of every parameter before processing.
- Separate authentication (is this user logged in?) from authorization (can they do this on this resource?).
- Never trust hidden form fields or any client-side state for security-critical decisions. Derive sensitive values from your server's verified session.
- Rate limit any action that's expensive or abusable—password resets, payments, invites, file uploads.
- Treat each Server Action as you'd treat a REST API endpoint. Apply the same security rigor you'd use for `/api` routes.






Comments
Loading comments…