Your login link can be pointed anywhere, and your users cannot tell
This is the finding people argue with us about. It does not leak data. It does not run code. It lets someone borrow your domain's reputation for a moment, and that moment is worth more than it looks.
September 2026 · Zoltra · No account needed to use this fix
Almost every app has a redirect with a destination in the URL. You were on the dashboard, the session expired, you land on /login?next=/settings and after signing in you are back where you were. Useful feature. The bug is what happens when nobody checks that next stays inside your app.
/login?next=https://evil.example/fake-login # the user sees your domain in the address bar, signs in on YOUR real # login page, and then gets redirected to evil.example — a page you # did not write, arriving off a click the user trusted.
Our probe is direct: it sends a URL with an external destination and watches the Location header. If the response says the browser should go to a host we named, the finding is confirmed with status/location evidence. Nothing invasive, no payload.
Why it matters more in an agent era
The classic use is phishing — yourapp.com/login?next=... reads as trustworthy in a message. The newer use is more interesting: automated agents follow redirects cheerfully, and a redirect through your domain launders the origin. If an agent needs to reach a URL while looking like it came from somewhere reputable, a redirect chain that starts on your hostname is free infrastructure.
We are not going to pretend this is the finding that keeps you up at night. It is not. It is a small lock on a door you forgot you had. But it is cheap to close, and open redirects have a way of being chained with other bugs, where the chain is worse than any link.
The fix: allow the shape, not the string
The instinct is to check whether the destination "starts with slash" or "contains our domain." Both lose. //evil.example starts with a slash and is a protocol-relative URL. https://yourapp.com.evil.example contains your domain. Browsers normalize characters you did not normalize for.
The durable pattern is boring: don't redirect to user-supplied URLs at all. Accept a path, verify it produces a same-origin URL after parsing, and fall back to a fixed default on anything else.
// one shared helper, used by every redirect
export function safeNextPath(raw: string | null): string {
const fallback = "/dashboard";
if (!raw) return fallback;
// decode once, so %2F%2Fevil.example is treated as //evil.example
let candidate: string;
try {
candidate = decodeURIComponent(raw);
} catch {
return fallback;
}
// must be a single leading slash: blocks //host and /\host
if (!candidate.startsWith("/")) return fallback;
if (candidate.startsWith("//") || candidate.startsWith("/\\")) {
return fallback;
}
// parse against a base and require the origin to survive
try {
const url = new URL(candidate, "https://yourapp.com");
if (url.origin !== "https://yourapp.com") return fallback;
return url.pathname + url.search + url.hash;
} catch {
return fallback;
}
}
// caller
redirect(safeNextPath(searchParams.get("next")));If your product genuinely needs to send users to partner sites, don't loosen the helper. Keep an explicit allowlist of full URLs, matched exactly, and route those through a named action like /out/partner-slug where the destination comes from your data, not from the query string.
Verify it three ways
# 1. plain external URL: Location must NOT be evil.example curl -sI "https://your-app.example/login?next=https://evil.example" | grep -i location # 2. protocol-relative: same check curl -sI "https://your-app.example/login?next=//evil.example" | grep -i location # 3. encoded: same check, this is the one people miss curl -sI "https://your-app.example/login?next=%2F%2Fevil.example" | grep -i location # 4. the feature still works curl -sI "https://your-app.example/login?next=/settings" | grep -i location
All three hostile cases should land on your own origin (typically the login page or the default), and /settings should still come back as /settings.
How this drifts back
Redirects multiply. A logout route, an auth callback, a marketing campaign tracker, an email unsubscribe link. Each one accepts a destination for a good reason, each one is written separately, and only some of them call the shared helper. The finding comes back on the newest one.
The habit that holds: one redirect helper, a code review rule that any route accepting a destination URL must use it, and the four curl checks above added to whatever smoke test you run before a deploy.
What this finding does not mean
If we report this, it does not mean your login is compromised or your users were attacked. It means the redirect we found can be pointed off-site, which makes the set of believable phishing links that start with your name slightly larger. Close it and move on. It is a small fix, and that is most of what makes it worth doing today rather than someday.
You can still ship this fix yourself. With GitHub connected, Zoltra can also prepare the repair as a pull request, leave it for your review, and verify the live result after deployment.
Related: SSRF in URL parameters — the same class of trusted-input mistake with a much bigger blast radius.