Blog/Web Security
Web SecurityJuly 16, 2026 · 6 min read

Open Redirect: The Small Bug That Powers Big Phishing

After login, your app sends users back to where they came from:

app.get('/login', (req, res) => {
  // ... authenticate ...
  res.redirect(req.query.next || '/dashboard');
});

Convenient — until an attacker emails your users a link to https://yourapp.com/login?next=https://yourapp-support.evil.com. The victim sees your real domain, clicks with confidence, logs in, and your server dutifully forwards them to the attacker's pixel-perfect phishing page. The credential form there is fake, but everything leading to it looked genuine because it started on your domain. That's an open redirect: a low-severity-sounding bug that is the engine behind a lot of successful phishing — and, when the redirect feeds an OAuth flow, a way to steal access tokens.

Why it matters more than it looks

  • Trust laundering for phishing. The link begins on your trusted domain, so it clears mail filters and user suspicion before bouncing to the attacker.
  • OAuth/SSO token theft. If a redirect_uri or returnTo is reflected without strict allowlisting, an attacker can redirect the authorization response — code or token in the URL — to a site they control.
  • Filter and SSRF bypass chaining. Open redirects are a common building block for escaping URL allowlists elsewhere in a system (a validator that trusts your host follows the redirect off it).

Why naive checks fail

The redirect target is a string, and attackers are fluent in URL edge cases:

  • //evil.com — protocol-relative; the browser treats it as https://evil.com, yet it "starts with /"
  • https://yourapp.com.evil.com — your domain is only a subdomain-looking prefix of the attacker's
  • https://evil.com/?x=yourapp.com — your domain appears, but not as the host
  • https:evil.com, backslashes, and encoded variants that some parsers normalize surprisingly

Every one of these defeats a startsWith/includes string check. The vulnerability is the gap between the string and the host it actually resolves to — so the check must operate on the parsed host.

The fix: prefer relative paths; allowlist hosts if you must go external

// The safest 'return to': accept ONLY a relative path. It can never
// leave your origin, so there is nothing to phish to.
function safePath(next) {
// Must start with exactly one slash. Reject '//', 'http:', backslashes,
// and control characters that browsers might normalize.
if (typeof next !== 'string') return '/dashboard';
if (!next.startsWith('/') || next.startsWith('//') || next.startsWith('/\\')) {
  return '/dashboard';
}
return next;
}

app.get('/login', (req, res) => {
// ... authenticate ...
res.redirect(safePath(req.query.next));  // always stays on-origin
});

The principles

  • Prefer relative paths. A destination that must start with a single / (and not //) can never leave your origin. This covers the vast majority of "return to" needs.
  • Allowlist hosts, don't pattern-match them. If external redirects are required, parse the URL and compare hostname against an explicit set. Never use startsWith/includes on the raw string.
  • Lock down OAuth redirect_uri. Register exact URIs and match them exactly at the provider — no wildcards, no prefix matching.
  • Consider an interstitial for any allowed off-site redirect ("You're leaving example.com"), which removes the silent-forward property attackers rely on.

The takeaway: never redirect to a raw user-supplied URL — accept a relative path, or an allowlisted host you parsed and verified.

Try it in the open redirect simulation: bypass a naive startsWith check with //evil.com, then apply relative-path validation and watch the escape fail. It pairs with the phishing URL anatomy guide — same goal, different half of the trick.

Share this post

Frequently Asked Questions

Related posts

Race Conditions in Web Apps: When Two Requests Beat Your Check

Check-then-act logic that looks correct on paper breaks when two requests run at once. Here's how TOCTOU race conditions drain balances and reuse one-time codes, and how atomic operations and locks fix them.

Aug 11, 2026 · 7 min read

Mass Assignment: How role=admin Ends Up in Your Update

Mass assignment lets an attacker set fields you never meant to expose — like isAdmin or accountBalance — by adding them to a request body. Here's why binding whole objects is dangerous and how to allowlist fields in Node, Rails, and Django.

Aug 10, 2026 · 6 min read

Insecure Deserialization: How Loading Data Becomes Running Code

Deserializing untrusted data can execute code before your app reads a single field. Here's how gadget chains work in Python pickle, Java, PHP, and Node, and why JSON with a schema is the fix.

Aug 9, 2026 · 7 min read