A few weeks ago, I was auditing a major financial technology platform when its logout flow caught my attention. The endpoint accepted a destination and redirected the user after signing them out. That usually ends with an open redirect and a modest report.

This one ended with me reading another user’s loan and bank information.

I never touched the victim’s password. My script waited while they signed in through the genuine login page, then used their new session to query the platform’s own APIs.

The bug in one line: The redirect validator checked the destination’s hostname but ignored its protocol. A javascript: URL could therefore claim the trusted hostname, pass the check, and execute code in the platform’s origin.

The harmless-looking logout redirect

The logout page accepted a URL telling the browser where to go next. The frontend parsed that value with the browser’s URL object and checked whether its hostname matched the platform’s domain.

The logic looked roughly like this:

const destination = new URL(next);

if (destination.hostname === "www.trusted.example") {
  window.location.href = next;
}

Give it https://evil.example, and the check works. The parsed hostname is evil.example, so the page rejects it.

The code made a more dangerous assumption: a trusted hostname must belong to an HTTPS URL.

One URL, two meanings

I supplied this destination:

javascript://www.trusted.example/%0Aalert(document.domain)

The browser’s URL parser splits it into two important fields:

protocol: javascript:
hostname: www.trusted.example

The hostname matches the allowlist, so the validator accepts it. The protocol remains javascript:, which means assigning the raw string to window.location executes it as JavaScript.

The rest of the payload takes advantage of JavaScript’s comment syntax:

//www.trusted.example/
alert(document.domain)

Everything after // on the first line is a comment. %0A decodes to a newline, ends that comment, and puts the payload on the next line. The trusted hostname exists for the URL validator. The JavaScript engine ignores it.

The alert ran in the logout page’s origin. That confirmed a same-origin DOM XSS, but it also created an awkward problem: the endpoint had just logged the victim out.

XSS after logout

An alert on a logged-out page does not expose an authenticated API. The session I wanted was already gone.

So I stopped trying to race the logout and let the victim create a fresh session for me.

My payload replaced the page with a full-screen iframe containing the platform’s genuine login page. This was not a cloned form or a pixel-perfect phishing kit. It was the real page, connected to the real backend.

The victim could enter their password, solve the CAPTCHA, and complete two-factor authentication exactly as usual. I did not need to capture any of those values. The injected script remained alive in the parent page while the login completed inside the same trusted origin.

Once the user authenticated, the parent script could interact with the logged-in application and recover the internal application ID exposed to the client. The browser also attached the victim’s fresh session cookies to same-origin requests. The logout XSS had survived long enough to become authenticated XSS.

The useful session came after the bug fired. The exploit did not steal an old cookie before logout. It waited for the victim to create a new authenticated session inside a page the attacker already controlled.

Reading the financial record

With the application ID and the victim’s ambient session, my script called the same APIs used by the frontend. The first response returned the victim’s:

  • Phone number
  • Employer
  • Loan approval status
  • Personalized loan pricing

A second endpoint returned bank account metadata:

  • Account holder name
  • Financial institution
  • Masked account number
  • Autopay status

The requests required no password replay and no second click. The victim could finish logging in and look at their dashboard while the parent page read the responses in the background.

A real attacker could send that JSON to an external server as soon as the API returned it. The user would see the genuine application throughout the flow.

The complete chain

The exploit crossed the boundary in six steps:

  1. The victim opens a crafted logout URL on the trusted platform.
  2. The frontend parses the destination and checks only its hostname.
  3. A javascript: URL presents the expected hostname and passes that check.
  4. %0A ends the fake hostname comment, and the remaining JavaScript runs in the trusted origin.
  5. The script covers the page with the genuine login flow and waits for the victim to authenticate.
  6. The parent uses the fresh same-origin session to retrieve loan and bank data from the platform’s APIs.

The first four steps produce DOM XSS. The last two turn an apparently useless logout-page bug into authenticated financial-data access.

Why the validation failed

The application validated one property of a parsed URL, then navigated to the original untrusted string. Those two operations answered different questions.

The hostname check asked, “Does this parsed object contain the expected hostname?” The navigation asked, “Which scheme should the browser execute?” The answer to the first question was www.trusted.example; the answer to the second was javascript:.

Security checks on redirect destinations need to cover the whole authority boundary: protocol, hostname, port, and, where possible, an allowlist of paths. Checking one convenient field does not make the original input safe.

The fix

The platform patched the issue quickly by rejecting non-HTTP schemes and requiring the expected secure origin. A safer version of the redirect logic looks like this:

const allowedOrigin = "https://www.trusted.example";
const destination = new URL(next, allowedOrigin);

if (destination.origin !== allowedOrigin) {
  throw new Error("Invalid redirect destination");
}

window.location.assign(destination.href);

This blocks javascript:, data:, alternate ports, deceptive user-info segments, and other inputs whose final origin is not the one the application expects. Restricting the redirect to known paths tightens the boundary further.

The order matters too: parse once, validate the complete normalized result, and navigate with that validated result. Do not validate selected properties and then hand the browser the original string.

Outcome: The vendor confirmed the issue and patched the redirect validation quickly.

The part worth remembering

Open redirects often look like the end of a test. In this case, the redirect target crossed two parsers. The URL parser found an allowlisted hostname, while the JavaScript engine found a comment followed by executable code.

The logout state looked like another dead end. It only changed the timing. Instead of stealing a session that had just expired, the payload stayed on the trusted origin and waited for the user to create the next one.