XSS (Cross-Site Scripting)

One-line relationship: XSS is attacker-controlled data that gets interpreted as attacker-controlled code — the highest-frequency web vulnerability class, and the reason CSP exists as a backstop.

What it is

Any time user-controlled input reaches the DOM as markup rather than as text, the browser will parse whatever that input contains — including <script> tags and event-handler attributes like onerror. If an attacker can get their string into that path, their JavaScript now runs with the full privileges of your page: it can read cookies (unless HttpOnly), read any bearer token sitting in memory, make authenticated API calls as the logged-in user, or rewrite the DOM to phish credentials in place.

Three shapes, same root cause:

  • Stored — the payload is saved server-side (a comment, a profile field) and served to every later visitor.
  • Reflected — the payload rides in the request (a query parameter) and is echoed straight back into the response.
  • DOM-based — the payload never touches the server at all; client-side JavaScript reads something attacker-controlled (location.hash, document.referrer) and writes it into the DOM unsafely.

Try it — escaping vs raw insertion

Imagine this is a comment box on a page that echoes back whatever a user typed. Nothing here is ever actually executed — the "unsafe" panel is a simulation of what raw HTML insertion would do, rendered as inert text.

Unsafe — page does el.innerHTML = comment (simulated, not executed here)

→ browser would parse this into a live DOM node:
<img src=x onerror="alert('stolen: ' + document.cookie)">
🚨 simulated: the handler above would fire immediately — attacker JS runs with full access to this page's DOM, cookies (unless HttpOnly), and any bearer token in memory.

Safe — page does el.textContent = comment (or a templating engine that HTML-encodes)

→ browser renders these exact characters as inert text:
&lt;img src=x onerror=&quot;alert(&#39;stolen: &#39; + document.cookie)&quot;&gt;
The lesson: the payload above is identical in both panels — the difference is entirely in how the page inserts it into the DOM. innerHTML (or any raw HTML sink) parses attacker-controlled strings as markup, so a crafted tag's event handler executes as your page's own JavaScript. textContent, an auto-escaping template, or output encoding turns the same string into inert display text. Content-Security-Policy (next page) is the backstop for whatever escaping misses.

The defense stack

  • Output encoding — the only real fix. Anything user-controlled that’s rendered as HTML must be HTML-entity-encoded (<&lt;) before insertion; anything inserted as an attribute, URL, or JS string needs the encoding appropriate to that context. Modern frameworks (React, Vue) auto-escape by default — the danger is almost always an explicit opt-out (dangerouslySetInnerHTML, v-html, raw innerHTML =).
  • Content-Security-Policy — the backstop for whatever escaping misses: a tight policy with no 'unsafe-inline' means even a successfully injected <script> tag simply won’t execute. See CSP.
  • Trusted Types — a browser API that locks down the dangerous DOM sinks (innerHTML, document.write) so they only accept values that passed through an explicit sanitizer, turning a whole class of DOM-XSS bugs into build-time type errors.

What to take to the client

XSS is the highest-volume finding in almost every web pentest, and it’s also the vulnerability that makes every other client-side control (cookies, tokens in memory, even a WebAuthn session) moot — a successful injection runs as your page, with your page’s access. Escaping is non-negotiable at every output boundary; CSP is what catches the boundary someone inevitably misses.