DedSec Application Security ·
Consider a hypothetical: a dynamic scan of a staging site returns two findings with the same title, "Cross-site scripting (reflected)", and the same evidence. In both, the scanner sent a parameter containing a marker string and found it in the response body. One is on /search?q=. The other is on /api/v2/profile?fields=. The developer assigned to the second opens the response, sees a JSON error message, and closes it as a false positive. The developer assigned to the first finds the payload in the results heading with its angle brackets HTML-encoded, and closes it too.
The second developer is probably right. The first is probably wrong, because the marker on /search lands in two places: once in the heading, where the template's HTML escaping handles it, and once inside a script block that sets up analytics, where a helper escapes quotes for a JavaScript string and leaves < alone. The evidence in both findings was the same, and so was the scanner's reason for raising them. What separates them is where in the response the input landed, how it was encoded, and whether a browser would ever parse that response as a page.
This post is for whoever has to triage that finding. It covers what a reflection check observes, how output context decides exploitability, what the response Content-Type changes, why stored and DOM-based XSS look different to a scanner, how a finding moves from "reflected" to "confirmed", where Content-Security-Policy fits, and where framework auto-escaping ends. It builds on earlier posts: which security headers matter for CSP in depth, what a SAST false positive is for sanitisers credited to the wrong sink, DAST coverage for what a scan never reached, and out-of-band testing for callbacks.
Sources: the OWASP Cross Site Scripting Prevention Cheat Sheet and DOM based XSS Prevention Cheat Sheet; the WHATWG HTML Standard, sections 13.1 (the HTML syntax) and 13.2 (parsing HTML documents); the WHATWG URL Standard's basic URL parser; RFC 3986 section 3.5 on fragments; the W3C Content Security Policy Level 3 specification; MDN's pages on the script-src directive, X-Content-Type-Options and Element.innerHTML; the React, Vue and Angular security documentation; the Jinja API and template documentation; and Flask's templating page. All were retrieved on 29 October 2026. The scenario is hypothetical, the payloads are the textbook ones, and no number in this post is a measurement of any tool, ours included.
A reflection check is the cheapest test a dynamic scanner can run. It puts a unique, harmless marker in a parameter, something like zq7xk2, sends the request, and searches the response for the marker. A hit means the input reaches the response. A scanner may then send the marker again with a few special characters attached, such as zq7xk2<"'>, and record which characters come back unchanged, which come back encoded, and which disappear.
That evidence answers one question: does this input come back? Cross-site scripting needs four more, and a reflection check answers none of them.
A finding that answers only the first question is a lead. Triage is working through the other four.
The HTML Standard's tokenizer has separate states for ordinary data, for attribute values (double-quoted, single-quoted and unquoted), for RCDATA, for raw text, for script data and for comments. Each state ends on different input, and decodes character references (such as <) in some states and not others. An encoder that is correct for one state can be useless in the next. Below, each example shows a template, a payload in the q parameter, and what the response contains.
Template: <p>Results for {q}</p>.
<img src=x onerror=alert(1)> returned raw: the tokenizer sees a new tag and the handler runs when the image fails to load.<img src=x onerror=alert(1)>: the tokenizer sees text. Nothing runs.In this context the angle bracket is the character that matters, and HTML entity encoding is the right defence. The OWASP sheet's examples of encoded values cover five characters: ampersand, less-than, greater-than, double quote and single quote. A scanner that saw < come back raw here has a real lead.
Template: <input name="q" value="{q}">.
" autofocus onfocus=alert(1) x=" returned raw produces <input name="q" value="" autofocus onfocus=alert(1) x="">. The quote closes the value and the rest becomes attributes. autofocus makes the element take focus as the page loads, which fires the handler without any click.", the whole payload stays inside the value.Angle brackets are irrelevant here. A filter that strips < and > and leaves quotes alone has not protected this attribute at all, and a scanner that reports "angle brackets encoded" has not established safety. In a single-quoted attribute the character that matters is the single quote, so check that your encoder handles it; not every encoder does by default.
Template: <input value={q}>.
x autofocus onfocus=alert(1) produces <input value=x autofocus onfocus=alert(1)>.The payload contains no angle bracket, no quote and no ampersand, so all five characters on the encoding list can be encoded perfectly and the injection still works. Whitespace ends an unquoted value, as the OWASP sheet notes: "The HTML parsing rules distinguish quoted values from unquoted ones, where whitespace ends the value." The fix is to quote every attribute, which the same sheet says to do in every case. A reflection with a raw space in an unquoted attribute is a stronger signal than one with a raw angle bracket.
Template: <a href="{q}">Your website</a>.
javascript:alert(1) produces <a href="javascript:alert(1)">, and a click runs the script.There is nothing for HTML encoding to encode. Encoding a character inside the scheme does not help either: javascript:alert(1) in an attribute value is decoded by the tokenizer, because character references are processed in attribute values, so the URL parser receives the same string. Inserting whitespace does not stop it. The WHATWG URL Standard's parser says to "Remove any leading and trailing C0 control or space from input" and to "Remove all ASCII tab or newline from input" before it reads the scheme, so a tab inside the word javascript is removed before the scheme is checked.
The fix is validation, not encoding: parse the URL and allow only the schemes you mean, typically https and http, after the same whitespace stripping a browser applies. The markdown renderer that produced this page does that before it emits a link: it allows relative, http, https and mailto targets only, and strips whitespace and control characters before testing the scheme, so java<TAB>script: renders as plain text. The OWASP sheet's guidance for a value placed into a URL, as opposed to a whole URL, is URL encoding followed by HTML attribute encoding; that covers a parameter value, not a scheme.
Template: <script>var q = "{q}";</script>.
There are two ways out, and they need different characters.
";alert(1);// produces var q = "";alert(1);//";. The quote ends the string, the statement runs and the comment eats the rest. A template that escapes quotes by adding a backslash but does not escape backslashes is still open: payload \";alert(1);// becomes \\";alert(1);//. The attacker's backslash escapes the one the template added, so the quote ends the string.</script><img src=x onerror=alert(1)> closes the script element. Script content is raw text, and the HTML Standard says the text of raw text and escapable raw text elements "must not contain any occurrences of the string "</" ... followed by characters that case-insensitively match the tag name of the element". The tokenizer does not know or care that it is in the middle of a JavaScript string. Escaping quotes for JavaScript does nothing against this.HTML entity encoding is the wrong tool in this context. Script content is not decoded for character references, so an encoded quote reaches the JavaScript engine as the literal six characters ". That happens to block the quote breakout while corrupting the data, but it does not encode backslashes or line terminators, and in an unquoted position such as var page = {q}; the payload alert(1) needs no special characters at all. The OWASP sheet asks for an encoder "documented for both the JavaScript string and the enclosing HTML script", which in practice means escaping the quote, the backslash, line terminators and < (as \u003c, for example), and never placing input outside a quoted string.
The same applies to state serialised into the page for a front end to hydrate, such as <script>window.__STATE__ = {"q":"..."}</script>. JSON.stringify, like many other serialisers, escapes quotes and backslashes but not < or /, so a value containing </script> ends the element. Jinja's tojson filter is documented for this case: its output "is safe to render in HTML documents and <script> tags", with the exception of double-quoted HTML attributes. This site's own build had the unescaped version until 22 October 2026: the prerenderer serialised schema data into JSON-LD script blocks without escaping <. The data was our own, so nothing was exploitable, but a post title containing </script> would have ended the element. The fix escapes <, >, & and the Unicode line terminators U+2028 and U+2029.
Template: <button onclick="track('{q}')">Search</button>, with the server HTML-encoding the value.
');alert(1);// is returned as <button onclick="track('');alert(1);//')">.That looks encoded, and the source of the page shows no raw quote. But the tokenizer decodes character references in attribute values, so the value handed to the JavaScript compiler is track('');alert(1);//'). The OWASP sheet: "HTML attribute encoding alone does not protect JavaScript in an event-handler attribute: the HTML parser decodes character references before the JavaScript is interpreted. Encoding more characters as HTML entities does not remove that second context."
This is the case where reading the raw response misleads a human reviewer most, because the evidence looks safe. The defence the sheet prefers is to stop interpolating into handlers: register handlers with addEventListener and pass the value as data, for example from a data- attribute.
Template: <div style="background-color: {q}"> or <style>.banner { color: {q} }</style>.
Running script from inside a CSS value was a legacy browser behaviour. What remains is breaking out of the CSS context into a more dangerous one: a quote ends the style attribute and the payload becomes new attributes, as in the quoted attribute case, and </style> ends a style element for the same raw-text reason as </script>. Injected CSS that stays in its context can still restyle the page, overlay a fake login form, or use attribute selectors and url() to leak attribute values to an outside server. The Vue security guide gives the clickjacking example: user-supplied styles turning a link into a transparent box over the "Log in" button. That is HTML or CSS injection rather than script execution, and it deserves a finding of its own with its own severity. The OWASP sheet's rule: "Variables should only be placed in a CSS property value."
Template: <textarea name="bio">{q}</textarea>.
<img src=x onerror=alert(1)> returned raw is text. In the RCDATA state the tokenizer does not recognise tags at all; it only looks for the matching end tag. The HTML Standard lists textarea and title as the escapable raw text elements.</textarea><img src=x onerror=alert(1)> closes the element, and the image tag after it is parsed as a tag.This context is a reliable source of false positives for string matching. The full payload appears verbatim in the response, unencoded, and nothing runs. The test that matters is whether the closing tag survives. HTML comments are similar: input inside <!-- --> matters only if --> survives.
< starts markup. Use HTML entity encoding.</script all matter. Use a JavaScript string encoder that also escapes <, or a serialiser documented for script blocks.</style move you out of CSS; inside it, values can still restyle and exfiltrate. Allowlist property values.The second finding in the opening scenario was a JSON response: {"error":"unknown field <img src=x onerror=alert(1)>"}, served with Content-Type: application/json. A victim who follows a link to that URL gets a JSON document, which the browser shows as text or in a JSON viewer. It does not build a DOM from the body, so the image tag is not a tag.
The X-Content-Type-Options header makes that explicit. MDN describes its effect on navigations: "the browser uses the supplied Content-Type as-is instead of examining the content to infer the type", and gives the example that with Content-Type: text/plain and nosniff "the browser will not interpret it as HTML, even if the content contains HTML markup." Historically, browsers guessed a response's type from its content when the declared type looked wrong, which is why markup in a text response was once a real risk. With a correct Content-Type and nosniff, that guess is off the table.
Check four things before closing a JSON reflection:
Two delivery details change severity without changing whether the injection exists. A response sent with Content-Disposition: attachment downloads rather than renders. A reflection that only happens on a POST request can still be delivered from an attacker's page by a form that submits itself, unless an anti-CSRF token rejects the request before the value is rendered, or a SameSite cookie means it arrives without the victim's session, which limits what the script could reach.
A reflected finding has its source and sink in the same request. Stored XSS separates them: the payload goes in through one request, a profile update or a support ticket, and comes out in another, often on a different page, often for a different user. A support agent's ticket view is the classic case.
A scanner that diffs each request against its own response cannot see this. Finding it needs a marker unique to each injection point, stored on submission, and a later search for that marker across every response the scan fetches afterwards. Even then the scan only finds the stored value if it later visits the page that renders it, as an identity that can see it. A payload rendered only in an admin console is invisible to a scan run as an ordinary user, which is the same two-identity problem the IDOR post describes for authorisation.
When the rendering page is out of reach entirely (an internal tool, an email, a PDF export processed later), the general practice is a payload that calls home when it runs. The confirmation is the callback, possibly hours later, from a browser the tester never saw. The out-of-band post covers how that correlation works and what a callback does and does not prove.
DOM-based XSS happens entirely in the browser. The OWASP DOM cheat sheet's definition: "For server-side XSS, the server inserts untrusted data into a response without making it safe for its output context. DOM-based XSS arises from unsafe client-side processing."
The textbook case reads the URL fragment and writes it into the page:
el.innerHTML = decodeURIComponent(location.hash.slice(1));https://app.example.com/welcome#<img src=x onerror=alert(1)>The fragment never reaches the server. RFC 3986 says "the fragment identifier is separated from the rest of the URI prior to a dereference, and thus the identifying information within the fragment itself is dereferenced solely by the user agent". The server returns the same bytes with or without the payload, so a scanner that compares requests with responses has nothing to compare. The vulnerable code is in a JavaScript file that is identical for every visitor.
Other client-side sources fail the same way, even when they are sent to the server: a query parameter read by URLSearchParams and written into the DOM, a postMessage handler that does not check the origin, a value from localStorage, a field from an API response. The server-side reflection, if there is one, may be harmless, while the client-side sink is not.
The sink also changes which payloads work. MDN notes that innerHTML "does prevent <script> elements from executing when they are injected", while remaining "susceptible to many other ways that attackers can craft HTML to run malicious JavaScript". A test that only tries a script tag against an innerHTML sink gets nothing back and reports clean. An image with an error handler works.
A headless browser check adds the missing half: load the page with a payload in each client-side source, let the scripts run, and see what happens. It can observe a dialog or a callback, or it can instrument the sinks themselves and record when a marker reaches innerHTML, document.write, eval or a script src. Its limits are the crawl's limits: the vulnerable code path may only run after a click, a route change, a feature flag or a particular API response, and a check that loads the page and waits does not take those paths.
What follows is the general sequence, not a description of any particular scanner. Each step removes a class of false positive, and each adds cost.
</script for a script string, the closing tag for RCDATA. Record how each came back.The trade-offs, stage by stage:
And the false negatives that no confirmation stage fixes, because they happen before the probe:
A strict policy changes what an injection can do. It does not remove the injection, so the finding stays open with different severity context. The security headers post covers policy design; this is what matters when triaging an XSS finding against it.
With a policy like script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none':
What it does not block, from the contexts above:
";alert(1);// runs as part of that script. The nonce authorises the element, not the bytes the attacker added to it. The script-string context is the one where a strict CSP helps least.And what undoes the policy:
For triage, record the policy in force on that response, which of the protections above apply to this context, and what an attacker can still do. A reflection in HTML text under a strict nonce policy is a real bug with reduced immediate impact. A reflection in a nonced script string is close to unmitigated. The OWASP sheet's position is the one to hold to: CSP "works best" when "Used as a defense-in-depth technique."
Most modern templates escape by default, so in an application built on one, the places to look are where that default was switched off or does not apply. The OWASP sheet says these frameworks "steer developers towards good security practices" and lists the escape hatches that undo it.
{% autoescape false %}. A Flask template with a .txt or .js extension, or a plain Jinja Environment built without autoescape, is outside the default.Auto-escaping is HTML escaping. It makes a value safe in HTML text and in a quoted attribute. It does nothing for a javascript: URL in an href, which contains no character it escapes; it is the wrong encoding inside a script block; and it cannot help an unquoted attribute or an unquoted value in JavaScript. A template with autoescape on can still have the reflection from the opening scenario, if the variable sits inside a script element.
This is where static and dynamic testing meet. A static rule can find the escape hatches, dangerouslySetInnerHTML, v-html, the safe filter, bypassSecurityTrust calls, and with taint analysis it can say whether request data reaches them. It can also produce false positives when it does not model the framework's escaping, or credits HTML escaping against a URL or script sink; the SAST false positive post covers both. A dynamic test can confirm that a request actually reaches a sink and executes, but only for the routes and states it reached. Use each to triage the other. For a DAST reflection, find the template and the line that renders the parameter, and check for an escape hatch or a non-HTML context. For a SAST finding on an escape hatch, ask whether any route a scan can reach puts input into it, and test that route directly.
</script in scripts, the closing tag in RCDATA. Ignore encoded angle brackets in contexts where angle brackets do not matter.Back to the two findings. The JSON reflection stays closed once the Content-Type and nosniff are confirmed on its error path and nothing on the front end writes that field into the page. The search reflection is reopened: the analytics helper escapes quotes for a JavaScript string and leaves < alone, so </script> ends the element and whatever follows is parsed as markup. Whether a handler in that markup runs today depends on the CSP on that response, which goes in the finding next to the context, and the way to settle it is to load the payload in a browser. The evidence was the same in both findings; the context was not.
AppSecD's dynamic and API testing runs Nuclei, Dalfox, SQLMap, Commix, Katana, Arjun, FFuf and Interactsh for out-of-band detection, plus more than 150 native checks and API discovery across more than 50 web frameworks. Its static analysis includes dataflow taint analysis that traces source to sink across functions.
That is the whole claim. This post does not claim how AppSecD or Dalfox decides that a reflection is cross-site scripting, which output contexts either one recognises, whether either executes payloads in a headless browser, how AppSecD treats a Content-Security-Policy when it rates a finding, or which framework escape hatches its static rules model. The confirmation stages and the checklist above are general practice, and they apply to any scanner's output, ours included.