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.

Mallory posts a comment containing a script tag. The server stores it and later pastes it into the HTML of the comments page. Alice's browser parses the page, runs the script as shop.example, and the script sends Alice's session cookie and token to evil.example. Output encoding stops it at the server, the CSP nonce stops it in the browser
The payload travels as data until the server pastes it into HTML; output encoding stops it there, and a nonce CSP stops it in the browser.

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

kindwhere the payload liveswho is hiton the page
storedthe database (a comment, a profile field)every visitor of the pageDemo 1
reflectedthe request (a link's query string), echoed back by the serverwhoever opens the linkDemo 4
DOM-basedthe URL fragment or other browser-side input, written into the page by the site's own JavaScriptwhoever opens the link; the server never sees the #fragmentDemo 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 goeswhat to dowhat 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 escapejavascript:steal() has nothing to escape and runs on click (Demo 6)
inside a <script> blockJSON-encode, never paste raw";steal();" ends the string
browser DOM APIstextContent, not innerHTMLDOM XSS; server escaping cannot help (Demo 5)
Three contexts side by side. In HTML text, escaping turns the script tag into harmless characters. In an href, HTML escaping leaves javascript:steal() unchanged, so a URL check is needed. In the browser, innerHTML parses the fragment into an img with onerror, while textContent keeps it as text
HTML-escaping fixes text in HTML; a URL in an href needs a scheme check, and browser code needs textContent.

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.

The same page with an injected inline script under three policies. With script-src self and unsafe-inline the script runs. With a nonce and strict-dynamic it is blocked and a report is sent. With the same policy as Report-Only it runs and a report is sent
Only an enforced nonce policy blocks the injected script; 'unsafe-inline' allows it and Report-Only only tells you about it.
policyinjected inline script
no CSPruns
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-Onlyruns, 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.