Angular Security: XSS, Sanitization, Trusted Types and CSP [2026]

Link copied
Angular Security: XSS, Sanitization, Trusted Types and CSP [2026]

Angular Security: XSS, Sanitization, Trusted Types and CSP [2026]

Angular is one of the safest front-end frameworks by default: write ordinary templates and it escapes and sanitizes values for you, so a malicious comment or a crafted URL can't run script in your users' browsers. Most XSS holes in Angular apps come from the handful of places where developers step around that protection, usually to make a rich-text field or an embedded video work, without knowing what they switched off.

This is lesson 8.9 of the Angular Tutorial, and the last lesson of Module 8. It follows lesson 8.8 on rendering performance, and it pairs with the HTTP lessons, since cross-site request forgery protection lives in HttpClient. You'll learn how Angular's sanitization works in each security context, when bypassSecurityTrust* is justified (and how to use it safely), the APIs that skip sanitization entirely, and how to add Trusted Types, a Content Security Policy and XSRF protection.

How Angular protects bindings #

Cross-site scripting (XSS) means an attacker gets their own script to run on your page, where it can read session data and act as the user. Angular treats every bound value as untrusted and handles it according to where it's going:

Binding What Angular does
{{ value }} interpolation Escapes it. HTML in the value is shown as text, never parsed
[innerHTML]="value" Sanitizes it: keeps safe markup (<b>, <a href>, <ul>…), strips scripts, event handlers and dangerous URLs
[href], [src] on images and links Sanitizes the URL: a javascript: URL becomes unsafe:javascript:… and can't run
[style] / [style.background] Allows normal CSS values; Angular's style binding doesn't evaluate URLs as script
[src] on <iframe>, <script>-like resource URLs Refuses plain strings; throws NG0904 unless the value is explicitly trusted
<script> written in a template Removed at compile time

So the default path is safe:

@Component({
  selector: 'app-comment',
  template: `
    <p>{{ comment().text }}</p>                 <!-- escaped -->
    <div [innerHTML]="comment().html"></div>    <!-- sanitized -->
    <a [href]="comment().website">Website</a>   <!-- javascript: URLs neutralised -->
  `,
})
export class Comment {
  comment = input.required<CommentData>();
}

If comment().html is <img src=x onerror="steal()"><b>Nice post</b>, the rendered output keeps <b>Nice post</b> and an <img> without the onerror handler. In development, Angular logs "sanitizing HTML stripped some content" to the console so you know it happened.

The five security contexts #

Angular's DomSanitizer classifies every dangerous binding into a context, and each has its own rules:

Context Examples Default behaviour
HTML [innerHTML], [outerHTML] Sanitized against an allow-list of tags and attributes
Style [style] Passed through as CSS (modern browsers don't execute script from CSS)
URL [href], <img [src]> Normal URLs pass; javascript: URLs are prefixed with unsafe: so they can't execute
Resource URL <iframe [src]>, <embed [src]>, <object [data]> Never sanitized automatically, because loading code from an arbitrary URL can't be made safe. Plain strings are rejected
Script (no bindings allowed) Not supported at all

The resource URL context is where most developers first meet the sanitizer, typically when embedding a video:

<!-- NG0904: unsafe value used in a resource URL context -->
<iframe [src]="'https://www.youtube.com/embed/' + videoId()"></iframe>

Bypassing sanitization safely #

DomSanitizer has five bypassSecurityTrust* methods (Html, Style, Script, Url, ResourceUrl). Each wraps a value in a marker that tells Angular "I've checked this; don't sanitize it." The name is the warning: you are taking responsibility for the value being safe.

The safe pattern is to never pass raw input to a bypass. Build the trusted value from parts you control, and validate the untrusted part strictly:

@Component({
  selector: 'app-video',
  template: `<iframe [src]="embedUrl()" title="Video" allowfullscreen></iframe>`,
})
export class Video {
  private sanitizer = inject(DomSanitizer);
  videoId = input.required<string>();

  embedUrl = computed(() => {
    const id = this.videoId();
    if (!/^[A-Za-z0-9_-]{11}$/.test(id)) {
      throw new Error(`Invalid YouTube id: ${id}`);    // reject, don't "clean"
    }
    // the origin and path are constants; only a validated id is user-controlled
    return this.sanitizer.bypassSecurityTrustResourceUrl(`https://www.youtube-nocookie.com/embed/${id}`);
  });
}

For rich HTML from users or a CMS, the default [innerHTML] sanitizer is usually enough. When you need markup it strips (for example inline style attributes, which Angular removes), sanitize with a dedicated library such as DOMPurify first, and only then bypass:

import DOMPurify from 'dompurify';

safeHtml = computed(() =>
  this.sanitizer.bypassSecurityTrustHtml(
    DOMPurify.sanitize(this.article().body, { USE_PROFILES: { html: true } }),
  ),
);

Rules of thumb for every bypass:

  • Keep it in one place. Wrap it in a small, reviewed service or pipe (SafeEmbedPipe), not scattered calls.
  • Validate, don't clean. Reject values that don't match a strict pattern instead of trying to fix them.
  • Never bypass a whole URL that came from input. Fix the origin and path in code; validate only the variable part.
  • Search for it in code review. grep bypassSecurityTrust should return a short list where each entry has a reason.

APIs that skip sanitization entirely #

Sanitization only applies to template bindings. Anything that writes to the DOM directly bypasses it, with no warning:

Unsafe Why Instead
el.nativeElement.innerHTML = html Direct DOM write, never sanitized [innerHTML] binding
renderer.setProperty(el, 'innerHTML', html) Renderer2 doesn't sanitize [innerHTML] binding, or sanitize first
document.write, insertAdjacentHTML, outerHTML = Raw HTML parsing Template bindings
Building templates from user input with the JIT compiler The input becomes Angular code, which is template injection Never compile user-provided templates
Third-party widgets that take HTML strings The library writes it as-is Sanitize before passing it in

The same goes for server-side rendering: values rendered through Angular templates are escaped on the server too, but HTML you concatenate into the response yourself (in an Express handler, for example) is not.

Trusted Types: make the browser enforce it #

Trusted Types is a browser feature that blocks raw strings from reaching dangerous DOM sinks like innerHTML; only values created by an approved policy are accepted. Angular supports it: its sanitizer and bypass methods produce Trusted Types values through named policies. Enable it with a CSP header:

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types angular angular#bundler
Policy Needed when
angular Always: Angular's own sanitization
angular#bundler Lazy-loaded chunks created by the CLI's bundler
angular#unsafe-bypass Your app uses any bypassSecurityTrust* method
angular#unsafe-jit Your app uses the JIT compiler (rare in production)

Leaving out angular#unsafe-bypass is a strong guarantee: any bypass call, including one a dependency makes, fails loudly instead of silently opening a hole. Roll it out with Content-Security-Policy-Report-Only first, so violations are reported without breaking anything.

A Content Security Policy for Angular #

A CSP tells the browser where scripts and styles may come from, so injected script can't run even if something slips through. Angular apps need two things from it:

  • Scripts: your own bundles ('self'). Since v19 the CLI can generate hash-based CSP for the inline scripts it adds to index.html: set "security": { "autoCsp": true } in your build options.
  • Styles: Angular inserts component styles as <style> elements at runtime, so the policy must allow them. Rather than 'unsafe-inline', give them a per-request nonce: add ngCspNonce="RANDOM" to your root element (<app-root ngCspNonce="...">) or provide the CSP_NONCE token, and send the same value in the header.
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'nonce-RANDOM'; object-src 'none'; base-uri 'self'

The nonce must be freshly generated for each response, which means it's set by your server or SSR handler (Module 9), not hard-coded in the build.

XSRF: protecting requests, not just rendering #

Cross-site request forgery (XSRF/CSRF) tricks a logged-in user's browser into sending a request to your API from another site. HttpClient has built-in support for the common cookie-to-header defence: if your server sets a cookie named XSRF-TOKEN, Angular copies it into an X-XSRF-TOKEN header on mutating requests (POST, PUT, PATCH, DELETE) to relative URLs. The server then checks they match.

provideHttpClient(
  withXsrfConfiguration({ cookieName: 'CSRF-TOKEN', headerName: 'X-CSRF-TOKEN' }),  // match your backend
),

Two things to know: the header is not added for absolute URLs or GET requests, and the protection only works if the server validates the token. SameSite=Lax or Strict cookies add a second layer.

A security checklist #

Area Do
Templates Use bindings; let Angular escape and sanitize
Rich HTML [innerHTML], or DOMPurify + one reviewed bypass
Embeds Fixed origin in code, strictly validated IDs, bypassSecurityTrustResourceUrl in one place
DOM access No innerHTML via ElementRef or Renderer2 with untrusted data
Browser enforcement Trusted Types + CSP, rolled out in report-only mode first
API calls XSRF tokens validated server-side; SameSite cookies
Dependencies Keep Angular and libraries updated; security fixes ship in patch releases

Gotchas #

Symptom Cause Fix
"Sanitizing HTML stripped some content" [innerHTML] removed unsafe or non-allow-listed markup Expected for untrusted HTML; use DOMPurify + bypass only if you need more
NG0904 on an iframe Plain string bound to a resource URL Build the URL from constants + validated ID, then bypassSecurityTrustResourceUrl
Inline style attributes disappear Angular's HTML sanitizer strips them Use classes, or DOMPurify + bypass
Link renders as unsafe:javascript:… Unsafe URL scheme Working as intended; don't bypass user URLs
Styles missing after adding CSP Runtime <style> elements blocked Add a nonce via ngCspNonce or CSP_NONCE
No X-XSRF-TOKEN header sent Absolute URL, GET request, or cookie name mismatch Use relative URLs or a proxy; configure withXsrfConfiguration

What's next #

That completes Module 8: you can now make an Angular app fast to load, fast to interact with, free of leaks and safe by default. Module 9 moves to the server: server-side rendering, hydration and incremental hydration, prerendering, and the per-request concerns like CSP nonces that SSR makes possible.

Try it yourself #

Search your codebase for bypassSecurityTrust, innerHTML and setProperty(. For each hit, write down where the value comes from. Any value that can contain user or CMS input without strict validation or DOMPurify is a finding; fix the riskiest one using the patterns above.

YouOur product pages show descriptions from the CMS with [innerHTML], but the editors’ colours and alignment disappear. A teammate wants to wrap everything in bypassSecurityTrustHtml. Is that OK?
Claude · used search_documentationNot as-is. The colours disappear because Angular’s HTML sanitizer strips inline style attributes, which is expected. Bypassing the raw CMS HTML would also let through any <script> or onerror handler that reaches the CMS, whether from a compromised editor account or a pasted snippet. Safer options, in order: (1) have the CMS output classes (text-center, brand-red) instead of inline styles, and keep plain [innerHTML]; or (2) add a single SafeCmsHtmlPipe that runs DOMPurify.sanitize(), allowing only the style properties you need, and then calls bypassSecurityTrustHtml. Use it only in ProductDescription. With Trusted Types enabled, you’ll then need the angular#unsafe-bypass policy, which is a useful reminder that this pipe is your one reviewed exception.

Up next in Angular

More from this topic

View all Angular articles →
Angular

When Angular is launched ?

Link copied Angular When Angular is launched ? February 8, 2024 · 1 min read When was Angular launched? # Angu…

Feb 8, 2024 Read →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *