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...
For further actions, you may consider blocking this person and/or reporting abuse
Really important article, John! πΈ
use serveris 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! π
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.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. πΉ
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! π
The IDOR point is the one I'd underline β with
updateManyyou actually get a cheap tell at runtime: if an attacker's invoice id isn't in their org,countcomes 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 plainupdatethat 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.Both of those are gold, Max.
The
updateManypattern is such a clean way to prevent resource enumeration - returning a generic 404 whencount === 0instead of leaking existence via a 403 (or dealing with Prisma throwingP2025on a plainupdate) 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.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
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.
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.
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 authenticateduserororgIdas an argument.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.
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.