Here's a pattern you'll find in a lot of Next.js apps, in app/admin/page.tsx:

import { redirect } from 'next/navigation' import { auth } from '@/auth' import { db } from '@/lib/db' export default async function AdminPage() { const session = await auth() if (!session) redirect('/login') async function deleteAll() { 'use server' await db.post.deleteMany() } return <form action={deleteAll}><button>Delete</button></form> } 

Logged-out users get redirected and never see the button. It looks safe. It isn't.

Why the page's check doesn't protect the action

The redirect only decides what the page renders. A Server Action is its own endpoint: submitting the form sends a POST request that runs deleteAll, and Next.js doesn't go through the page's code first. The redirect never gets a chance to stop it.

The Next.js data security guide says it plainly:

A page-level authentication check does not extend to the Server Actions defined within it.

And a bit further:

you should still treat Server Actions as reachable via direct POST requests and verify authentication and authorization inside each one.

The fix

Check the session inside the action itself:

async function deleteAll() { 'use server' const session = await auth() if (!session?.user) throw new Error('Unauthorized') await db.post.deleteMany() } 

For a real admin action, check the role too. Being logged in is not the same as being allowed to delete everything.

Why it slips through

The fix is easy. Seeing the bug is the hard part. Nothing fails: the page works, the tests pass, the button is hidden. And in a real app, actions are spread across dozens of files: "use server" modules imported by client components, inline actions in pages, action clients, your own helpers like requireUser(). Reviewing them one by one is slow, and it has to be done again on every pull request.

So I wrote server-action-audit, a small open-source CLI that lists every Server Action in a project and flags the ones without an authorization check. It reads your code and never runs it.

npx server-action-audit 

On the page above:

app/admin/page.tsx ✗ deleteAll 9:3 no auth check ✗ 1 unprotected · ✓ 0 protected · ○ 0 public · ! 0 with unvalidated input 

After the fix:

app/admin/page.tsx ✓ deleteAll 9:3 auth() 

What it understands

  • Auth libraries: Auth.js / NextAuth, Clerk, Supabase, Better Auth, Lucia, Kinde, iron-session, Firebase Admin and WorkOS AuthKit.
  • Your own helpers: getCurrentUser(), verifySession()… It follows them across files, through tsconfig aliases like @/lib/dal, re-exports and React cache().
  • Action clients: next-safe-action and zsa, including their middleware.
  • Public actions: login, sign-up and newsletter actions are meant to be public. Mark them with a comment:
// Anyone can subscribe. @public-action export async function subscribe(email: string) { // … } 

It also warns when an action uses its arguments without validating them (Zod, Valibot, Yup, or an action client schema), because those arguments come straight from the client.

I ran it on five large open-source Next.js apps: 456 Server Actions, about a second per app.

In your editor and in CI

There's an ESLint plugin (ESLint 9 or 10, flat config):

// eslint.config.mjs import serverActionAudit from 'server-action-audit/eslint' export default [ // …your config serverActionAudit.configs.recommended, ] 

And a GitHub Action that shows the problems as annotations on the pull request, right on the line of the action:

steps: - uses: actions/checkout@v7 - uses: i6moons/server-action-audit@v0 with: path: apps/web # the folder of your Next.js app, "." by default comment: true # also post the report on the PR (needs pull-requests: write) 

The CLI exits with code 1 when it finds an unprotected action, so it works in any CI. There's also a SARIF output for GitHub code scanning.

What it doesn't check

It's a static check based on names. Treat a clean report as "nothing obviously missing", not as proof that your app is secure.

  • await auth() counts as a check, even if you then ignore the result.
  • It doesn't check roles or ownership: being logged in is not the same as being allowed to delete this post.
  • Middleware doesn't count. The Next.js docs recommend checking inside each action, and middleware-only protection has been bypassed before (CVE-2025-29927).
  • Unusual patterns can slip through: dynamic imports, or auth checks hidden in packages from node_modules.

Try it

npx server-action-audit 

If it gets your project wrong, with a false positive or a missed action, I'd really like to know. Open an issue with the smallest snippet that reproduces it.