Securing Server Actions starts from one fact: every action is reachable as a direct POST request, not only through your UI. Authentication, authorization, and input validation all have to live inside the action itself.
The page that renders the form is not a security boundary. A form hidden behind a login still exposes its action to a crafted request, so the checks cannot depend on which UI is visible.
Here is the minimum safe shape. The action confirms who is calling, then confirms they may touch this specific resource, before it writes.
// app/actions.ts
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
export async function deletePost(postId: string) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const post = await db.post.findUnique({ where: { id: postId } })
if (post?.authorId !== session.user.id) throw new Error('Forbidden')
await db.post.delete({ where: { id: postId } })
}The auth and db imports are your own project modules. The two checks are the whole point: the first blocks anonymous callers, the second blocks a logged-in user deleting someone else's post.
Note the optional chaining on post?.authorId. A missing row would otherwise crash on a property read, and this way an unknown id fails as Forbidden like any other.
What the framework already protects
Next.js handles the transport-level risks for you, but none of these replace application checks.
| Automatic in Next.js | Your responsibility |
|---|---|
| POST only, plus an origin CSRF check | Authenticate the caller |
| Encrypted action IDs and dead code elimination | Check ownership of each resource |
| 1MB body size limit | Validate and sanitize all input |
See how Next.js Server Actions work under the hood for what each automatic protection does.
The split is worth stating plainly. The framework stops the transport attack, but it cannot know which users may act on which rows. Only your code knows that.
Re-check authentication inside the action
A page that redirects unauthenticated visitors still exposes its Server Actions to anyone who sends the POST directly. The redirect controls which UI renders, nothing more. Re-verify the session inside every action that changes data.
// app/admin/actions.ts
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
export async function deleteAllRecords() {
const session = await auth()
if (!session?.user?.isAdmin) throw new Error('Unauthorized')
await db.record.deleteMany()
}If this check lived only on the page, a crafted POST would still delete every record. The action is the entry point, so the action carries the check.
Read the session inside the action, not from a prop or a token the client passed in. Throw as soon as the check fails, before any database work starts, so a failed check costs nothing and leaves no partial write behind.
Check authorization, not just authentication
Authentication tells you who the caller is. Authorization tells you whether that caller may act on this resource. The deletePost example above does both, and the second check is the one that stops an insecure direct object reference, where a caller guesses another row's id.
Send an id from the client, then re-read ownership from the session and the database. Never trust an entire object supplied by the client, because a well-formed record can still name a row the caller does not own.
The deletePost example fails with Forbidden before any write when the caller does not own the post. Moving that check into a data access layer marked with import 'server-only' means every caller gets the same guard, and the action itself stays thin.
Validate every input
FormData, params, and searchParams are all attacker-controlled. Validate their shape, length, and type before using them, and decide what a valid value looks like before you read it.
Never use them as a source of truth for privileges. A value like isAdmin in the query string is a request, not a fact, so re-verify the real answer against the session every time.
Folders with brackets count as user input too, so treat a route param the same way you treat a form field.
For a schema-based approach that returns field errors, see server-side form validation with Zod. A check like "is this id numeric" belongs in the action, not only in the form.
Constrain what you return
Action return values are serialized and sent to the client. Return a small success shape, never the raw database record.
The client only needs confirmation and, at most, the fields it renders. A bare success flag is often the right answer, and it gives an attacker nothing to inspect.
// app/actions.ts
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
export async function updateName(formData: FormData) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
await db.user.update({ where: { id: session.user.id }, data: { name: String(formData.get('name')) } })
return { success: true }
}Returning the whole user row would send password hashes, emails, and roles straight to the browser, where anyone can read them in the response.
The same rule applies to what you pass into Client Components, so shaping the data once in a data access layer covers both paths. When an action appears to do nothing, see debugging Server Actions that silently do nothing.
Rune AI
Key Insights
- Server Actions are reachable through direct POST requests.
- Re-check authentication inside every action, not only on the page.
- Check ownership of the resource to prevent IDOR.
- Validate FormData, params, and searchParams as untrusted input.
- Return a success flag, never a raw database record.
Frequently Asked Questions
Is rendering a form only on an authenticated page enough?
What is the difference between authentication and authorization?
Does Next.js protect Server Actions automatically?
Conclusion
Treat every Server Action as a public POST endpoint. Re-check authentication and resource ownership inside the action, validate all input, and return only what the UI needs. The framework guards the transport, but the trust decisions are yours.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.