XSS in 2026: Content Security Policy, Trusted Types, and Framework Escape Hatches
A practical look at reflected, stored, and DOM XSS today, and how nonce-based CSP, strict-dynamic, and Trusted Types close the gaps frameworks leave open.
Cross-site scripting is one of the oldest vulnerability classes on the web, and it is still routinely found in production applications built on React, Vue, and Angular in 2026. That is not because developers got careless — it is because the attack surface moved. Modern frameworks auto-escape template output by default, which quietly eliminated most of the “classic” injection points. What is left is concentrated in a small number of deliberate escape hatches, third-party widgets, and server-rendered fragments that frameworks never touch.
This shift changes how you should think about XSS defense. Auto-escaping reduces the number of vulnerable sinks, but it does nothing to reduce the blast radius when one of the remaining sinks is abused. A single dangerouslySetInnerHTML call fed by user input, or a third-party analytics snippet that gets compromised upstream, can still hand an attacker a fully working session hijack. Relying on “the framework escapes by default” as your only line of defense is a bet that every developer, on every pull request, forever, will remember where the escape hatches are.
The layered answer that has matured over the past few years combines three things: understanding where each class of XSS actually originates, a strict Content Security Policy built on nonces rather than domain allowlists, and Trusted Types to make DOM-based sinks refuse untrusted strings at the browser level. None of these is sufficient alone. Together, they turn a single missed escape into a contained bug instead of a full account takeover.
The Three Classes of XSS, Refreshed
Reflected XSS happens when user-controlled input from the request (a query parameter, a form field) is echoed back into the response HTML without encoding. It requires tricking a victim into clicking a crafted link, so it is usually delivered via phishing or a malicious referring page.
Stored XSS is the more dangerous variant: the payload is persisted server-side (a comment, a profile bio, a support ticket) and served to every visitor who views that content, no phishing required. A single stored payload in an admin-facing view can compromise every operator who opens it.
DOM-based XSS never touches the server at all. Client-side JavaScript reads an attacker-controlled value — location.hash, document.referrer, a postMessage payload, an API response — and writes it into a dangerous sink (innerHTML, document.write, eval, setAttribute("href", ...) with a javascript: URL). This is the class that has grown the most as applications became client-heavy single-page apps, and it is also the class that CSP and Trusted Types target most directly, because the vulnerable code path lives entirely in the browser.
Server-side templating auto-escapes reflected and stored injection points reasonably well today (Jinja2, Blade, Rails ERB with <%= %>, and JSX all escape by default). DOM XSS is where teams keep getting hurt, because the sink is a JavaScript API call, not a template directive, and static analysis tools miss it far more often.
Content Security Policy: From Blocklist to Nonce-Based Allowlist
Early CSP deployments tried to allowlist trusted script origins (script-src 'self' https://cdn.example.com). This aged badly: any script hosted on an allowed origin — including a compromised third-party library, an open JSONP endpoint, or an uploaded file served from the same domain — becomes a valid injection vector. Domain allowlisting also breaks the moment you add a new vendor script and forget to update the header.
The modern approach uses a per-response cryptographic nonce. The server generates a fresh random value on every request, includes it in the CSP header, and stamps the same value onto every <script> tag it renders. The browser only executes scripts carrying a matching nonce; an attacker who injects a <script> tag has no way to guess it.
# nginx.conf — CSP header with a per-request nonce
# The nonce itself must be generated per-request by your app server
# (nginx alone can't mint per-request randomness); this shows the header shape.
map $request_id $csp_nonce {
default $request_id; # illustrative only — use a real CSPRNG upstream
}
server {
listen 443 ssl http2;
server_name app.example.com;
add_header Content-Security-Policy "default-src 'self'; script-src 'nonce-$csp_nonce' 'strict-dynamic' https:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; require-trusted-types-for 'script'; report-to csp-endpoint" always;
add_header Reporting-Endpoints "csp-endpoint=\"https://app.example.com/csp-reports\"" always;
location / {
proxy_pass http://app_backend;
}
}
In practice the nonce is generated by your application layer (Express, Django, Rails, an Astro/Node SSR middleware) per request, injected into the response template, and reused as the CSP header value — nginx alone cannot mint per-request randomness safely, so treat the snippet above as the header shape, not a copy-paste nonce generator.
strict-dynamic: Letting Trusted Scripts Load Their Own Dependencies
'strict-dynamic' solves a real operational problem: modern bundles dynamically inject additional <script> tags at runtime (code-splitting, dynamically loaded chunks, third-party SDKs that bootstrap more scripts). Without strict-dynamic, every one of those dynamically created scripts would need its own nonce, which is often impossible to control.
strict-dynamic says: any script that carries a valid nonce is trusted, and anything that trusted script injects is also trusted, regardless of its origin. Domain-based script-src values are ignored by browsers that support strict-dynamic, which is exactly the point — it replaces “we trust these domains” with “we trust this specific execution, and its children.”
script-src 'nonce-r4nd0mVALUE123' 'strict-dynamic' https:;
The trailing https: is a fallback for older browsers that do not understand strict-dynamic — they will fall back to allowing any HTTPS script source, which is weaker but still better than no policy at supporting older clients during a phased rollout.
Trusted Types: Killing DOM XSS at the Sink
CSP’s script-src stops script tags from executing. It does not stop element.innerHTML = userControlledString from running arbitrary markup that the browser then parses and executes inline event handlers or embedded scripts from. That is exactly the DOM XSS class described above, and it is what Trusted Types was built to close.
Trusted Types works by making dangerous DOM sinks (innerHTML, outerHTML, Element.setAttribute for certain attributes, Function, eval, document.write) refuse plain strings once the policy is enabled. Code must first pass the string through a registered “policy” that explicitly sanitizes or validates it and returns a branded TrustedHTML/TrustedScript object.
// trusted-types-setup.ts — register a policy once, at app bootstrap
import DOMPurify from "dompurify";
if (window.trustedTypes && window.trustedTypes.createPolicy) {
window.trustedTypes.createPolicy("default", {
createHTML: (input: string) => DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: false }),
createScript: () => {
throw new Error("Dynamic script creation is not allowed by policy");
},
createScriptURL: (input: string) => {
const allowed = ["https://cdn.example.com/", "https://app.example.com/"];
if (allowed.some((prefix) => input.startsWith(prefix))) return input;
throw new Error(`Blocked script URL: ${input}`);
},
});
}
With require-trusted-types-for 'script' in the CSP header (as shown above), any assignment to innerHTML with a plain string throws a TypeError in the console instead of silently rendering attacker-controlled markup. This turns what used to be a silent DOM XSS bug into a loud, immediately visible break — caught in QA, not in production traffic logs three weeks later.
Framework Escape Hatches That Reintroduce Risk
Every major framework auto-escapes by default and gives developers an explicit way to opt out when they genuinely need to render raw HTML (rich text editors, CMS content, markdown-to-HTML pipelines). These opt-outs are exactly where audits should focus, because they are rare enough that reviewers often skip them and common enough that every real codebase has a handful.
| Framework | Escape hatch | Typical legitimate use | Risk if input is untrusted |
|---|---|---|---|
| React | dangerouslySetInnerHTML | Rendering sanitized CMS/markdown output | Full DOM XSS if the string isn’t sanitized |
| Vue | v-html directive | Rich text fields, WYSIWYG editor output | Full DOM XSS, same as above |
| Angular | bypassSecurityTrustHtml / bypassSecurityTrustResourceUrl | Embedding trusted iframes, sanitized rich text | Explicitly disables Angular’s built-in sanitizer |
| Svelte | {@html ...} | Same category as v-html | Full DOM XSS |
// React — safe pattern: sanitize immediately before the escape hatch
import DOMPurify from "dompurify";
function ArticleBody({ rawHtml }) {
const clean = DOMPurify.sanitize(rawHtml, { USE_PROFILES: { html: true } });
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
<!-- Vue — same discipline: never bind v-html directly to user input -->
<template>
<div v-html="sanitizedComment"></div>
</template>
<script setup>
import DOMPurify from "dompurify";
import { computed } from "vue";
const props = defineProps<{ rawComment: string }>();
const sanitizedComment = computed(() => DOMPurify.sanitize(props.rawComment));
</script>
// Angular — prefer never calling bypassSecurityTrustHtml on user input;
// if you must, sanitize first and keep the call in one audited helper.
import { DomSanitizer, SafeHtml } from "@angular/platform-browser";
export class TrustedHtmlPipe {
constructor(private sanitizer: DomSanitizer) {}
transform(rawHtml: string): SafeHtml {
const clean = DOMPurify.sanitize(rawHtml);
return this.sanitizer.bypassSecurityTrustHtml(clean);
}
}
The pattern is identical across frameworks: never call the escape hatch directly on untrusted data, always route through a sanitizer (DOMPurify is the de facto standard, actively maintained, and battle-tested against mutation XSS), and keep the number of call sites that use the escape hatch as small and centralized as possible so a code review can actually enumerate them.
CSP Reporting: Turning Violations into Signal
A strict CSP without reporting is a policy you can’t debug and won’t notice breaking. Configure report-to (the modern replacement for the deprecated report-uri) and collect violation reports server-side.
// Express endpoint receiving CSP violation reports
import express from "express";
const app = express();
app.use(express.json({ type: ["application/csp-report", "application/reports+json"] }));
app.post("/csp-reports", (req, res) => {
const report = req.body;
// Log to your SIEM/observability stack — a spike here often means
// either a broken deploy or an active injection attempt in the wild.
console.log("CSP violation:", JSON.stringify(report));
res.status(204).end();
});
Deploy new or changed policies with Content-Security-Policy-Report-Only first. It reports violations without blocking anything, which lets you catch legitimate scripts your policy would have broken (an analytics snippet, a payment widget iframe) before you flip to enforcing mode.
A Practical Rollout Plan
- Inventory every script source currently loaded (first-party bundles, CDNs, tag managers, payment/analytics widgets).
- Ship
Content-Security-Policy-Report-Onlywith a nonce +strict-dynamicpolicy and watch the reports endpoint for at least one full traffic cycle (including any weekly batch jobs or cron-triggered admin pages). - Fix or explicitly allowlist the legitimate violations (usually inline event handlers like
onclick="..."that need converting toaddEventListener, and third-party scripts that need nonce support upstream). - Flip to enforcing
Content-Security-Policyonce the report-only run is clean. - Add
require-trusted-types-for 'script'and register your Trusted Types policy, again starting in report-only mode if your browser support target allows it. - Grep the codebase for every escape hatch (
dangerouslySetInnerHTML,v-html,{@html,bypassSecurityTrust*,innerHTML =) and confirm each one routes through a sanitizer before merging the enforcing policy.
Key Takeaways
- Reflected and stored XSS are largely tamed by framework auto-escaping; DOM-based XSS, driven by JavaScript sinks reading attacker-controlled data, is where most current incidents originate.
- Nonce-based CSP with
strict-dynamicis meaningfully stronger than domain allowlists, which are routinely bypassed via compromised or overly broad trusted origins. - Trusted Types stops the DOM-sink class of XSS that CSP’s
script-srcalone cannot touch, by makinginnerHTMLand friends reject unbranded strings. - Every framework’s raw-HTML escape hatch (
dangerouslySetInnerHTML,v-html,{@html},bypassSecurityTrustHtml) is a legitimate feature that becomes a vulnerability the moment it touches unsanitized user input. - DOMPurify (or an equivalent, actively maintained sanitizer) belongs between untrusted input and any escape hatch — never trust “we control the CMS” as a substitute for sanitization.
- Roll out CSP and Trusted Types in report-only mode first; both are strict enough to break legitimate third-party scripts if deployed blind.
- Centralizing escape-hatch usage into a small number of audited helpers makes future code review tractable — scattering raw
dangerouslySetInnerHTMLcalls across a codebase guarantees one gets missed.