DEV Community

Cover image for Your "use server" Functions Are Public HTTP Endpoints (And Middleware Won't Save You)

Your "use server" Functions Are Public HTTP Endpoints (And Middleware Won't Save You)

𝗝𝗼𝗡𝗻 on October 27, 2026

If you import a 'use server' function into a protected React component and pass it a typed TypeScript object, it looks and feels like a private, in...
Collapse
 
nyaomaru profile image
nyaomaru •

Really important article, John! 😸

use server is convenient, but I think choosing where to use it matters a lot. It can feel like a normal function call, which makes it easy to forget that there’s still a real security boundary there.

Auth, runtime validation, and authorization all need to be handled carefully, and with the security issues we’ve seen around RSC / Next.js, I think Server Actions are something we should use deliberately, not casually.

Great write-up! πŸ‘

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

Thanks so much, @nyaomaru! πŸ™Œ

You nailed the core issue - the DX is so smooth that it blurs the mental line between a local function call and an HTTP request. Treating 'use server' with the exact same caution as a public REST endpoint (Zod validation + auth check inside every action) is really the only safe way to use them in production.

Collapse
 
nyaomaru profile image
nyaomaru •

Exactly 😸

The backend side of a Server Action really needs to be implemented with the same level of care as any normal backend API.

The DX makes it feel lightweight, but the security boundary is still very real, so it’s definitely something we can’t get too relaxed about. 😹

Thread Thread
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

100%! Couldn't agree more. Great DX shouldn't come at the expense of sound backend hygiene.

Thanks again for stopping by and sharing your thoughts! πŸ™Œ

Collapse
 
stackmasteraliza profile image
Aliza Ali •

The quota-reset example is the trap I see most often: a helper exported from a 'use server' file "just for a test." A pattern that prevents it structurally: keep 'use server' files thin (validate the input with zod, check the session, then call into a data layer), and put internal helpers in plain modules that start with import 'server-only'. Worth adding too: Next 15's unguessable action IDs and dead-code elimination of unused actions help, but they're obscurity, not authorization. Every action still needs its own auth check.

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

"Obscurity, not authorization" is the exact phrase every Next.js team needs to hear regarding Next 15's action ID changes. Dead-code elimination helps with accidental unused exports, but the moment an action is referenced anywhere in the bundle, that ID is public again.

Also, complete agreement on the structural split, keeping 'use server' files strictly as thin entrypoints and pushing testable helpers into 'server-only' modules makes it impossible to accidentally expose internal logic just to satisfy a unit test.

Collapse
 
stackmasteraliza profile image
Aliza Ali •

Exactly. Dead-code elimination only helps until the action is referenced somewhere. Once it's in the bundle, the ID is effectively public, so the auth check has to live inside the action itself, every time.

Collapse
 
friga profile image
Artur PokΕ‚adek •

Same trap one layer down. I took over a shop built with v0 where RLS was switched off in Supabase, and the owner's answer was "what matters is that it works". The anon key sits in the page source anyway, so the whole database was one request away.

Your curl example is what I'd show a client. A request that works from outside the UI convinces faster than any explanation.

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

"What matters is that it works" alongside a public Supabase anon key and disabled RLS is pure nightmare fuel. πŸ˜…

And you're 100% right. Nothing ends a security debate with a stakeholder faster than running a single curl command in a terminal and watching their production database respond without the UI.

Collapse
 
friga profile image
Artur PokΕ‚adek •

Nightmare fuel is about right. A working curl ends that kind of discussion faster than any slide deck.

Collapse
 
sherotree profile image
Sherotree •

Treating 'use server' like a private typed helper is such an easy trap. The curl-can-hit-it framing made it click for me β€” auth and authorization have to live inside the action itself, because layout guards and middleware never see that POST.

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

Glad the curl framing made it click! Once you picture every 'use server' export as an exposed POST route rather than a private helper, putting auth and schema validation right inside the action becomes second nature.

Collapse
 
maxbuildsdev profile image
Max Kaya •

The IDOR point is the one I'd underline β€” with updateMany you actually get a cheap tell at runtime: if an attacker's invoice id isn't in their org, count comes back 0 and you can return a generic not-found instead of a 403 that confirms the record exists. Easy to lose when people refactor toward a plain update that throws on missing rows.

One thing I'd add to the checklist: some teams put the auth check in a wrapper and think that's enough, but with inline actions defined in JSX ('use server' inside the form) you can't always see the call chain in review β€” having a lint rule that fails CI when an exported action doesn't reference your auth helper directly has caught more than one for me.

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

Both of those are gold, Max.

The updateMany pattern is such a clean way to prevent resource enumeration - returning a generic 404 when count === 0 instead of leaking existence via a 403 (or dealing with Prisma throwing P2025 on a plain update) is a great callout.

And +100 on the lint rule! Inline 'use server' closures inside JSX are a huge blind spot during code reviews, especially because they also capture variables from the surrounding scope. Banning inline 'use server' in JSX altogether and enforcing an auth helper/wrapper check in CI is honestly the safest route for teams.

Collapse
 
anas_issath profile image
Anas Issath •

Mistake #3 is the scary one for me. Exporting a helper just to unit test it and accidentally making it a public endpoint is so easy to do without noticing. Also love typing the input as unknown so TypeScript forces you to validate first. Running a quick search for β€˜use server’ in my projects right now.

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

Right? Exporting a helper for testing is pure muscle memory, which is why top-of-file 'use server' directives can be such a landmine.

Glad the unknown input pattern resonated too. Making TypeScript work with the runtime boundary instead of hiding it saves so many headaches. Good luck with the 'use server' audit! πŸ™Œ

Collapse
 
ivannovazzi profile image
Ivan Annovazzi •

The curl part is what got me. Everything looks locked down in the code until you realize the action's id is sitting in the page source for anyone to POST at. So the real fix is checking auth inside the action itself and treating the layout check as cosmetic? Would love to see how you handle actions that legitimately need different permissions per caller

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

Spot on! Layout checks are basically just for UX/routing, while the action itself is the real security boundary. For actions with different permissions per caller, I usually wrap them in a lightweight action builder (like next-safe-action or a custom HOF) where you pass the required role/permission, or verify resource ownership right after parsing the input with Zod.

Collapse
 
paullovelace profile image
Paul Lovelace •

The POST-to-/login detail is the one that makes it click for me β€” the action doesn't care which route you hit, only the header. Layout checks as UX, action as boundary. How does the action-builder pattern hold up when actions compose, one action calling another with different permission needs? That's where I'd expect it to get messy.

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

You're spot on. Composing Server Actions directly gets messy fast (plus you end up running redundant auth/session checks).

The cleanest way around it is treating Server Actions like thin route handlers: they never call each other. Instead, they validate the caller once at the boundary and delegate the work to plain internal service/domain functions (marked with 'server-only') that accept the authenticated user or orgId as an argument.

Collapse
 
marcusykim profile image
Marcus Kim •

The organizationId filter and updateMany count check handle the tenant boundary, but I'd add an invoice-specific amount rule beside them. A positive amount below the schema's global maximum can still exceed that invoice's remaining refundable balance. Keep that check and the state transition in one transaction, then test two simultaneous refund requests against the same invoice. That separates payload validity from business validity, which is easy to miss when the hardened example already looks complete.

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

Such a great distinction between payload validity and business/domain validity, Marcus.

Zod only proves the input is well-formed, not that the state transition is legal. Even better with updateMany (or a single atomic SQL UPDATE), you can bake that balance guard directly into the where clause - e.g., remainingRefundableAmount: { gte: amount } alongside a decrement, so Postgres handles the row lock atomically and concurrent refund requests can't double-spend due to a read-then-write race condition.

Collapse
 
ascendra_ventures_92c21f2 profile image
Ascendra Ventures •

Artur PokΕ‚adek's point is the one I would use with a client too: a request that works from outside the UI ends the debate faster than any explanation. The action id sitting in the page source makes the risk concrete in a way a code review rarely does.

The framing that helps most here is layout checks as UX and the action as the boundary. Once a team accepts that every exported 'use server' function is a public POST endpoint, the rest follows: validate unknown input, check the session inside the action, then check ownership of the record.

Marcus Kim's addition deserves attention. Zod proves the payload is well formed, not that the refund is allowed for that invoice, so the balance check and the state change belong in one transaction, tested with two requests at once.

Aliza Ali's thin action files with helpers behind server-only would be my default on client builds. Has anyone added a lint rule that flags exports from 'use server' files that no form or component calls?

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

Love how you tied the whole thread together.

For catching orphaned 'use server' exports, knip in CI is the easiest win for dead exports, combined with a custom ESLint AST selector (no-restricted-syntax) that only allows exporting calls wrapped in createSafeAction()inside actions/ files. That way, even if someone exports a helper purely to import it in a unit test, CI still blocks it from becoming an unintended endpoint.

Collapse
 
sherotree profile image
Sherotree •

The layout-auth illusion is the trap I keep seeing in reviews β€” the page is gated, the action file isn’t. Authz inside every exported action (plus zod on the payload) feels like the minimum bar once you treat them as public POST endpoints.

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

Spot on. A gated layout only protects the rendered HTML, never the HTTP endpoint behind the action.

Auth inside the function + Zod on the input really should be the universal baseline for every 'use server' export. Appreciate you stopping by to share that! πŸ™Œ

Collapse
 
mudassirworks profile image
Mudassir Khan •

the curl to /login carrying the action id header for a different action is the part that makes reviewers stop nodding. we hit this on an admin refund action where auth lived in the layout and the action itself checked nothing. now every exported use server function goes through one wrapper that checks the session and parses input with zod before the body runs, plus a lint rule that fails any unwrapped export. do you also rate limit actions separately, or lean on the gateway?

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

Enforcing the auth + Zod wrapper via a lint rule is the cleanest way to prevent regressions.

I rate limit sensitive actions directly inside that same action wrapper (keyed by userId + action name) rather than relying solely on the gateway, since edge gateways usually just see a generic POST to a page route unless specifically configured to inspect the Next-Action header.

Collapse
 
alexshev profile image
Alex Shev •

The important shift is to evaluate each server action as an externally reachable capability. Authorization needs to live at the action boundary with an explicit resource check, not in a navigation assumption. I would add negative tests for direct requests with a valid session but an unauthorized object ID.

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

Spot on, Alex. Framing every action as an externally reachable capability immediately exposes why navigation/layout assumptions fail.

Negative tests with a valid session + a foreign resource ID should honestly be mandatory in the test suite for any mutating Server Action. Great addition!