Next.js server actions are public endpoints
A server action looks like a function call. You put 'use server' at the top of a file, export an async function and pass it to a form or a button. There is no route file, no fetch and no URL to think about. That is the appeal, and it is also the trap.
Every server action is an HTTP endpoint. The browser calls it with a POST request that names the action, and the server runs your function with whatever arguments that request carries. The Next.js data security guide says an exported action is reachable "via a direct POST request, not just through your application's UI." Anyone who can send a POST can call it, with arguments they choose.
So every action has to answer the questions every API endpoint answers: who is calling, and may they do this to this record?
What the framework does, and what it leaves to you
Next.js hardens actions in several ways. Per the same guide, action ids are encrypted and non-deterministic and are recalculated between builds, actions that nothing uses are removed from the client bundle, variables an action closes over are encrypted, and the server compares the Origin header with the Host header to stop most cross-site form posts.
None of that is authorization. An action id ships in the page your users already load. Every logged-in user has it, and on a public page so does every anonymous visitor. The guide is clear that the hardening only reduces risk when an auth layer is missing, and that you should verify authentication and authorization inside each action. The authentication guide asks you to treat actions with "the same security considerations as public-facing API endpoints."
Two everyday patterns break that rule without looking wrong.
The page checks, the action does not
// app/admin/users/page.tsx
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
export default async function UsersPage() {
const session = await auth()
if (!session?.user?.isAdmin) redirect('/login')
async function deleteUser(formData: FormData) {
'use server'
await db.user.delete({ where: { id: String(formData.get('id')) } })
}
return <form action={deleteUser}>{/* ... */}</form>
}
The redirect decides who sees the button. It does not protect the action. The action is a separate entry point, and the Next.js docs say so directly: a page-level check does not extend to the actions defined inside the page. A non-admin who gets hold of the action id can post to it and delete any user.
The fix is to check again where the work happens:
async function deleteUser(formData: FormData) {
'use server'
const session = await auth()
if (!session?.user?.isAdmin) throw new Error('Forbidden')
await db.user.delete({ where: { id: String(formData.get('id')) } })
}
Signed in, but not scoped
// app/invoices/actions.ts
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
export async function markPaid(invoiceId: string) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
await db.invoice.update({ where: { id: invoiceId }, data: { paid: true } })
}
This one checks the session, so it passes a quick review. But invoiceId comes from the request, and nothing ties the invoice to the caller. Any signed-in user can mark any invoice paid by sending a different id. That is the insecure direct object reference we wrote about in broken access control is still number one, moved into a function that does not look like an endpoint.
The fix scopes the write to the session:
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
export async function markPaid(invoiceId: string) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const { count } = await db.invoice.updateMany({
where: { id: invoiceId, tenantId: session.user.tenantId },
data: { paid: true },
})
if (count === 0) throw new Error('Not found')
}
TypeScript types do not help here either. invoiceId: string is a compile-time promise. At runtime the argument is whatever the request sent, so validate it with a schema before you use it.
Why middleware alone is fragile
Many Next.js apps put authentication in middleware.ts: a matcher lists the protected paths, the function reads a session cookie and redirects anyone without one. As a first filter that is fine. As the only check it has failed in three ways.
It can be skipped. In March 2025 the Next.js team published GHSA-f82v-jwr5-mffw, tracked as CVE-2025-29927 and rated 9.1: authorization checks that ran in middleware could be bypassed. Vercel's postmortem explains the mechanism. Next.js used an internal header, x-middleware-subrequest, to stop middleware from calling itself in a loop, and a request that arrived from outside with that header could make the server skip middleware entirely. Self-hosted apps running next start or the standalone output were affected; the fixes shipped in 12.3.5, 13.5.9, 14.2.25 and 15.2.3. The postmortem's own advice is not to rely on middleware as the only way to protect a route.
It drifts from the routes. A matcher is a list of path patterns kept apart from the code it protects. A new route under a path nobody added to the list is public, and nothing fails. The example matcher in the Next.js authentication guide itself excludes /api, which suits optimistic page redirects but means every route handler under /api gets no check from it.
It was never meant to be the check. Next.js 16 renamed middleware to Proxy, and its documentation describes it as the place for optimistic checks that read the cookie, not the database. The guide says the majority of security checks belong as close to the data as possible.
A route handler that does not depend on the matcher:
// app/api/invoices/[id]/route.ts
import { verifySession } from '@/lib/dal'
import { db } from '@/lib/db'
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await verifySession()
if (!session) return new Response(null, { status: 401 })
const { id } = await params
const invoice = await db.invoice.findFirst({
where: { id, tenantId: session.tenantId },
})
if (!invoice) return new Response(null, { status: 404 })
return Response.json(invoice)
}
Keep the middleware or Proxy redirect for user experience. Put the decision in the handler.
A short checklist for every action
- Read the session first and deny early. Do not rely on the page, the layout or the matcher having done it.
- Scope every lookup and every write to the caller. Owner or tenant id from the session, never only the id from the arguments.
- Validate the arguments. They are request data, whatever their declared types.
- Return only what the UI needs. Action return values are serialised to the client.
- Keep the checks in one place. A server-only data access layer, as the Next.js guide recommends, means an action cannot forget them.
- Patch
next. If you are still on a version in the CVE-2025-29927 range, upgrade before anything else.
What Vulkro reports
Since Vulkro 0.30.0, vulkro scan treats Next.js route handlers, pages, server actions and middleware matchers as entry points. Each endpoint names the function that handles it and the guards that protect it, and severity follows who can reach the code, so an unguarded action on a public page ranks above the same gap behind an admin login. Server action arguments are treated as untrusted input, so injection that reaches a query or a shell from an action is reported too.
Two checks cover the shapes above:
nextjs-server-action-no-authz. A server action that writes to the data store, or looks up a record keyed by its own argument, with no session or authorization check in front of it. The first example in this post is the textbook case: the page checks, the action does not.nextjs-middleware-only-auth. A route handler whose only protection is the middleware matcher while the installednextis in the CVE-2025-29927 range, or a route that sits outside the matcher of a project that authenticates through middleware and reads or writes data with no check of its own. The finding names the line that touches the data, and the fix it suggests always includes a session check in the handler itself, plus an upgrade ofnextor a matcher entry, whichever applies.
The ownership question, where a caller is signed in but the record is not scoped to them, is the job of the authorization check idor-authn-no-ownership, which reports lookups by a caller-supplied id with no ownership or tenant check.
These checks are structural. They tell you which guard is missing on which entry point. They do not claim an exploit, and an app that enforces access somewhere Vulkro cannot see (a database policy, a gateway in front of the app) will need a triage decision rather than a code change. When Vulkro stays quiet and you expected a finding, vulkro why-not <file:line> says why: no entry point reaches the line, a guard applies, it was suppressed, or it fell below the confidence floor. More on that in why did the scanner not flag this.
Where to see it
Run vulkro scan . in a Next.js project, or open it in the VS Code extension to see these findings on the line as you write the action. The JavaScript and TypeScript page lists the frameworks and entry points Vulkro reads, and attack paths explains how an unguarded entry point is joined to what it can reach. To get set up, start here.
Sources
- Next.js, How to think about data security in Next.js
- Next.js, How to implement authentication in Next.js
- Next.js, GHSA-f82v-jwr5-mffw: Authorization Bypass in Next.js Middleware, March 2025
- Vercel, Postmortem on Next.js Middleware bypass, March 2025
