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
 
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
 
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
 
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
 
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.