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 bypassSecurityTrustshould 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 toindex.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: addngCspNonce="RANDOM"to your root element (<app-root ngCspNonce="...">) or provide theCSP_NONCEtoken, 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.
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
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


