Securing Server Actions: Auth Checks and Input Validation

Why every Server Action must re-check authentication, ownership, and input, and what the framework already protects on its own.

7 min read

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.

index.tsindex.ts
// 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.jsYour responsibility
POST only, plus an origin CSRF checkAuthenticate the caller
Encrypted action IDs and dead code eliminationCheck ownership of each resource
1MB body size limitValidate 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.

index.tsindex.ts
// 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.

index.tsindex.ts
// 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

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.
RunePowered by Rune AI

Frequently Asked Questions

Is rendering a form only on an authenticated page enough?

No. A page-level check controls the UI, but a Server Action is a separate POST entry point. Anyone who can construct the request can call it, so re-check auth inside the action.

What is the difference between authentication and authorization?

Authentication confirms who the user is. Authorization confirms they may act on the specific resource, such as a post they own. Missing authorization causes IDOR vulnerabilities.

Does Next.js protect Server Actions automatically?

Partially. Next.js enforces POST-only, an origin CSRF check, a 1MB body limit, and encrypted action IDs. It does not authenticate the caller or validate input, which stays your responsibility.

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.