Next.js Server Actions are one of the framework's most powerful features — they let you write server-side code directly in your React components, automatically creating POST endpoints under the hood. But this convenience comes with a hidden security risk that can expose sensitive data and enable account takeovers.
The Problem: Server Actions Are Public POST Endpoints
When you define a Server Action, Next.js creates a POST endpoint that any client can call — not just your own UI. This means an unauthenticated user can call your data mutation actions directly via curl or fetch, bypass client-side auth guards entirely, and access or mutate data they shouldn't have permission to touch.
// This server action is accessible to anyone
'use server'
export async function deletePost(postId: string) {
await db.posts.delete({ where: { id: postId } }) // No auth check!
}The endpoint URL is derived from the action hash, but it is publicly accessible to any HTTP client — not just your frontend.
Why This Happens
Next.js Server Actions don't add authentication by default. Developers often assume that because the action is "on the server," it's protected — but any HTTP client can call these endpoints directly. The framework treats them as server-side functions while exposing them as network-accessible routes.
This is a well-understood pattern in API design, but the implicit nature of Server Actions makes it easy to forget.
Solution 1: Inline Auth Checks
The most straightforward protection is checking authentication at the start of every action:
'use server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
export async function deletePost(postId: string) {
const session = await getServerSession(authOptions)
if (!session?.user) {
throw new Error('Unauthorized')
}
// Verify ownership too
const post = await db.posts.findUnique({ where: { id: postId } })
if (post?.authorId !== session.user.id) {
throw new Error('Forbidden')
}
await db.posts.delete({ where: { id: postId } })
}This is explicit but repetitive across many actions. A shared utility handles this better at scale.
Solution 2: Auth Wrapper Utility
Create a withAuth wrapper that enforces authentication for any action:
// lib/with-auth.ts
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
type ActionFn<T, R> = (user: User, input: T) => Promise<R>
export async function withAuth<T, R>(
action: ActionFn<T, R>,
input: T
): Promise<R> {
const session = await getServerSession(authOptions)
if (!session?.user) {
throw new Error('Unauthorized: Please sign in to continue.')
}
return action(session.user, input)
}Usage becomes clean and consistent:
'use server'
import { withAuth } from '@/lib/with-auth'
export async function deletePost(postId: string) {
return withAuth(async (user) => {
const post = await db.posts.findUnique({ where: { id: postId } })
if (post?.authorId !== user.id) throw new Error('Forbidden')
return db.posts.delete({ where: { id: postId } })
}, postId)
}Solution 3: Middleware-Level Protection
Next.js Middleware provides a centralized protection layer for entire route segments:
// middleware.ts
import { withAuth } from 'next-auth/middleware'
export default withAuth({
pages: {
signIn: '/auth/signin',
},
})
export const config = {
matcher: ['/dashboard/:path*'],
}Middleware is excellent for route-level gates but doesn't replace per-action auth checks for sensitive operations, since middleware runs before the action and can't enforce object-level permissions.
CSRF Considerations
Next.js 14 and later include built-in CSRF protection for Server Actions through Origin header validation. However, this protection only applies to same-origin calls and doesn't replace authentication. For apps that use cookie-based sessions, adding explicit CSRF tokens to sensitive mutations provides an additional layer of defense.
Audit Your App with Shipcheck
The @shipcheck/cli tool scans your Next.js project for unprotected server actions:
npx @shipcheck/cliIt identifies Server Action files that lack authentication checks and produces a report of potential vulnerabilities. Running this as part of your pre-deployment checklist catches gaps before they reach production.
Rate Limiting Sensitive Actions
Authentication alone isn't enough for high-stakes actions. Add rate limiting to prevent brute-force or abuse:
'use server'
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '1 m'),
})
export async function requestPasswordReset(email: string) {
const session = await getServerSession(authOptions)
const identifier = session?.user?.id ?? email
const result = await ratelimit.limit(identifier)
if (!result.success) {
throw new Error('Too many requests. Please try again later.')
}
// proceed with reset logic
}Production Security Checklist
Before deploying any Next.js app with Server Actions:
- Every action that reads or mutates data performs an auth check
- Object-level authorization ensures users only affect their own resources
- Rate limiting is applied to sensitive actions (password reset, payments, bulk ops)
- Error messages don't leak internal stack traces or database details
- Failed auth attempts are logged for monitoring and alerting
- CSRF protection is enabled and verified in staging
@shipcheck/clihas been run and findings addressed
The Right Mindset
Treat Server Actions the same way you'd treat REST API routes — because that's exactly what they are at the network layer. The developer experience of co-locating server logic with your components is genuinely powerful, but it doesn't make that code any less exposed to the public internet.
Explicit authentication at the action level, combined with middleware for route-level gates and rate limiting for high-risk operations, gives you defense in depth that protects your users no matter how an attacker tries to reach your server.
The pattern is the same one that's kept REST APIs secure for years: never trust a request, always verify the caller, and always scope permissions to what the authenticated user is actually allowed to do.