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.

Last updated
© 2025, Devpulsion.
Exposio 1.0.0#82a06ca | About
Next.js 13 Server Actions Security Vulnerabilities | Devpulsion