TLDR: In Next.js, protecting a page does NOT protect the server actions behind it. Every action is its own callable endpoint. Guard each one with
requireAuth()— and make sure your allowlist actually FAILS CLOSED when it's empty.
The Setup
I've been building a suite of internal apps for an ecommerce business — an internal KOL CRM, a webinar analytics dashboard, an affiliate payout manager — all sitting behind Supabase auth.
Pages were gated. Redirects were in place. It looked secure.
It wasn't.
The Wall We Hit
Here's the thing about Next.js server actions (the "use server" functions that handle your form submissions and mutations) that didn't fully click for me until the audit: the action ID ships in the client bundle.
Anyone can call it directly with a POST request — no browser, no UI, no login redirect.
The page auth check only fires when someone loads the page in a browser. The action has its own front door, and if you didn't put a lock on that door specifically… it's open.
In the webinar analytics dashboard, createWebinarV2 was writing directly to the database with zero auth check. P0. A curl command could have populated our webinar schedule with garbage.
What Looked Like a Guard (But Wasn't)
This is the part that stings.
In the internal KOL CRM, there WAS an allowed_external_emails allowlist defined in the config. It looked like protection. It felt like protection.
It had never been wired into the actual auth check.
Dead code masquerading as a security boundary.
And in the affiliate payout manager, the ALLOWED_EMAILS env var was checked — but if the list was empty, the check passed. Fail-open. Which means in any fresh environment or misconfigured deploy, the gate is up.
Fail-open is worse than no gate at all, because it lies to you.
The Fix That Stuck
The pattern is simple, and now it's mandatory in every app I ship:
// At the top of EVERY mutating server action:
const user = await requireAuth()
// throws / redirects if not authenticated; returns session otherwise
- Every action gets it — not just the ones that "feel sensitive"
- The helper fails closed: missing env var → throw, not pass
- Allowlists enforce fail-closed by default: empty list means nobody gets in, not everybody
Five server actions in the affiliate payout manager. Two more that arrived mid-rebase in a webinar project management tool. All of them got the guard. The commit message was deliberately boring: fix(security): add requireAuth() guard to all server actions.
Boring is correct for security fixes.
Why This Matters to Me
I ship fast. I like that about myself. But fast without this pattern means you're building a UI that looks protected while leaving the machinery underneath wide open.
The mental model shift is small but important: every server action is a public API route until you say otherwise. Treat it that way from the first line.
If you're using Next.js server actions and you haven't audited every one for an explicit auth check — do it now. It takes twenty minutes. The dead allowlist check I missed took much longer to find.
P.S. The
requireAuth()helper that checks env vars and fails closed is the one piece worth pulling into a shared lib immediately. Write it once, use it everywhere — copy-pasting the same null check across five apps is how you end up with four correct and one quietly broken.