Server Action Security
Server Actions execute on the server but are triggered from the client. This creates unique security considerations that you must address.
The Security Model
Server Actions are exposed as HTTP endpoints. Anyone can call them:
// ❌ DANGEROUS: No authentication check
'use server'
export async function deleteUser(userId: string) {
await db.users.delete({ where: { id: userId } })
}
An attacker could call this action directly. Always verify authorization.
Authentication in Server Actions
// app/actions/user.ts
'use server'
import { cookies } from 'next/headers'
import { verifyToken } from '@/lib/auth'
export async function updateProfile(formData: FormData) {
// Always verify the user first
const cookieStore = await cookies()
const token = cookieStore.get('token')?.value
if (!token) {
throw new Error('Unauthorized')
}
const user = await verifyToken(token)
if (!user) {
throw new Error('Invalid session')
}
// Now safe to proceed
const name = formData.get('name') as string
await db.users.update({
where: { id: user.id },
data: { name },
})
return { success: true }
}
Authorization: Ownership Checks
Verify the user owns the resource:
'use server'
export async function deletePost(postId: string) {
const user = await getAuthenticatedUser()
// Check ownership
const post = await db.posts.findUnique({
where: { id: postId },
select: { authorId: true },
})
if (!post) {
throw new Error('Post not found')
}
if (post.authorId !== user.id) {
throw new Error('Forbidden: You do not own this post')
}
await db.posts.delete({ where: { id: postId } })
}
Input Validation
Never trust client input. Validate everything:
'use server'
import { z } from 'zod'
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1).max(50000),
published: z.boolean().optional(),
})
export async function createPost(formData: FormData) {
const user = await getAuthenticatedUser()
// Validate input
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'true',
}
const result = createPostSchema.safeParse(rawData)
if (!result.success) {
return {
error: 'Invalid input',
details: result.error.flatten(),
}
}
const { title, content, published } = result.data
await db.posts.create({
data: {
title,
content,
published,
authorId: user.id,
},
})
return { success: true }
}
Rate Limiting
Prevent abuse with rate limiting:
'use server'
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
import { headers } from 'next/headers'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
})
export async function sensitiveAction(data: FormData) {
const headersList = await headers()
const ip = headersList.get('x-forwarded-for') ?? '127.0.0.1'
const { success, remaining } = await ratelimit.limit(ip)
if (!success) {
throw new Error('Too many requests. Please try again later.')
}
// Proceed with action
}
Protecting Against CSRF
Server Actions have built-in CSRF protection through:
- Origin header validation
- Same-origin request verification
But for extra-sensitive actions, add additional checks:
'use server'
import { headers } from 'next/headers'
export async function deleteAccount() {
const headersList = await headers()
const origin = headersList.get('origin')
const host = headersList.get('host')
// Verify same origin
if (!origin || !origin.includes(host!)) {
throw new Error('Invalid request origin')
}
// Proceed with deletion
}
Return Safe Data
Never return sensitive data in responses:
// ❌ Leaking sensitive data
export async function getUser(userId: string) {
const user = await db.users.findUnique({ where: { id: userId } })
return user // Includes password hash, internal fields, etc.
}
// ✅ Return only what's needed
export async function getUser(userId: string) {
const user = await db.users.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
email: true,
avatar: true,
},
})
return user
}
Security Checklist
- Authentication: Verify the user in every action
- Authorization: Check ownership/permissions for resources
- Input validation: Use Zod or similar for all inputs
- Rate limiting: Prevent brute force and abuse
- Error handling: Don't leak internal error details
- Return data: Only return necessary, non-sensitive fields
Summary
- Server Actions are HTTP endpoints - always verify auth
- Check ownership before modifying/deleting resources
- Validate all input with a schema library
- Implement rate limiting for sensitive actions
- Never return sensitive data in responses
- Think of Server Actions as public APIs that need protection

