Server Action Security

+15 Mana ✨

Server Actions are public HTTP endpoints. Treat them with the same security considerations as API routes.

Authentication & Authorization

Always verify the user has permission:

tsx
'use server';

import { auth } from './auth';
import { redirect } from 'next/navigation';

export async function deletePost(postId: string) {
  // 1. Authenticate
  const session = await auth();
  if (!session) {
    redirect('/login');
  }
  
  // 2. Authorize
  const post = await db.posts.findUnique({ where: { id: postId } });
  if (post.authorId !== session.user.id) {
    throw new Error('Not authorized');
  }
  
  // 3. Perform action
  await db.posts.delete({ where: { id: postId } });
  revalidatePath('/posts');
}

Input Validation

Never trust client input:

tsx
'use server';

import { z } from 'zod';

const CreatePostSchema = z.object({
  title: z.string().min(1).max(100),
  content: z.string().min(1).max(10000),
  categoryId: z.string().uuid()
});

export async function createPost(formData: FormData) {
  // Validate all input
  const result = CreatePostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
    categoryId: formData.get('categoryId')
  });
  
  if (!result.success) {
    return { error: result.error.flatten() };
  }
  
  const { title, content, categoryId } = result.data;
  
  // Verify categoryId exists (don't trust client)
  const category = await db.categories.findUnique({
    where: { id: categoryId }
  });
  
  if (!category) {
    return { error: 'Invalid category' };
  }
  
  await db.posts.create({ data: { title, content, categoryId } });
}

Closure Security

Be careful with closures - they serialize values:

tsx
// ⚠️ Dangerous - userId could be tampered with
async function UserProfile({ userId }) {
  async function deleteAccount() {
    'use server';
    // userId is serialized and sent to client!
    await db.users.delete({ where: { id: userId } });
  }
  
  return <button formAction={deleteAccount}>Delete</button>;
}

// ✅ Safe - verify on server
async function UserProfile() {
  async function deleteAccount() {
    'use server';
    const session = await auth();
    if (!session) throw new Error('Not authenticated');
    
    // Use authenticated user's ID, not a closure value
    await db.users.delete({ where: { id: session.user.id } });
  }
  
  return <button formAction={deleteAccount}>Delete</button>;
}

Rate Limiting

Protect against abuse:

tsx
'use server';

import { rateLimit } from './rate-limit';
import { headers } from 'next/headers';

export async function submitForm(formData: FormData) {
  const ip = headers().get('x-forwarded-for') ?? 'unknown';
  
  const { success } = await rateLimit.limit(ip);
  if (!success) {
    return { error: 'Too many requests. Please try again later.' };
  }
  
  // Process form...
}

CSRF Protection

Next.js automatically includes CSRF protection for Server Actions. The action can only be called from your own site.

Sensitive Data

Never return sensitive data:

tsx
'use server';

// ❌ Bad - returns sensitive data
export async function getUser(id: string) {
  return await db.users.findUnique({ where: { id } });
  // Might include password hash, internal IDs, etc.
}

// ✅ Good - select only needed fields
export async function getUser(id: string) {
  return await db.users.findUnique({
    where: { id },
    select: {
      id: true,
      name: true,
      email: true,
      avatar: true
    }
  });
}
✓ Completed