Get listed

XSS: encode the sink, then enforce Trusted Types (2026)

Coral ink soaks a cream page. A capped teal pen sits on the blotter.

Most XSS bugs start as a string you trusted, not as a clever bypass of a WAF.

A comment field, a search box, or a URL parameter is written into HTML. The browser parses it as markup. A script runs in the victim’s origin. From there the attacker can read cookies the page can read, call APIs the user can call, and change what the page shows.

The usual mistake is treating a sanitizer as a complete defense, or treating Content-Security-Policy as something you add after the app is done. Encoding at the sink, a strict CSP, and HttpOnly on the session cookie are three different controls. Each one fails in a different way if you skip the others.

This page is where the string becomes markup, what current browsers still execute, and the tests that prove a payload is dead before you close the ticket.

You shipped a comment preview. Design wanted bold and lists, so the API started returning HTML. The default escape made the preview look wrong, and the fastest path was innerHTML, v-html, or dangerouslySetInnerHTML. That assignment is the bug. Cross-site scripting is still the moment untrusted markup is parsed as the user’s page.

MITRE’s 2025 CWE Top 25 still ranks CWE-79 first. In the 2025 OWASP Top 10, injection sits at A05, and that bucket includes more than 30,000 XSS CVEs. The ranking is enough.

You get the jobs an attacker is trying to do, the sinks that still win, the defenses that hold, and the tests that prove a fix. For the rest of the request-and-object surface, keep the secure coding checklist next to this page. Injection that never becomes HTML is a different page: start at injection. Cookie requests the user did not mean are CSRF.

The same comment string reaches three sinks. textContent encodes the tags as characters. innerHTML parses them into a node with a handler. Trusted Types throw TypeError on a plain string.

SecureCoding

Three jobs, one weakness

Stored, reflected, and DOM XSS are textbook labels for the same CWE-79 failure: the product does not neutralize user-controllable input before that input becomes part of a page. The useful split is the job the attacker is trying to do.

  • Write once, hit everyone. Stored XSS is a record your app later renders to other people. A profile bio, a support ticket, a markdown post, a filename shown in an admin queue. The attacker stores markup. Your renderer does the rest.
  • Ride a request the victim will open. Reflected XSS is data in a request your server echoes: a search term, an error string, a redirect target. It needs a click, an embed, or a mail. The response looks like your origin, so the browser treats the reflected string as yours.
  • Never touch the HTTP body. DOM XSS is your own client script reading a source (location hash, query string, postMessage, localStorage) and writing it into a sink. A scanner that only diffs HTML responses will miss it. The page still executes.

Self-XSS (paste this into your console) is social engineering. It’s not a renderer bug, and it shouldn’t eat the sprint.

If the session cookie is HttpOnly, is the XSS still worth fixing? Yes. Script running in your origin can still rewrite the DOM, fire any request the user is allowed to make, and read anything the page can already see. That includes a forged state-changing request if the page already holds the anti-CSRF token, and any object the UI was about to load. Cookie flags shrink what document.cookie returns. They leave the script in the page.

Why the escape hatches keep winning

innerHTML, outerHTML, document.write, insertAdjacentHTML, eval, and new Function treat a string as code. Template concatenation is the same bug with extra quotes. You concatenate because it’s one line and the preview looks right. The browser then parses whatever landed in the string.

Modern frameworks closed the default path. Interpolation goes through textContent or an equivalent encoder. Then each framework shipped a hatch for the day someone needs real markup:

  • React: dangerouslySetInnerHTML
  • Vue: v-html (and innerHTML in a render function)
  • Angular: [innerHTML], which sanitizes until you call bypassSecurityTrustHtml

The OWASP XSS Prevention Cheat Sheet names those hatches as the remaining gaps. I checked that page against the current React, Vue, and Angular security docs. The wording differs. The pattern doesn’t: default interpolation is safe, the hatch is a sink, and template-string assembly of component markup is treated as executable code.

A 2026 variant of the same hatch is AI-generated HTML pasted into a template. The model doesn’t encode for your sink. If you drop that string into innerHTML, you inherited every tag it invented. The same hatch shows up in the React checklist as dangerouslySetInnerHTML.

Firefox 148 setHTML is not a free pass

Firefox 148 shipped on 24 February 2026. The release notes and the Mozilla Hacks writeup both name two APIs: Trusted Types, and the HTML Sanitizer API with element.setHTML(). MDN’s setHTML page is the contract: the method parses HTML, drops XSS-unsafe tags and event handlers even if your sanitizer config asked to keep them, then inserts the rest.

That is a safer insert than innerHTML. It is not a text sink. Markup still becomes markup. Aachen said the quiet part on that same thread: you can still inject headings, breaks, and style into a username if the renderer asked for HTML. Script execution is the bug class setHTML is built to refuse. Spoofed chrome is a different class. If the field is a name, use textContent.

Defenses that hold

No single control closes XSS. You encode for the sink, you refuse raw strings at the platform, you constrain what scripts may run, and you sanitize only when the product must accept HTML. Input validation still belongs on the way in (type, length, allow-list). It doesn’t replace encoding at render. A chat emoticon that contains < is valid input and still unsafe in an HTML body.

Contextual encoding

OWASP calls the target “perfect injection resistance”: every variable is validated, then escaped or sanitized for the context it lands in. HTML body, HTML attribute, JavaScript string, CSS value, and URL query are different encodings. HTML-entity encoding inside a <script> block won’t save you. Unquoted attributes let a space change the context.

Prefer sinks that never execute. textContent, insertAdjacentText, a hardcoded setAttribute name such as id or class, and a form field’s value treat the string as text. Do not put variables into script bodies, HTML comments, event-handler attributes, or unquoted attributes. Those are dangerous contexts even after encoding.

// BAD: query is attacker-controlled and lands in an HTML sink
results.innerHTML = "Results for " + query;
//... string is parsed as markup
// FIX: the search term is text, so use a text sink
results.textContent = "Results for " + query;

Trusted Types

MDN’s Trusted Types page marks the API Baseline 2026, newly available across current browsers since February 2026. A CSP of require-trusted-types-for 'script' makes DOM XSS sinks reject a plain string. innerHTML = userHtml throws a TypeError. The only legal assignment is a TrustedHTML (or TrustedScript / TrustedScriptURL) created by a policy you named.

You write the policy. The browser doesn’t sanitize for you. A typical policy delegates HTML to a sanitizer and refuses script sinks:

const policy = trustedTypes.createPolicy("app-html", {
 createHTML: (input) => DOMPurify.sanitize(input),
 createScript: () => { throw new TypeError("script sinks are closed"); },
 createScriptURL: () => { throw new TypeError("script URLs are closed"); },
});

preview.innerHTML = policy.createHTML(userHtml);

Angular’s security guide still says Trusted Types might not be available in every browser you target. MDN says Baseline 2026. Those two official pages disagree on readiness. Treat enforcement as real on current engines, keep contextual encoding for everyone, and use the tinyfill only after you have tested the same code path under require-trusted-types-for 'script' on a supporting browser.

Do not assume Safari throws on the same sink list Chromium documents for require-trusted-types-for 'script'. Public WebKit notes announce support. They do not enumerate every injection sink that rejects a string.

CSP that is actually a CSP

A CSP Level 3 policy that still contains 'unsafe-inline' in script-src is theatre. Injected inline script is one of the common XSS shapes, and 'unsafe-inline' tells the browser to run it. Use a per-response nonce or a hash. Block plugins. Lock <base>. Add Trusted Types on top.

Content-Security-Policy:
 script-src 'nonce-{RANDOM}';
 object-src 'none';
 base-uri 'none';
 require-trusted-types-for 'script';
 trusted-types app-html default;
 report-uri /csp-report;
 report-to csp

Nonce and hash policies are the “strict CSP” shape. Host allow-lists grow, rot, and get bypassed through JSONP and forgotten CDNs. 'strict-dynamic' helps when a trusted script must load children, and it also means a compromised trusted script can load more. Start in Report-Only, then enforce. Pair the header with the rest of your response headers. CSP is a backstop, not the primary defense.

Mint the nonce per HTML response. A nonce baked into a CDN-cached shell is a static allow. Hash the few inline boot scripts you cannot move; anything that changes every deploy belongs on a nonce. If you are still choosing headers, the Helmet.js guide is the Express path for the same CSP.

Cookies do not fix the sink

Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax

HttpOnly stops document.cookie from reading that cookie. Secure keeps it off HTTP. SameSite reduces cross-site sending. CWE-79 and the OWASP cheat sheet both treat cookie attributes as impact reduction. Cookie flags are mitigation, not a fix. Tokens in localStorage have no such flag. If the page can read the token, so can script in the page.

Sanitizers are a last line

When the product must accept user-authored HTML (a rich-text field, a CMS body), encoding will show tags as text and break the feature. Then you sanitize. OWASP recommends DOMPurify. The call is one line:

const clean = DOMPurify.sanitize(dirty, { USE_PROFILES: { html: true } });

The caveats are the job. If you sanitize and then mutate the string, you can void the work. If you hand the result to a library that rewrites markup, same. Patch DOMPurify; bypasses track browser parser changes. On the server, feed it current jsdom. The project says happy-dom is not safe for this and will likely produce XSS. A sanitizer is what you use after the page must interpret HTML. Don’t reach for it on a name, an email, or a search term.

Call DOMPurify.sanitize in the same function that writes the sink. If a helper returns cleaned HTML and a caller concatenates a title or a class, the sanitizer never saw the final string.

Framework notes

React

JSX text and attribute interpolation escape. Stay on that default. React’s own docs say dangerouslySetInnerHTML overrides innerHTML and that untrusted markup is an XSS hole. Build the { __html } object next to the trusted generator. Don’t create it inline in JSX. Sanitize markdown or CMS HTML first. React won’t do it for you.

// BAD: storage contents go straight into the hatch
return <div dangerouslySetInnerHTML={{ __html: comment }} />;
// FIX: default escaping, no hatch
return <p>{comment}</p>;

React also won’t special-case javascript: or data: URLs in href or src without your own check. Encode the URL, then allow-list the scheme.

Angular

Interpolation is escaped. Binding to [innerHTML] runs Angular’s sanitizer, which strips executable bits and keeps safe tags. Audit those bindings. The hatch is DomSanitizer.bypassSecurityTrustHtml (and the other bypassSecurityTrust* methods). Calling it says you inspected the value. If a user can control the value, you just disabled the control.

// BAD: marks attacker-controlled HTML as trusted
this.html = this.sanitizer.bypassSecurityTrustHtml(userHtml);

Never concatenate user input into a template string and compile it. Angular treats templates as executable. Production should stay on the AOT compiler. If you enforce Trusted Types, Angular needs the angular policy, and any bypass call needs angular#unsafe-bypass on the trusted-types allow-list. That allow-list entry is a smell. Hunt the call sites.

Vue

Mustache interpolation and attribute bindings escape through native textContent and setAttribute. Vue’s security guide is blunt about the hatch: v-html (and innerHTML in a render function) renders HTML. User-provided HTML is not 100% safe unless it is sandboxed or only ever shown to the author. Do not concatenate user strings into template. Vue compiles templates to JavaScript.

<!-- BAD: v-html skips default escaping -->
<div v-html="userHtml"></div>

<!-- FIX -->
<h1>{{ userHtml }}</h1>

How you verify the fix

You’re not walking an exploit. You’re proving the renderer refused the sink.

  • Trusted Types. With require-trusted-types-for 'script' on, assign a plain string to the old sink in a unit or browser test. You want a TypeError (or a SecurityPolicyViolationEvent if you are still in report-only). A default policy that logs and sanitizes is a migration aid. Do not leave it as the long-term design.
  • CSP reports. Point report-uri (and report-to, once the Reporting API is on) at an endpoint you read. A fix is real when the renderer path stops showing up as script-src or Trusted Types violations, and when a deliberate inline script without a nonce does show up.
  • A failing unit test around the renderer. Assert the comment (or search, or bio) component writes through textContent or a sanitizing policy, and that its source does not contain innerHTML, dangerouslySetInnerHTML, v-html, or bypassSecurityTrustHtml.
test("comment renderer stays on a text sink", () => {
 const src = renderComment.toString();
 expect(src).not.toMatch(/innerHTML|dangerouslySetInnerHTML|v-html/);
 const el = renderComment("hi <b>there</b>");
 expect(el.textContent).toContain("hi <b>there</b>");
 expect(el.innerHTML).not.toMatch(/<b>/);
});

That test should fail on the old renderer and pass on the new one. If you need user-authored HTML, invert it: the policy ran, and a script or event-handler attribute from the fixture is gone while an allowed paragraph remains.

The verify step is that split in code. textContent when the field is a name. setHTML or a named Trusted Types policy when the product must render HTML. No innerHTML leftover next to either.

Can I turn on a WAF and ship? No. WAFs look for known strings and miss DOM-only XSS entirely. OWASP doesn’t recommend a WAF as an XSS control. Use it as a tripwire if you must. Encode the sink anyway.

What still bites you after the patch

The renderer you own is the part you can test. The leftovers hide in features that must interpret markup:

  • Markdown renderers. A library that turns markdown into HTML is a parser you now trust. CommonMark plus a sanitizer is the floor. “Safe by default” flavors still grow raw-HTML opts. React’s docs walk a markdown preview through dangerouslySetInnerHTML and warn that you are trusting the parser.
  • PDF and HTML previews. “View in browser” for an uploaded document is a renderer. PDF.js, Office-to-HTML converters, and email preview panes have all been XSS hosts. Treat the preview origin as untrusted content: separate origin if you can, sanitizer plus CSP if you cannot.
  • Rich-text editors. The editor produces HTML. The stored HTML is the stored XSS. Sanitize on output (and on input if you like), and do not re-hydrate the editor from unsanitized storage.
  • AI-generated HTML. Assistants emit tags you did not type. Paste into a template only after the same policy you use for user HTML. A system prompt that says “do not emit script” is not a control.

Those features are why Trusted Types plus a named policy pay off. You want one function in the repo that is allowed to create TrustedHTML, and you want every preview, editor, and markdown path to go through it.

Questions we keep getting

Does a single-page app make reflected XSS go away?

No. The server may no longer echo a query into HTML, but the client still reads location, route params, and postMessage. That is the DOM job. An SPA that writes those values into innerHTML is a reflected-plus-DOM bug with extra routing.

Should I still send X-XSS-Protection?

No. Chrome removed XSS Auditor. The header is inert or harmful in modern engines. Spend the header budget on CSP, Trusted Types, and cookie flags.

If I sanitize on save, do I still encode on render?

Yes. Storage is not a context. A later feature will read the same column into a different sink (an attribute, a JSON blob, a markdown round-trip). Encode for the sink you are writing to. Sanitize when that sink must interpret HTML. Do both if the string has already been through a rich-text editor.

Alfrik Opidi

Alfrik Opidi / About Author

Alfrick is a full-stack web developer with extensive experience in developing robust, futuristic, and secure applications. Heโ€™s worked with a wide range of software, system architectures, and programming languages. Notably, heโ€™s been involved in a variety of projects that aim to find, fix, and tighten the security of web applications.ย See him as a technology enthusiast with a keen eye on making the latest developments in the industry feasible, decipherable, and known to all.ย In his free time, he likes participating in bike racing, playing games, or just stargazing. You can connect with him onย LinkedIn,ย GitHub, or via hisย website.