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_uriorreturnTois 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 ashttps://evil.com, yet it "starts with /"https://yourapp.com.evil.com— your domain is only a subdomain-looking prefix of the attacker'shttps://evil.com/?x=yourapp.com— your domain appears, but not as the hosthttps: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 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
hostnameagainst an explicit set. Never usestartsWith/includeson 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.
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 readMass 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 readInsecure 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