Auth inside every action

A Server Action is a public endpoint. Check auth inside each one.

Next.js Docsnextjs.org
Treat Server Actions with the same security considerations as public-facing API endpoints, and verify if the user is allowed to perform a mutation.
Guides: AuthenticationRead docs ↗

From vercel-react-best-practices, rule server-auth-actions.

Signed in as

Page guarded, action exposed

As Alice, send the POST anyway. Then sign out.

No check insideBad
await db.user.delete({ where: { id: userId } })
verifySession() insideGood
verifySession() → role check → delete
'use server'

import { verifySession } from '@/lib/auth'
import { unauthorized } from '@/lib/errors'

export async function deleteUser(userId: string) {
  const session = await verifySession()
  if (!session) throw unauthorized('Must be logged in')

  if (session.user.role !== 'admin' && session.user.id !== userId) {
    throw unauthorized('Cannot delete other users')
  }

  await db.user.delete({ where: { id: userId } })
  return { success: true }
}

Who can delete whom

Pick an implementation. Click a cell to trace it.

deleteUser checks

Caller × targetPolicy: admin, or yourself
Result of deleteUser for each caller and target
CallerAlice's idBob's id

TracePick a cell

      

Validate, auth, then mutate

Pick a preset or edit the form, then run.

Straight to the DBBad
db.user.update({ where: { id: data.userId }, data })
Four gates, in orderGood
parse → verifySession → owner check → update
'use server'

import { verifySession } from '@/lib/auth'
import { z } from 'zod'

const updateProfileSchema = z.object({
  userId: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email()
})

export async function updateProfile(data: unknown) {
  const validated = updateProfileSchema.parse(data)      // 1. validate

  const session = await verifySession()                   // 2. authenticate
  if (!session) throw new Error('Unauthorized')

  if (session.user.id !== validated.userId) {             // 3. authorize
    throw new Error('Can only update own profile')
  }

  await db.user.update({                                  // 4. mutate
    where: { id: validated.userId },
    data: { name: validated.name, email: validated.email }
  })
  return { success: true }
}

Why it matters, pros and cons

Why it matters

Anyone can POST to an action with any arguments. Only an inside check always runs.

Pros

  • Holds against scripts and replayed requests.
  • Auth sits next to the mutation it guards.
  • Bad input is rejected before any lookup.

Cons

  • One session lookup per call; cache with React.cache().
  • Repeated boilerplate; wrap it in one helper.
  • Public actions skip auth but still need validation.