The idea: the attacker's text becomes the page's code
Cross-site scripting (XSS) happens when text an attacker controls reaches the browser's HTML parser, or a DOM API like innerHTML, as markup instead of as text. The browser cannot tell which part of a page came from the site and which from the attacker. A <script> in the page is a script of the page: it runs with the site's origin, reads what the page can read and sends requests as the logged-in user.
The canvas is a small shop with comments. Alice is logged in: her browser holds the session cookie session=s3cr3t and an API token in localStorage. Mallory's steal() sends whatever it can read to evil.example. Pick an attack and the three defences, then press Run Attack, or run a demo.
Stored, reflected and DOM-based XSS
| kind | where the payload lives | who is hit | on the page |
|---|---|---|---|
| stored | the database (a comment, a profile field) | every visitor of the page | Demo 1 |
| reflected | the request (a link's query string), echoed back by the server | whoever opens the link | Demo 4 |
| DOM-based | the URL fragment or other browser-side input, written into the page by the site's own JavaScript | whoever opens the link; the server never sees the #fragment | Demo 5 |
Stored XSS is the worst: it needs no link and keeps firing. Run a harmless comment after the stored attack and Mallory's old comment runs again, because it is still in the last two comments.
What an injected script can do, and HttpOnly
The script runs as shop.example. It can read the DOM, non-HttpOnly cookies, localStorage and sessionStorage, and it can call the site's API with Alice's cookies. Sending the data out is easy: an image URL to evil.example is not a CORS request, so nothing checks it (see CORS).
The HttpOnly cookie flag hides the cookie from document.cookie. The browser still sends it with every request. That stops the cookie from being copied, but not the token in localStorage, and not the script acting as Alice while the page is open (Demo 2). HttpOnly limits the damage. It does not fix the bug. See Cookies and JWT vs Session Cookies for where to keep a token.
Output encoding by context
The fix is to encode output for the place it goes. Input is stored as it is; the page escapes it at the moment it becomes part of HTML. One function is not enough, because each place in a page parses text differently:
| where the value goes | what to do | what goes wrong without it |
|---|---|---|
HTML text: <p>{{text}}</p> | escape < > & " ' | <script> becomes a tag (Demo 3) |
attribute: value="{{x}}" | escape, and always quote the attribute | " onfocus=… closes the attribute and adds a handler |
URL: href="{{url}}" | allow only http: / https:, then escape | javascript:steal() has nothing to escape and runs on click (Demo 6) |
inside a <script> block | JSON-encode, never paste raw | ";steal();" ends the string |
| browser DOM APIs | textContent, not innerHTML | DOM XSS; server escaping cannot help (Demo 5) |
Template engines and frameworks (React, Vue, Angular, Jinja, Thymeleaf) escape {{ }} by default, which removes most XSS. The holes are the escape hatches: innerHTML, v-html, dangerouslySetInnerHTML, |safe, HTML built by string concatenation, and URLs from users. When HTML from users is really needed, run it through a sanitizer such as DOMPurify.
Content-Security-Policy
A Content-Security-Policy header tells the browser which scripts it may run. It is a second line of defence for the day the encoding is wrong somewhere. The policy that works against XSS uses a nonce:
Content-Security-Policy: script-src 'nonce-n0nce3' 'strict-dynamic'; report-uri /csp-report <script nonce="n0nce3" src="/app.js"></script> ← runs <script>steal()</script> ← no nonce: blocked
The server makes a new random nonce for every response, so Mallory cannot know it when she writes her payload. Injected <script> tags, inline event handlers like onerror= and javascript: URLs carry no nonce and are refused (Demo 8). 'strict-dynamic' lets the trusted app.js load further scripts, so the policy does not need a list of hosts.
| policy | injected inline script |
|---|---|
| no CSP | runs |
script-src 'self' 'unsafe-inline' | runs: 'unsafe-inline' allows exactly what XSS injects (Demo 7) |
script-src 'nonce-…' 'strict-dynamic' | blocked, report sent (Demo 8) |
the same as Content-Security-Policy-Report-Only | runs, report sent (Demo 9) |
Report-Only is for rolling a policy out: the browser reports what it would have blocked without breaking the site. When the reports only show attacks and no legitimate scripts, switch the header to the enforcing one. A host allowlist like script-src 'self' cdn.example is weaker than a nonce: any script file on an allowed host, such as an old JSONP endpoint, can be abused.
XSS is a design question as much as a coding one: escaping by default, no HTML from users, a strict CSP from day one. See OWASP Insecure Design.
See also HttpOnly Cookie vs localStorage: why HttpOnly stops a script from stealing the token but not from using it.
What the page leaves out
Attribute and JavaScript-string contexts on the canvas (they are in the table only), sanitizers like DOMPurify, Trusted Types, hash-based CSP ('sha256-…'), other CSP directives (connect-src, img-src, frame-ancestors), mutation XSS, XSS in SVG and file uploads, and what the script can do besides stealing: changing the page, logging keystrokes, calling the API as Alice.