TLDR: If your open-redirect guard is a string heuristic, it's probably bypassable. Use new URL() and compare origins instead. One-liner → five lines. Bulletproof.

The Setup

We were running security hardening cycles on an internal KOL influencer CRM (Key Opinion Leader, think creator/brand relationship management).

After OAuth via Supabase, the auth callback needed to redirect the user somewhere.

Standard pattern: read a ?next= param, go there after login.

The code in src/app/auth/callback/route.ts had a guard. And honestly? It looked reasonable.

What We Had

const safeNext = (next.startsWith('/') && !next.startsWith('//') && !next.includes(':'))
  ? next
  : '/'

Starts with slash. Doesn't start with double-slash. Has no colon. That feels like it covers the bases.

//evil.com? Blocked. https://evil.com? Blocked (has a colon). javascript:? Blocked (colon).

So what's wrong with it?

What Broke It

Backslashes.

\evil.com passes every one of those checks.

Starts with a non-slash character? No — a backslash isn't /. Doesn't start with //. No colon.

But Chromium normalizes backslash to forward slash during URL resolution. So what you see as \evil.com (looks relative) the browser processes as //evil.com (fully external). Your user gets redirected to attacker.com after login. Silently.

And that's before we even talk about percent-encoding — %2F%2Fevil.com — which walks right through string matching that never decodes.

This is the trap with writing your own URL parser as a string heuristic. You're reimplementing RFC 3986 edge-case by edge-case, and the browser's real parser will always know tricks your regex doesn't.

The Fix That Worked

let safeNext = '/'
try {
  const resolved = new URL(next, origin)
  if (resolved.origin === origin) safeNext = resolved.pathname + resolved.search + resolved.hash
} catch { /* malformed URL — default to '/' */ }

Five lines. Here's what it does differently:

  • new URL(next, origin) — let the browser's own parser normalize everything: backslashes, percent-encoding, scheme resolution, the works
  • resolved.origin === origin — one comparison, fully normalized, no tricks
  • try/catch — malformed garbage just falls back to /, no throw, no leak
  • pathname + search + hash only — we never hand the caller back a full absolute URL, even if origins match

\evil.com becomes https://evil.com in resolved — origin mismatch, blocked.

/dashboard?tab=kols becomes https://app.example.com/dashboard?tab=kols in resolved — same origin, allowed.

GENIUS is too strong. But it is the right tool. You're delegating normalization to the same engine that will parse the URL when it's actually used.

Why This Matters to Me

I write this kind of heuristic all the time. A quick .startsWith('/') check and I move on — it feels like it obviously works.

But "obviously works" and "works against an adversary who knows RFC 3986 better than you do" are very different bars.

The lesson: whenever you're validating a URL, reach for the URL constructor first. Let the parser tell you what the URL actually is. Then check the property you care about — .origin, .protocol, .hostname — on the parsed object.

String matching on URLs is a bad habit that only gets caught when a security review cycle (or a pentester) goes looking for it.

Go grep your auth callbacks. I'll wait.

P.S. The file was src/app/auth/callback/route.ts — the Supabase OAuth redirect handler in a Next.js app. If yours follows the same ?next= pattern, the fix is literally a drop-in.