Next.js server actions are public endpoints
Every exported server action can be called directly. The Next.js docs say an exported server action “is reachable via a direct POST request, not just through your application’s UI,” so anyone who can reach your site can call it with arguments of their choosing. Auth in proxy.ts (formerly middleware.ts) or a redirect at the top of the page does not protect the action, so check the session and the caller’s permission on the resource inside every 'use server' function.
On this page9 sections
- Are Next.js server actions really public?
- What Next.js already does to protect server actions
- Why proxy (middleware) auth doesn’t cover server actions
- Reproduce it on your own app with curl
- The fix: check auth and ownership inside every action
- A quick audit for every “use server” file
- Patched versions for Next.js issues that touch server actions
- Where Shipmate fits
- Sources
Are Next.js server actions really public?
A server action is an HTTP endpoint that Next.js wires up for you. The Next.js data security guide puts it plainly: “even if a Server Action or utility function is not imported elsewhere in your code, it can still be called externally,” and you “should still treat Server Actions as reachable via direct POST requests and verify authentication and authorization inside each one.”
The confusion comes from how actions feel in code. You write async function deleteProject(id) next to a component, call it from a form or button, and it reads like a local function call. Under the hood, the browser sends a POST to the route where the action is used, with an identifier for the action and the serialized arguments. Whoever sends that request picks the arguments, whether it’s your own form or a script.
What Next.js already does to protect server actions
Next.js adds a few protections by default, and none of them checks who the caller is. According to the data security guide:
- Action IDs are encrypted and non-deterministic, and they are recalculated between builds. The guide says IDs are cached for a maximum of 14 days and regenerated on a new build or when the build cache is invalidated.
- Unused server actions are removed from the client bundle during
next build(dead code elimination), so an exported action that nothing calls doesn’t get a public ID. - Variables captured in a closure by an inline action are encrypted before they go to the client, with a new key generated per build. The guide adds that it doesn’t recommend “relying on encryption alone to prevent sensitive values from being exposed on the client.”
- Server actions only accept
POST, and Next.js compares theOriginheader with theHost(orX-Forwarded-Host) header and aborts the request if they don’t match. That protects against cross-site request forgery from other websites in a browser.
The guide describes the ID scheme as reducing “the risk in cases where an authentication layer is missing,” and still tells you to verify auth inside each action. A hard-to-guess ID only slows someone down until they find it, and any action your UI uses needs an ID the client can call, so anyone who loads that page can see it. The Origin check targets browsers on other sites. A script calling your server directly can send whatever headers it likes.
Why proxy (middleware) auth doesn’t cover server actions
Proxy runs in front of routes, and server actions ride along on whatever route they’re used from. The Next.js proxy docs say server functions “are handled as POST requests to the route where they are used, so a Proxy matcher that excludes a path will also skip Server Function calls on that path.” They add that “a matcher change or a refactor that moves a Server Function to a different route can silently remove Proxy coverage.”
There is also a history of proxy-level auth being bypassed outright. In March 2025, Next.js published GHSA-f82v-jwr5-mffw (CVE-2025-29927): “It is possible to bypass authorization checks within a Next.js application, if the authorization check occurs in middleware.” Fixed versions were 12.3.5, 13.5.9, 14.2.25 and 15.2.3. If an app’s only auth lived in middleware, that bug opened every route it protected, server actions included.
Page-level checks have the same gap. The data security guide shows an admin page that redirects non-admins, with an inline action that deletes records, and notes that “a page-level authentication check does not extend to the Server Actions defined within it.” The redirect controls which UI renders. The action is “a separate entry point and must verify the caller on its own.”
Next.js renamed middleware to proxy in v16.0.0, and the docs recommend avoiding it “unless no other options exist.” Use it for optimistic redirects if you like. Don’t let it be the only place auth happens.
Reproduce it on your own app with curl
Copy a real server action request from your browser, strip the session cookie, and replay it with curl: if the action still runs, it has no auth check of its own. Only do this against an app you own or have written permission to test.
- Run your app locally or open your staging site, log in, and open the browser’s DevTools Network tab.
- Trigger a server action from the UI, for example “delete project” or “update profile.”
- Find the POST request it made to the page’s route, right-click it, and choose Copy as cURL. The copied command already contains everything Next.js sent to identify the action and its arguments.
- Paste it into a terminal, delete the cookie header or
-boption that carries your session, and run it.
Here is the shape of that command with the noisy parts cut. Everything in angle brackets is a placeholder for what your own copy contains:
# example: replaying a copied server action request with no session cookie
curl 'https://staging.example.com/projects' \
-X POST \
-H '<each non-cookie header from your copy, unchanged>' \
--data-raw '<the arguments from your copy, e.g. a project ID>'If the action has no auth check of its own, it runs, and the response or your database shows the change. If it does check, you get an error back and nothing changes. Then try the second test: log in as a different user, copy their cookie into the command, and change the argument to an ID that belongs to the first user. An action that checks “is someone logged in” but not “does this user own this record” passes the first test and fails the second. The guide calls this second class of bug Insecure Direct Object Reference (IDOR).
The same two-account approach is how you test Supabase Row Level Security from the outside; the two-account RLS test walks through it at the database layer.
The fix: check auth and ownership inside every action
Every server action should verify the session and then check that this user may act on this specific resource. The data security guide’s own example looks like this:
// example, following the Next.js data security guide
'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 || post.authorId !== session.user.id) {
throw new Error('Unauthorized')
}
await db.post.delete({ where: { id: postId } })
}For anything bigger than a handful of actions, the guide recommends moving this into a Data Access Layer: a server-only module that does authentication, authorization and database access, with the 'use server' file kept thin. The action becomes a one-line call into the DAL, and the checks live in one place where reviewers can find them.
// app/actions/posts.ts (example)
'use server'
import { revalidatePath } from 'next/cache'
import { deletePost } from '@/data/posts' // auth + authz happen inside
export async function deletePostAction(postId: string) {
await deletePost(postId)
revalidatePath('/posts')
}Treat arguments as untrusted input too. The guide says to “always validate input from client, as they can be easily modified,” which covers form data, URL parameters, headers and searchParams. In practice that means checking types and shapes before use, and never accepting fields like role, userId, price or plan from the client when the server should decide them.
If you’re on Supabase and pass the user’s session through to the database, Row Level Security policies give you a second layer behind the action. If you use the secret (service role) key inside actions, Supabase’s docs say it bypasses Row Level Security, so the action’s own check is the only thing between a caller and your data.
A quick audit for every “use server” file
You can find every server action in a repository with one search, then go through them one at a time. Start with:
# list files and inline functions that declare server actions
rg -n "['\"]use server['\"]" --glob '!node_modules'For each hit, the Next.js guide’s own audit checklist asks whether arguments are validated, whether the user is re-authorized inside the action, whether it checks ownership of the resource (authorization, not just authentication), whether return values are filtered to what the client needs, and whether database access goes through a server-only Data Access Layer.
Two patterns deserve extra attention:
- Actions defined in a file shared by several pages. Moving one page behind a different proxy matcher changes nothing for the others, which is easy to forget.
- Actions that return whole database rows. Return only the fields the UI needs, because the response goes to whoever called it.
The same checklist calls out proxy.ts and route.ts as files with “a lot of power” that deserve extra auditing, and recommends regular penetration testing or vulnerability scanning in line with your development cycle.
Patched versions for Next.js issues that touch server actions
Run at least the fixed release for your version line from each advisory below, and preferably the latest patch release in that line. The framework itself has had serious bugs in the code that handles server functions, so your own auth checks are only half of it. The versions come from the advisories linked in Sources.
| Advisory | What it allowed | Fixed in (from the advisory) |
|---|---|---|
| CVE-2025-29927 / GHSA-f82v-jwr5-mffw (Mar 2025) | Bypassing authorization checks done in middleware | 12.3.5, 13.5.9, 14.2.25, 15.2.3 |
| CVE-2025-66478 (Dec 2025), downstream of React’s CVE-2025-55182 “React2Shell” | Remote code execution via the React Server Components protocol, CVSS 10.0, App Router apps | 15.0.5, 15.1.9, 15.2.6, 15.3.6, 15.4.8, 15.5.7, 16.0.7 |
| CVE-2025-55183 (Dec 2025) | A crafted request makes a server function return the compiled source code of other server functions | 15.0.7, 15.1.11, 15.2.8, 15.3.8, 15.4.10, 15.5.9, 16.0.10 |
| CVE-2025-55184, completed as CVE-2025-67779 (Dec 2025) | Denial of service: a crafted request hangs the server process | 14.2.35, 15.0.7, 15.1.11, 15.2.8, 15.3.8, 15.4.10, 15.5.9, 16.0.10 |
A few notes from the same advisories. The React2Shell advisory says Next.js 13.x, 14.x stable, Pages Router apps and the Edge Runtime were not affected by that bug, and the Next.js advisory recommends rotating your app’s secrets if it was online and unpatched as of December 4, 2025 at 1:00 PM PT. The source code exposure bug matters most for secrets written directly into server function code; keep them in environment variables. React updated its advisory on January 26, 2026 with a further denial-of-service issue, CVE-2026-23864, so check the current Next.js security advisories for the release that includes that fix before you pick a version.
Next.js also publishes npx fix-react2shell-next, an interactive tool that checks your installed versions and bumps them for the December 2025 advisories.
Where Shipmate fits
If your app was built quickly with an AI coding tool, it’s worth going through the audit above before launch, because an action can look finished while skipping the ownership check. Shipmate’s Launch Lockdown tests every server action and API route with two accounts, checks your Next.js version against the advisories, and delivers fixes as a tested pull request. The free check is passive and needs written permission; it looks at what’s already public, such as exposed files, keys in your bundle and response headers, and it doesn’t call your actions.
Sources
- Next.js Docs, “How to think about data security in Next.js”: https://nextjs.org/docs/app/guides/data-security (accessed 2026-11-07)
- Next.js Docs, “proxy.js” (server functions and matchers; middleware renamed to proxy): https://nextjs.org/docs/app/api-reference/file-conventions/proxy (accessed 2026-11-07)
- GitHub Security Advisory GHSA-f82v-jwr5-mffw, “Authorization Bypass in Next.js Middleware” (CVE-2025-29927): https://github.com/vercel/next.js/security/advisories/GHSA-f82v-jwr5-mffw (accessed 2026-11-07)
- Next.js Blog, “Security Advisory: CVE-2025-66478”: https://nextjs.org/blog/CVE-2025-66478 (accessed 2026-11-07)
- Next.js Blog, “Next.js Security Update: December 11, 2025”: https://nextjs.org/blog/security-update-2025-12-11 (accessed 2026-11-07)
- Supabase Docs, “API keys” (secret keys bypass RLS): https://supabase.com/docs/guides/api/api-keys (accessed 2026-11-07)
- React Blog, “Denial of Service and Source Code Exposure in React Server Components” (updated 2026-01-26): https://react.dev/blog/2025/12/11/denial-of-service-and-source-code-exposure-in-react-server-components (accessed 2026-11-07)