- Published on
Next.js 13 Server Actions Security Vulnerabilities
Server Actions introduced in Next.js 13 represent a major innovation, but they also bring new attack vectors that are often overlooked. Unlike traditional API routes, these actions create implicit HTTP endpoints with specific security considerations.
1. Lack of Native CSRF Protection
The Problem
Server Actions don’t implement any CSRF (Cross-Site Request Forgery) protection by default, contrary to what many developers assume.
// ❌ Vulnerable to CSRF attacks
export async function deleteUser(formData) {
const userId = formData.get('userId');
await db.user.delete({ where: { id: userId } });
}
The Attack
A malicious site can execute actions on your application:
<!-- Malicious site -->
<form action="https://your-app.com" method="POST">
<input type="hidden" name="userId" value="123">
<input type="hidden" name="$ACTION_ID" value="generated_action_id">
<button type="submit">Click here to win!</button>
</form>
Solutions
// ✅ Manual CSRF protection
import { headers } from 'next/headers';
export async function deleteUser(formData) {
const headersList = headers();
const referer = headersList.get('referer');
const origin = headersList.get('origin');
// Verify origin
if (!origin || !origin.includes(process.env.ALLOWED_ORIGIN)) {
throw new Error('Unauthorized');
}
// Verify CSRF token
const csrfToken = formData.get('_token');
if (!validateCSRFToken(csrfToken)) {
throw new Error('Invalid CSRF token');
}
const userId = formData.get('userId');
await db.user.delete({ where: { id: userId } });
}
2. Data Injection via FormData
The Problem
Server Actions receive FormData directly without automatic validation, opening the door to injections.
// ❌ Vulnerable to injections
export async function createPost(formData) {
const title = formData.get('title');
const content = formData.get('content');
// Potential SQL injection
await db.$executeRaw`INSERT INTO posts (title, content) VALUES (${title}, ${content})`;
}
The Attack
FormData field manipulation:
// Malicious payload injected client-side
const formData = new FormData();
formData.append('title', "'; DROP TABLE posts; --");
formData.append('content', 'Innocent content');
// Adding unexpected fields
formData.append('isAdmin', 'true');
formData.append('role', 'super_admin');
Solutions
// ✅ Strict validation with Zod
import { z } from 'zod';
const PostSchema = z.object({
title: z.string().min(1).max(100).trim(),
content: z.string().min(10).max(5000).trim(),
});
export async function createPost(formData) {
// Strict data validation
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
};
const validatedData = PostSchema.parse(rawData);
// Using parameterized queries
await db.post.create({
data: validatedData
});
}
3. Accidental Exposure of Sensitive Data
The Problem
Server Actions automatically serialize their return value to the client, potentially exposing sensitive data.
// ❌ Sensitive data exposure
export async function getUserProfile(userId) {
const user = await db.user.findUnique({
where: { id: userId },
include: {
profile: true,
internalNotes: true, // ⚠️ Sensitive data
apiKeys: true, // ⚠️ Exposed secrets
}
});
return user; // Everything is sent to the client!
}
The Attack
Data inspection in DevTools:
// In browser console
console.log(window.__NEXT_DATA__); // Contains all returned data
Solutions
// ✅ Explicit field selection
export async function getUserProfile(userId) {
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
profile: {
select: {
bio: true,
avatar: true,
// No internalNotes or apiKeys
}
}
}
});
return {
id: user.id,
email: user.email,
name: user.name,
bio: user.profile?.bio,
avatar: user.profile?.avatar,
};
}
4. Lack of Rate Limiting
The Problem
No native protection against denial-of-service attacks or spam.
// ❌ No rate limiting
export async function sendEmail(formData) {
const email = formData.get('email');
const message = formData.get('message');
await emailService.send({
to: email,
subject: 'Contact Form',
body: message
});
}
The Attack
Automated spam:
// Attack script
for (let i = 0; i < 1000; i++) {
fetch('/contact', {
method: 'POST',
body: new FormData(document.querySelector('form'))
});
}
Solutions
// ✅ Rate limiting with Redis
import { rateLimit } from '@/lib/rate-limit';
export async function sendEmail(formData) {
const ip = headers().get('x-forwarded-for') || 'unknown';
// Limit: 5 emails per hour per IP
const { success } = await rateLimit({
id: `email_${ip}`,
limit: 5,
window: 3600000, // 1 hour
});
if (!success) {
throw new Error('Too many requests');
}
const email = formData.get('email');
const message = formData.get('message');
await emailService.send({
to: email,
subject: 'Contact Form',
body: message
});
}
5. Authorization Bypass
The Problem
Server Actions can be called directly, bypassing page authorization checks.
// ❌ No authorization checks
export async function deletePost(postId) {
await db.post.delete({
where: { id: postId }
});
}
The Attack
Direct action call:
// From any page in the app
const formData = new FormData();
formData.append('postId', '123');
fetch('/', {
method: 'POST',
headers: {
'Next-Action': 'action_id_extracted_from_html'
},
body: formData
});
Solutions
// ✅ Systematic authorization checks
import { auth } from '@/lib/auth';
export async function deletePost(postId) {
const session = await auth();
if (!session?.user) {
throw new Error('Unauthorized');
}
// Check that user can delete this post
const post = await db.post.findUnique({
where: { id: postId },
select: { authorId: true }
});
if (!post || post.authorId !== session.user.id) {
throw new Error('Forbidden');
}
await db.post.delete({
where: { id: postId }
});
}
6. Information-Leaking Error Handling
The Problem
Unhandled errors can expose the application’s internal structure.
// ❌ Errors that expose too much information
export async function processPayment(formData) {
const amount = formData.get('amount');
// In case of error, the complete stack trace is sent
const payment = await stripe.paymentIntents.create({
amount: amount * 100,
currency: 'eur',
automatic_payment_methods: {
enabled: true,
},
});
return payment;
}
Solutions
// ✅ Secure error handling
export async function processPayment(formData) {
try {
const amount = parseFloat(formData.get('amount'));
if (isNaN(amount) || amount <= 0) {
return { error: 'Invalid amount' };
}
const payment = await stripe.paymentIntents.create({
amount: amount * 100,
currency: 'eur',
automatic_payment_methods: {
enabled: true,
},
});
return { success: true, paymentId: payment.id };
} catch (error) {
// Complete log server-side
console.error('Payment error:', error);
// Generic error client-side
return { error: 'Payment processing failed' };
}
}
Security Best Practices
1. Centralized Security Middleware
// /lib/secure-action.js
export function secureAction(handler) {
return async function(formData) {
// Common checks
await checkCSRF(formData);
await checkRateLimit();
await checkAuth();
try {
return await handler(formData);
} catch (error) {
logError(error);
throw new Error('Operation failed');
}
};
}
2. Systematic Validation
// Always validate inputs
const schema = z.object({
email: z.string().email(),
amount: z.number().positive().max(10000)
});
const data = schema.parse(formDataToObject(formData));
3. Principle of Least Privilege
// Return only necessary data
return {
success: true,
id: result.id,
// No other sensitive properties
};
Conclusion
Server Actions offer an exceptional developer experience but require particular vigilance regarding security. Each action should be treated as a classic HTTP endpoint with all the security implications that entails.
Security is not optional: implement these protections from the beginning of your projects to avoid vulnerabilities in production.