Cross-Site Scripting
Untrusted content becomes executable content. Your framework already escapes text interpolation — so every XSS in a modern application is at the exact place someone opted out.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Which places in my UI turn data into code, and what is my framework already doing about it?
A person types a product review, a display name, a support message. They expect it to appear as text, exactly as typed, for everyone who reads it.
We render user content with innerHTML so formatting works, and we strip <script> tags on the way in. Anything else is over-engineering.
Executable content is not only <script>. Event-handler attributes, javascript: URLs, <iframe srcdoc>, SVG <use> and <animate>, and a <style> block are all paths to behaviour that a <script>-shaped filter never sees.
- Executable content is not only
<script>. Event-handler attributes,javascript:URLs,<iframe srcdoc>, SVG<use>and<animate>, and a<style>block are all paths to behaviour that a<script>-shaped filter never sees. - Stripping on input means the stored value is now neither the user's text nor safe HTML, and the moment a second surface renders it differently — an email, a native app, a CSV export — the assumption breaks in a place nobody is looking (Sanitization and Trusted HTML).
- The dangerous string usually does not arrive through a form at all. A query parameter read into the DOM, a
postMessagepayload, a value out oflocalStorage, a field in an API response — all of them are untrusted, and only one of them looks like user input (The Same-Origin Policy). - Once script runs in your origin, everything the page can do it can do: read the DOM, read Web Storage, call your API with the user's cookies attached, and rewrite the interface the user is looking at (The Browser Security Model).
- It is invisible in review.
dangerouslySetInnerHTMLreads as one line in a diff; the line is a decision to leave the framework's protection.
What is actually happening
In the browser, not in the framework.
- The bug is a context confusion: a value crosses from data into a context where the parser treats some characters as syntax. HTML text, an attribute value, a URL, a
<script>body and a<style>body are five different parsers with five different dangerous characters (The HTML Tokenizer). - Stored XSS: the value is persisted and served to everyone who views the record. One injection, every viewer, and it survives until someone cleans the database.
- Reflected XSS: the value comes in on the request — a query string, a path segment — and is echoed into the response or into the DOM. It needs a crafted link and it affects whoever follows one.
- DOM-based XSS: the server is entirely innocent. Client code reads from a source it does not control and writes into a sink that executes. The server logs show a completely normal request (What a Mutation Costs).
- Modern frameworks escape text interpolation by default. React
{value}, Vue{{ value }}, Svelte{value}and Angular interpolation all produce text nodes, not markup. This is why the vulnerability class shrank so dramatically and why the remaining cases cluster so tightly. - Escaping is done by setting a property or creating a text node, not by finding characters and replacing them.
el.textContent = valuecannot produce an element no matter whatvaluecontains, because the value never goes through the HTML parser (The DOM Is Not Your HTML). - A sink is any API that parses a string as markup, as a URL, or as code. The list is finite and worth memorising, because "which sinks does this code touch" is the whole of a frontend security review.
What this makes the browser do
And which of it is avoidable.
- Every assignment to
innerHTMLruns the full HTML parser and tree builder, then tears down and rebuilds the subtree — this is a real cost as well as a real risk (Tree Construction). - A rebuilt subtree loses node identity: listeners attached to the old nodes are gone, focus is lost, the accessibility tree is recomputed, and any element the user was interacting with no longer exists (Node Identity Across Updates).
- Setting
textContentavoids the parser entirely and produces one text node, which is cheaper on every axis. - A sanitizer running on the client is parsing the string a second time and walking the resulting tree — a cost that scales with content size and belongs off the render path where possible.
- Avoidable work: repeatedly rendering the same untrusted string through
innerHTMLon every state change, when a text node set once would do.
From a string to executing code
Every instance of this bug follows the same five steps, and naming them is what turns a scary category into a review checklist. The useful property is that you can break the chain at any step, and the earlier you break it the cheaper it is.
Notice that the third step — the sink — is the only one that is a line of your code. Sources are numerous and mostly out of your hands; sinks are few and entirely in your hands. That asymmetry is why frontend XSS work is sink work.
- 1Source
A value arrives from somewhere you do not control: a form field, a query parameter, a hash fragment, a
postMessage, a storage value, an API response, a cookie.fails by Being classified as trusted because it came from your own API — which is only as trusted as everything that can write to it.
- 2Transport and storage
The value is persisted or echoed. Whether it is stored (every viewer) or reflected (whoever follows a link) determines the blast radius, not the fix.
fails by Filtering here instead of at render — the stored value now matches no context, and a second consumer renders it differently.
- 3Sink
Application code hands the string to an API that parses it as markup, as a URL, or as code. This is the vulnerability; everything else is logistics.
fails by Being invisible: a one-line prop, a helper component, a utility called
renderRich. - 4Parse
The browser's HTML, URL or JavaScript parser turns characters into syntax. An attribute boundary, a
javascript:scheme or a tag start becomes structure.fails by Doing exactly what it is specified to do. There is no parser bug to blame here.
- 5Execute
The resulting node or navigation runs with your origin's authority: DOM access, storage access, credentialed same-origin requests.
fails by CSP capping this step — the last chance, and the reason CSP is worth having even when your escaping is correct (Content Security Policy).
Break it at "Sink" and the other four steps stop mattering. Break it only at "Execute" and you are relying on a mitigation to hold.
The sinks, concretely
This is the list. It is short enough to hold in your head and specific enough to lint for, and knowing it converts a vague anxiety about user content into a mechanical property of a diff. Two things are worth noticing: several of these do not look like HTML at all, and framework interpolation is deliberately absent because it is the safe path.
Also notice the URL sinks. A value bound to href or src is not HTML and is not escaped by anything your framework does — it is a URL, and the dangerous part is the scheme.
1// ── MARKUP SINKS: the string is parsed as HTML ──────────────────────2el.innerHTML = value // sink3el.outerHTML = value // sink4el.insertAdjacentHTML('beforeend', v) // sink5document.write(value) // sink, and it blocks the parser too6<div dangerouslySetInnerHTML={{ __html: value }} /> // React opt-out7<div v-html="value" /> // Vue opt-out8{@html value} // Svelte opt-out9 10// safe form: let the value stay data11el.textContent = value12<div>{value}</div> // React / Svelte: escaped, always13<div>{{ value }}</div> // Vue: escaped, always14 15// ── CODE SINKS: the string is parsed as JavaScript ──────────────────16eval(value)17new Function(value)18setTimeout(value, 0) // string form only; a function is fine19el.setAttribute('onclick', value)20 21// safe form: there isn't one. Pass data, not code.22setTimeout(() => run(value), 0)23 24// ── URL SINKS: the string is parsed as a URL, and the scheme is code ─25<a href={value}> // javascript: navigates AND executes26<iframe src={value} /> // data: and javascript: both matter here27<form action={value}> // and formaction on the submit button28el.setAttribute('xlink:href', value) // SVG, and it is often forgotten29 30// safe form: parse it and check the scheme against an allowlist31function safeHref(raw: string, base = location.href): string | undefined {32 let url: URL33 try { url = new URL(raw, base) } catch { return undefined }34 return url.protocol === 'https:' || url.protocol === 'mailto:'35 ? url.href36 : undefined // render as plain text, or drop the link entirely37}The URL block is the one that surprises people. Frameworks escape *text*; they do not inspect the value you bound to href, so a scheme check is application code you have to write.
What the developer actually sees
These bugs rarely announce themselves. They are found by review, by a report, or by a symptom that looks like something else entirely. The rows below are how this surfaces in practice, and each one is worth recognising early because the second column is usually attributed to a rendering bug first.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A user's display name contains angle brackets | The name renders as blank, or as a broken element | The value is going through innerHTML and being parsed as markup | Render as text. The disappearing name and the vulnerability are the same bug wearing two hats. |
A link in user content points at javascript: | Clicking it runs code with the page's authority | The value was bound straight to href with no scheme check | Parse with URL and allowlist the scheme; render as text when it fails (What Native Elements Already Do). |
| A markdown renderer is passed raw HTML pass-through | Nothing visible; the feature works as specified | Most markdown libraries permit inline HTML by default | Disable HTML in the renderer, or sanitize its output (Sanitization and Trusted HTML). |
| CSP report-only starts reporting inline script | A stream of violations from a page with user content | Either an injection, or legitimate inline script you did not know about | Triage by source. Both answers are worth having before you enforce (Content Security Policy). |
| A query parameter is written into the page | A crafted link behaves differently from a normal one | DOM-based reflection; server logs show nothing unusual | Treat location as untrusted input everywhere it is read (The URL Is Application State). |
| The sanitizer was upgraded | Formatting quietly disappears from old content | The allowlist changed, and previously-permitted markup is now stripped | Pin and review sanitizer configuration explicitly rather than relying on its defaults. |
How to build it
Most important first.
- Default to text. If a value can be rendered as text, render it as text —
{value},textContent, a bound text attribute. This handles the overwhelming majority of user content and costs nothing (Semantics Are Behaviour). - Make the opt-outs greppable and reviewed.
innerHTML,dangerouslySetInnerHTML,v-html,{@html},outerHTML,insertAdjacentHTML,document.write,eval,new FunctionandsetTimeoutwith a string argument should each be a lint rule with a named allowlist. - Validate URLs before they reach
href,src,action,formactionorxlink:href. Parse the URL and check the scheme against an allowlist; a substring check forjavascript:is defeated by whitespace and encoding that the parser normalises and your check does not. - Prefer structured content over HTML strings. Markdown rendered to a node tree you construct, or a small block schema, removes the sink instead of guarding it (What a Component Owes Its Caller).
- If HTML must be rendered, sanitize with a maintained allowlist library at the point of rendering, not the point of storage (Sanitization and Trusted HTML).
- Add a Content Security Policy as the second layer, and treat it as a cap on impact rather than a fix (Content Security Policy).
- Keep session credentials out of script-readable storage where the architecture allows it, so a successful injection does not immediately become a portable session (Cookies vs Script-Readable Tokens).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Replacing a subtree with
innerHTMLdestroys focus. If the user was inside that subtree, focus falls back to the document body and a keyboard or screen-reader user is dropped to the top of the page mid-task (Focus Management). - The same rebuild breaks ARIA relationships that point by id:
aria-labelledby,aria-describedby,aria-controlsandaria-activedescendantall become dangling references pointing at elements that no longer exist (The Rules of ARIA). - Injected content can supply
role,tabindexand ARIA attributes, which means an injection is also an accessibility-tree injection — a screen-reader user can be told something entirely different from what is on screen (The Accessibility Tree). - Rendering untrusted HTML into a live region means an attacker chooses what is announced. Live regions should carry text you constructed, never a sanitized blob (Live Regions and Announcement).
- When a sanitizer strips content, the resulting gap needs to be perceivable rather than an empty node — otherwise the failure is visible to sighted users as a blank space and invisible to everyone else.
What can go wrong
- A denylist that filters
<script>andon*attributes and missesjavascript:in anhref, adata:URL in an iframe, or an SVG event attribute. Denylists fail by omission and you find out from someone else. - Escaping applied in the wrong context: HTML-escaping a value that lands inside a URL, or inside a
<style>block, produces a string that is safe for a parser you are not using (Custom Properties). - A "safe" wrapper component that takes an
htmlprop and is used in eleven places, three of which pass a value that came from an API. - Server-rendered state serialised into the page — a JSON blob in a
<script>tag — without escaping the sequences that terminate the script element (Server-Side Rendering). - The mitigation failing: a sanitizer configured with
ALLOW_UNKNOWN_PROTOCOLS, or upgraded past a breaking change and silently reverted to defaults that permit more than intended. - A framework escape hatch used for a legitimate reason — an editor, a CMS field — that later becomes the general-purpose "render this string" component.
- Sanitization racing render: an optimistic update showing raw user content while the sanitized version is still being computed puts unsanitized markup on screen for a frame (Optimistic UI).
- A CSP delivered via
metaafter the parser has already reached user-controlled markup does not cover it, so injection timing relative to policy delivery matters (Streaming HTML). - Two async writes to the same container with
innerHTMLcan interleave so that the second overwrites a sanitized result with an unsanitized one from a slower earlier request (Out-of-Order Responses).
- Script executing in your origin has your origin's full authority: same-origin
fetchwith cookies attached,localStorage, the DOM, and the ability to rewrite what the user sees while doing it. HttpOnlyprevents a cookie being *read* by script; it does not prevent script sending an authenticated request from the page. The attacker does not need the token if they can use the session in place (Cross-Site Request Forgery).- CSP with nonces or hashes and no
unsafe-inlineblocks a large fraction of injected script, and is defeated by injections that reuse an already-allowed origin or an allowed nonce (Content Security Policy). - Trusted Types, where available, turns the sinks themselves into checkpoints so that an unaudited assignment throws instead of executing (Sanitization and Trusted HTML).
- Exploitation technique, payload construction and the full taxonomy live in Security Engineering; this lesson is about which line of your UI code is the sink.
- "React is XSS-proof." React escapes text interpolation. It also ships
dangerouslySetInnerHTML, does not validatehrefvalues you pass it, and will happily render ajavascript:URL you constructed. - "We strip tags on input, so we are safe." Input filtering does not know the output context, and there are several output contexts. Escape on output, in the context you are writing into.
- "Only user-submitted text is untrusted." Query parameters, hash fragments,
postMessagedata, storage values, and fields from your own API are all untrusted for this purpose. - "It is only reflected, so the impact is lower." A crafted link in an email lands in the victim's authenticated session exactly like a stored payload does.
- "A CSP means we can render whatever we like." A policy limits what injected content can do. It does not stop the injection, and a misconfigured policy stops very little (Content Security Policy).
- "
HttpOnlycookies mean an injection cannot do anything." It cannot read the cookie. It can still make the request.
Measuring it, and what changes in the field
- Grep is a genuine security tool here. A count of sink usages per repository, tracked over time, is the single most informative metric this lesson has.
- CSP in report-only mode reports inline script execution and blocked sources from real browsers, which finds both injections and the surprising amount of legitimate inline script you did not know you shipped (Content Security Policy).
- A linter —
react/no-danger,vue/no-v-html, or an equivalent — turns each opt-out into a reviewed exception rather than a silent one (Choosing the Test Level). - Error tracking surfaces sanitizer throws and URL-parse rejections, which are the signal that untrusted content is arriving in a shape you did not expect (Frontend Error Tracking).
- The Elements panel shows what actually landed in the DOM after sanitization, which is frequently not what the sanitizer configuration implies (A Mental Model of the Devtools).
- On a large document,
innerHTMLre-parsing and a client-side sanitizer both scale with content size and both run on the main thread, so a long comment thread makes a security control into a responsiveness problem (Long Tasks). - On a slow device, sanitizing on every render rather than once at ingestion into component state is felt directly as input lag.
- In a server-rendered application, the same value is escaped by two different systems with two different context models, and the mismatch between them is where the bug lives (Hydration Mismatch).
- In an application with a rich-text editor, HTML rendering is a product requirement rather than a shortcut, and the whole weight moves onto sanitizer configuration.
- Text-only rendering removes the class entirely and removes formatting. When formatting is a genuine requirement, a structured content model costs more to build and is safe by construction.
- Client-side sanitization is flexible and costs main-thread time on every render; server-side sanitization is cheaper per view and cannot see the rendering context the client will use.
- Strict lint rules on sinks generate friction and exceptions. That friction is the mechanism: an exception someone had to justify is one someone read.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe context-confusion mechanism, the sink list and the default-escaping behaviour of mainstream frameworks hold across browsers; what varies is which exotic markup a given engine will still execute, which is why a denylist is unsafe on every one of them.
- FRAMEWORK-SPECIFICThe name of the opt-out differs —
dangerouslySetInnerHTMLin React,v-htmlin Vue,{@html}in Svelte,[innerHTML]in Angular — and so does the surrounding behaviour: Angular sanitizes bound HTML by default through its own sanitizer, whereas React and Svelte insert the string with no sanitization whatsoever. - SPEC-EVOLVINGTrusted Types and the built-in
SanitizerAPI are shipping unevenly: Trusted Types is available in Chromium and not in Safari at the time of writing, so a design that depends on either needs a fallback and a feature check rather than an assumption.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — why a unit test proves a sanitizer configuration and cannot prove the absence of a sink, and what that means for where this belongs in a test strategy.