The Login Redirect Parameter That Sent Users Anywhere

Share
The Login Redirect Parameter That Sent Users Anywhere. Abstract security illustration in orange and dark grey on debugly.dev

The login flow had a next parameter so that after signing in, users returned to the page they had been trying to reach. It was a kindness. It was also an open redirect, because the parameter accepted any absolute URL, and the redirect happened after a successful login, which is exactly where users are least suspicious.

An open redirect is rarely the whole vulnerability. It is a multiplier. It lends your domain's trust to an attacker's destination, and it composes with other bugs to make them worse.

This was found in a code review of the authentication flow, and the fix below is the one we shipped.

Why the trusted domain matters

Phishing works when the victim trusts the first thing they see. A link that begins with your real hostname, over HTTPS, with the padlock, is as trustworthy a first hop as exists. If the rest of the URL sends the user to an attacker's page after a redirect, the user's defences were disarmed by your domain, not by the attacker's.

The attacker's link looks like:

https://app.example.com/login?next=https://evil.example/your-account

The user checks the hostname, sees you, clicks, logs in if needed, and is delivered to the attacker. The browser bar may flash your domain and then change, and many users never notice the change.

How it composes with real bugs

The open redirect on its own is often rated medium. Its value jumps when combined.

With OAuth flows. If your OAuth redirect validation checks that the post login URL begins with your domain, an open redirect inside your domain defeats it. The attacker sets the OAuth return to your open redirect, which then bounces to the attacker, carrying the authorisation code or token. This is the same trust confusion as OAuth redirect URI mismatch, but exploited from the inside.

With token in URL patterns. Any flow that puts a one time token in the redirect URL hands that token to the attacker's origin via the Referer header or the URL itself.

With SSRF style confusion. Some internal tooling trusts same origin redirects, and an open redirect makes an external destination look same origin.

So the review question is not "is this a redirect". It is "what does this redirect make trustworthy".

The validation that fails

The common attempts all have holes.

Check that it starts with your domain string. next=https://app.example.com.evil.example/ starts with your domain as a substring and is not your domain. String prefix checks on URLs are the classic hole.

Check the hostname equals yours, then allow any path. This is close, but if you then accept protocol relative URLs like //evil.example, or backslash variants like https://app.example.com\@evil.example, parser differences between your validator and the browser reopen the hole. Browsers are forgiving in ways validators are not.

Decode and recheck once. An attacker can double encode so that your first decode produces a safe string and the browser's handling produces the evil one. Any scheme where you decode then trust is a race against the browser's parser.

The validation that works

The robust approach is to stop accepting arbitrary URLs and accept only what you can name.

Prefer relative paths. Store and redirect to a path, not a URL. next=/settings/billing is safe by construction. If the value contains a scheme or a host, reject it or reduce it to its path.

If you must allow absolute URLs, parse and compare properly. Use a URL parser, take the origin, and compare it against an explicit allowlist of origins you own. Not a substring, not a prefix. The parsed origin, scheme, host and port, must equal an allowlisted origin. Then take only the path and query from the parsed URL and rebuild it against your own origin, discarding anything else.

Default closed. If the parameter is absent, malformed or fails the check, redirect to a safe default, and do so silently. Logging the rejected value is also your detection signal for probing.

In code shape:

function safeNext(value) {
  if (!value) return "/";
  if (value.startsWith("/") && !value.startsWith("//") && !value.startsWith("/\\")) {
    return value;                      // relative path, safe
  }
  let u;
  try { u = new URL(value); } catch { return "/"; }
  if (ALLOWED_ORIGINS.has(u.origin)) return u.pathname + u.search;
  return "/";
}

Notice the double defence: relative paths are accepted only in their strict form, and absolute URLs are reduced to path and query on a known origin.

The post login timing makes it worse

A redirect that fires before login is less dangerous than one after, because the post login moment is when the session is fresh and the user expects to be "inside" your product. An attacker controlled page arriving at that moment inherits maximum trust. If you can, show an interstitial for any external destination: "you are leaving example.com". It is friction, and it is also the only thing that re arms the user's suspicion at the right moment.

The rule

Any URL the user can influence that becomes a redirect destination is a trust transfer, and it must be validated as a parsed origin against an allowlist, or reduced to a relative path. Substring checks are not validation, and a redirect that fires after authentication is the most valuable kind to an attacker.

Audit every next, redirect, return, continue and url parameter in your auth flows today, because they were almost all added as kindnesses, and kindnesses do not get security review.

The same "the user supplied the destination" defect, pointed at your server instead of the browser, is the webhook URL field that could reach your metadata endpoint.

Finding every instance

Open redirects hide because they are added as conveniences across many flows, not just login. Grep for the parameter names and the redirect calls, then read each site that feeds them:

grep -rnE "next=|redirect=|return=|continue=|url=" app/ | grep -i redirect
grep -rn "res.redirect\|redirect(" app/ 

Every hit that interpolates request input into the destination is a candidate. Apply the same allowlist or relative path rule to each, and you will usually find the login flow was only the first of several.

Read more