Open Redirects: Find and Fix Them Before Phishers Do
An open redirect is the bug security teams love to mark "low" and phishers love to use. Your login page takes a next parameter, sends the user wherever it says, and suddenly a link that starts with your real, trusted dom
An open redirect is the bug security teams love to mark "low" and phishers love to use. Your login page takes a next parameter, sends the user wherever it says, and suddenly a link that starts with your real, trusted domain lands a victim on a credential-harvesting page. The fix is not a smarter regex. It is a small allowlist function, written once and used by every redirect in the app.
I build PixelValut, a set of free browser-side security tools. I wrote this with help from AI tools and checked every command, output and claim myself. Below are real responses from a tiny test server I ran on Nov 15, 2026, the bypass payloads that defeat naive checks, and the validation pattern I ship instead.
What is an open redirect and how do you fix it?
Direct answer: An open redirect is an endpoint that forwards users to any URL supplied in a parameter, letting attackers borrow your domain's trust for phishing or OAuth token theft. Fix it by accepting only same-origin relative paths or an explicit allowlist of exact hostnames, parsed with a real URL parser, and falling back to a safe default.
Why a "low severity" bug matters
On its own, an open redirect does not leak data from your server. The damage comes from what it enables:
-
Phishing with a real domain. A link like
https://app.yourco.com/login?next=https://yourco-support.example/resetpasses the "check the domain before you click" advice that every security awareness course teaches. Email filters that score links by domain reputation see your domain first. - OAuth and SSO token theft. If an identity provider allows a redirect URI on your domain, and that page forwards anywhere, an attacker can chain the two. The authorization code or token in the URL fragment can follow the browser to the attacker's host.
- Referrer and token leaks. Password-reset or magic-link pages that redirect onward can leak the token in the URL to the next site, which is why I pair this fix with a strict Referrer-Policy for sensitive URLs.
- Allowlist bypasses in other systems. SSRF filters, webhook validators and CSP rules that trust your domain can be steered to an external host through your redirect.
So the real question is not "can this redirect hurt us" but "what else trusts our domain".
A real test: vulnerable vs fixed
To show the behaviour without touching anyone's production system, I ran a 30-line Python server locally with two endpoints. /login/vuln copies next straight into the Location header. /login/safe runs it through a validator. Here are the actual responses:
vuln next=/settings -> Location: /settings
vuln next=https://evil.example/login -> Location: https://evil.example/login
vuln next=//evil.example -> Location: //evil.example
vuln next=/%5Cevil.example -> Location: /\evil.example
vuln next=https:evil.example -> Location: https:evil.example
vuln next=https://app.example.test.evil.example/ -> Location: https://app.example.test.evil.example/
safe next=/settings -> Location: /settings
safe next=https://evil.example/login -> Location: /dashboard
safe next=//evil.example -> Location: /dashboard
safe next=/%5Cevil.example -> Location: /dashboard
safe next=https:evil.example -> Location: /dashboard
safe next=https://app.example.test/billing -> Location: https://app.example.test/billing
safe next=https://app.example.test.evil.example/ -> Location: /dashboard
Each of those vulnerable lines is a classic bypass:
-
//evil.exampleis a protocol-relative URL. It starts with a slash, so a check likestartsWith("/")passes it, and the browser treats it ashttps://evil.example. -
/\evil.examplerelies on browsers normalising a backslash to a forward slash in the authority part, which turns it into//evil.example. -
https:evil.examplehas a scheme but no slashes. Some parsers call it a relative path; browsers often resolve it to the external host. -
app.example.test.evil.exampledefeats acontains("app.example.test")orstartsWithhostname check, because the attacker ownsevil.exampleand every name under it.
Naive checks vs a parsed allowlist
| Check | Example code | Stops https://evil.example
|
Stops //evil.example
|
Stops /\evil.example
|
Stops look-alike subdomain |
|---|---|---|---|---|---|
| No validation | redirect($next) |
No | No | No | No |
Starts with /
|
str_starts_with($next, '/') |
Yes | No | No | Yes |
| Substring match | str_contains($next, 'yourco.com') |
Yes | No | No | No |
| Regex on host | /^https:\/\/.*yourco\.com/ |
Yes | Often no | Often no | No |
| Parsed URL + exact host allowlist | parse, compare host to a set |
Yes | Yes | Yes | Yes |
| Server-side redirect IDs |
?next=billing mapped to a route |
Yes | Yes | Yes | Yes |
The last two rows are the only ones I trust. The ID approach is the strongest when the list of destinations is small, such as "back to billing" or "back to the order".
The validation pattern I ship
The rules, in order:
- Reject empty values, anything over a sane length, control characters and backslashes.
- Allow a relative path only if it starts with a single
/and not//. - Allow an absolute URL only if it parses, uses
https, and its hostname is an exact member of an allowlist. - Otherwise, redirect to a fixed default page. Never echo the input back in an error.
Here it is in Laravel, where most of my login and checkout flows live:
// app/Support/SafeRedirect.php
final class SafeRedirect
{
private const ALLOWED_HOSTS = ['app.yourco.com', 'billing.yourco.com'];
public static function target(?string $next, string $fallback = '/dashboard'): string
{
if ($next === null || $next === '' || strlen($next) > 2048) return $fallback;
if (preg_match('/[\x00-\x1F\\\\]/', $next)) return $fallback;
if (str_starts_with($next, '/') && !str_starts_with($next, '//')) {
return $next; // same-origin path
}
$parts = parse_url($next);
if (($parts['scheme'] ?? null) === 'https'
&& in_array(strtolower($parts['host'] ?? ''), self::ALLOWED_HOSTS, true)
&& !isset($parts['user'])) { // blocks https://[email protected]
return $next;
}
return $fallback;
}
}
// In a controller
return redirect()->to(SafeRedirect::target($request->query('next')));
And the same idea in Node, using the WHATWG URL parser that browsers also follow:
const ALLOWED = new Set(['app.yourco.com', 'billing.yourco.com']);
export function safeTarget(next, fallback = '/dashboard') {
if (!next || next.length > 2048 || /[\x00-\x1f\\]/.test(next)) return fallback;
if (next.startsWith('/') && !next.startsWith('//')) return next;
try {
const u = new URL(next);
if (u.protocol === 'https:' && ALLOWED.has(u.hostname) && !u.username) return u.href;
} catch { /* not a valid absolute URL */ }
return fallback;
}
Two details worth calling out. Checking for a user part blocks https://[email protected], where everything before @ is a username and the real host is evil.example. And comparing the parsed hostname to an exact set avoids every substring and suffix trick in one move.
Image: AI-generated.
Where open redirects hide
Login next parameters are the famous one, but in audits I find them in quieter places:
- Logout endpoints with
?return_to=. - Language and currency switchers that send you "back where you were" using the
Refererheader or abackparameter. - Email click-tracking links like
/t/click?u=. - OAuth
redirect_uriregistrations that use wildcards or prefix matching instead of exact strings. - JavaScript redirects:
location.href = params.get('url'). These never show up as a 3xx in server logs. If the value can bejavascript:alert(1), it is also a DOM XSS. - Framework helpers such as
redirect()->back()that trust a forgedRefererwhen there is no session history.
How to test your own site
From a terminal, curl shows exactly what the server sends without following it:
for p in "https://evil.example" "//evil.example" "/%5Cevil.example" "https:evil.example"; do
printf '%-24s ' "$p"
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' "https://app.yourco.com/login?next=$p"
done
Any response whose redirect points at evil.example is a finding. For JavaScript redirects, open the URL in a private window with DevTools open and watch where the page goes.
In the browser, paste the full test URL into the Redirect Chain Checker to see every hop, status code and final destination. It is useful for marketing links too, where a chain of three tracking redirects can end somewhere nobody expected. Only test domains you own or have written permission to test.
Rollout checklist
- [ ] Search the codebase for
redirect(,Location:,location.href,window.openandreturn_to/next/url/continueparameters. - [ ] Route every one through a single helper like
SafeRedirect::target(). - [ ] Replace free-form destinations with route names or IDs wherever the list is short.
- [ ] Register OAuth redirect URIs as exact strings; remove wildcards.
- [ ] Add unit tests for
//host,/\host,https:host,user@hostand look-alike subdomains. - [ ] Set
Referrer-Policy: strict-origin-when-cross-origin(or stricter on token pages). - [ ] Mark session cookies
Secure,HttpOnlyandSameSite=Lax, as covered in auditing cookie security flags, so a redirect cannot become a session leak. - [ ] Re-run the curl loop in CI against staging after every auth change.
Failure modes I keep seeing
-
Validating, then decoding. The check runs on the raw value, then the framework URL-decodes it again before redirecting. Validate the exact string you will put in
Location. -
Allowlisting the apex with a suffix match.
endsWith('yourco.com')also matchesevilyourco.com. Compare exact hosts, or checkhost === 'yourco.com' || host.endsWith('.yourco.com')only if you control every subdomain. - Fixing the login page only. Logout, language switchers and email trackers keep the bug alive.
- Interstitials as the only fix. A "you are leaving this site" page helps for user-generated links, but it does not fix OAuth chains, where no human sees the hop.
FAQ
Is an open redirect a real vulnerability or just a bug?
It is a real vulnerability with context-dependent severity. Alone it enables convincing phishing. Combined with OAuth, SSO or SSRF allowlists that trust your domain, it can lead to token theft or internal requests, which is why I fix every one I find.
Can I just block URLs that start with http?
No. Protocol-relative values like //evil.example, backslash variants and https:evil.example all skip that check. Parse the URL and compare the hostname to an exact allowlist, or accept only paths that start with one slash.
Does a "you are leaving this site" warning page fix it?
It reduces phishing risk for human clicks on user-submitted links, but it does not protect OAuth or SSO flows where the redirect happens automatically. Validate destinations first; use an interstitial only for genuinely external user content.
How do open redirects affect OAuth?
If a provider allows a redirect URI on your domain and that page forwards to any URL, an attacker can route the authorization response through it. Register exact redirect URIs, use PKCE, and make sure the callback page never forwards to untrusted destinations.
Are JavaScript redirects safer than server redirects?
No. location.href = param has the same open-redirect risk and adds DOM XSS if the value can be a javascript: URL. Apply the same validator in the browser, and only allow https: or same-origin paths.
Will fixing open redirects hurt marketing tracking links?
Not if you design for it. Keep tracking links pointing to an allowlist of your own domains and approved partners, or store destinations server-side behind an ID. You keep click analytics without letting anyone forward traffic anywhere.
Conclusion
Every open redirect I have fixed came down to one missing function. Parse the destination, allow same-origin paths and exact hostnames, default to a safe page, and use that helper everywhere your app sends a user onward. Then test the classic bypasses in CI so the bug does not come back with the next login refactor. If you are tightening transport security at the same time, my notes on a safe HSTS preload rollout cover the next layer.
Follow & Connect
- YouTube: PixelValut (@pixel-valut)
- TikTok: PixelValut (@pixelvalut007)
- GitHub: Muhammad Ali Mughal (@ali111887)
- LinkedIn: Muhammad Ali Mughal
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.