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.

FrameworkEscape hatchTypical legitimate useRisk if input is untrusted
ReactdangerouslySetInnerHTMLRendering sanitized CMS/markdown outputFull DOM XSS if the string isn’t sanitized
Vuev-html directiveRich text fields, WYSIWYG editor outputFull DOM XSS, same as above
AngularbypassSecurityTrustHtml / bypassSecurityTrustResourceUrlEmbedding trusted iframes, sanitized rich textExplicitly disables Angular’s built-in sanitizer
Svelte{@html ...}Same category as v-htmlFull 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

  1. Inventory every script source currently loaded (first-party bundles, CDNs, tag managers, payment/analytics widgets).
  2. Ship Content-Security-Policy-Report-Only with a nonce + strict-dynamic policy and watch the reports endpoint for at least one full traffic cycle (including any weekly batch jobs or cron-triggered admin pages).
  3. Fix or explicitly allowlist the legitimate violations (usually inline event handlers like onclick="..." that need converting to addEventListener, and third-party scripts that need nonce support upstream).
  4. Flip to enforcing Content-Security-Policy once the report-only run is clean.
  5. 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.
  6. 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-dynamic is 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-src alone cannot touch, by making innerHTML and 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 dangerouslySetInnerHTML calls across a codebase guarantees one gets missed.