TLDR: If your Next.js server action does
redirect(userInput)— even for something as innocent as a post-login bounce — you have an open redirect. The fix is four lines of destination validation. Don't wait for an audit to find it.
The App and the Audit
I was heads-down building an internal CRM (a KOL/influencer relationship CRM for our webinar pipeline — think contact notes, scheduling board, analytics) when I ran a fleet-wide security audit across all our apps.
The first finding was obvious: half the server actions had no auth guard at all.
Unauthenticated mutations. Anyone who could guess an endpoint could write to the database. I added requireAuth() to every mutating action and moved on.
But the second finding was sneakier.
The Bug That Looks Like a Feature
The login flow had a next param — perfectly standard UX.
User hits a protected page, gets bounced to /login?next=/dashboard/kols, logs in, server action does redirect(next).
Smooth. Except I was reading next straight from user input and passing it to redirect() without any validation.
That means an attacker can craft this link and send it to anyone on the team:
https://app.example.com/login?next=https://evil-phishing-site.com
User logs in — real credentials, real session — and lands somewhere completely different. Dressed in your brand's authority the whole way.
What I Tried First (and Got Pushed Back On)
I first went wide on the Supabase Auth side: added wildcard redirect-URL allowlists — https://example-*.vercel.app/** — so preview deploys would work without maintenance headaches.
I shut that down immediately.
"Use exact URLs for less entry points."
He was right. Every wildcard pattern is a surface. The app only ever needs to redirect to /auth/callback — one path, per environment. The allowlist should be exactly that. Three entries total. Nothing broader.
The Actual Fix
Two things had to land together:
-
Exact redirect URLs in the Supabase allowlist — no wildcards, ever.
-
Destination validation inside the server action itself:
const safeNext =
next?.startsWith("/") && !next.startsWith("//")
? next
: "/dashboard";
redirect(safeNext);
That second check — !next.startsWith("//") — is the one people miss.
//evil.com/phish looks like a relative path but browsers treat it as protocol-relative, resolving to https://evil.com/phish. Strip it.
Why This Stuck With Me
Unauthenticated server actions feel like a security hole. Open redirects feel like a routing helper.
That's exactly why they survive code review. The next param is clearly useful. It looks intentional. It ships.
The lesson: any time you redirect to a value that came from outside your code, validate the destination. Relative path only. No //. Default to / if it doesn't pass.
Four lines. Blocks a phishing vector that wears your brand's face.
P.S. Separate gotcha in a different repo: if you wrap
redirect()in a try/catch, the redirect error gets swallowed and never fires. That'sunstable_rethrowterritory — import it fromnext/navigationand call it first thing in your catch block. Different bug, same family of "Next internals are not what you expect."