Angular starts from one simple rule: every value bound in a template is considered unsafe, until proven otherwise. Interpolation escapes the text. [innerHTML] goes through the sanitizer. URLs with dangerous schemes are neutralized. That is why an XSS in an Angular application almost always appears when someone bypasses these protections, not when they use them.
This page shows what Angular does automatically, where the “side doors” are that you should look for at code review, and how to add the second line of defence: Content Security Policy and Trusted Types. At the end is the XSRF protection in HttpClient.
localStorage. The default protections cover the common cases. The rest depends on what you do with bypassSecurityTrust* and with the DOM.Security contexts
Angular chooses the treatment according to where the value ends up. In text it escapes it. In HTML ([innerHTML]) it sanitizes it: it keeps the formatting tags and removes <script>, the on* attributes, <iframe> and dangerous URLs. In a URL ([href], [src]) it neutralizes schemes such as javascript:. Resource URL (the source of an <iframe> or of a script) is the strictest context, because there a URL means code that gets executed.
<!-- Interpolation: the text is ESCAPED — “<b>” shows up literally, it does not become a tag -->
<p>{{ comment.text }}</p>
<!-- [innerHTML]: the HTML is SANITIZED — formatting tags stay, script and event handlers are removed -->
<div [innerHTML]="comment.html"></div>
<!-- URLs: javascript: and other dangerous schemes become “unsafe:…” -->
<a [href]="profile.website">site</a>
<!-- Styles: values in [style] are checked too -->
<div [style.background-image]="'url(' + user.avatar + ')'"></div>[innerHTML] (sanitized automatically) Hello, world! See docs.
sanitizer.sanitize(SecurityContext.HTML, …)<p><b>Hello</b>, <em>world</em>! See <a href="https://angular.dev">docs</a>.</p>
nothing removed the content was already safe.
With bypassSecurityTrustHtml(…) Angular would insert exactly the text in the box, including event handlers. That is why the demo does not do this.
URLs
A link with an href that comes from the user (“my website”) is a classic attack vector: javascript:alert(document.cookie) runs code on click. Angular prefixes unsafe values with unsafe:, so the browser can no longer execute them. Common schemes (https:, mailto:, relative paths) pass through unchanged.
Template: <a [href]="url()"> → the attribute in the DOM:
bypassSecurityTrust and the side doors
DomSanitizer has five bypassSecurityTrust… methods (Html, Script, Style, Url, ResourceUrl). They mark a value as safe, and Angular no longer checks it. They are legitimate only for content that you build and control yourself, for example the URL of an embed built from a validated id. On data from users or from an external API, every call is a vulnerability. The Angular documentation marks them as “security sensitive”: look for them in every review.
import { DomSanitizer, SafeHtml, SafeResourceUrl } from '@angular/platform-browser';
export class Embed {
private readonly sanitizer = inject(DomSanitizer);
// ✓ Only for content you CONTROL: a URL you build yourself from a validated id
readonly video: SafeResourceUrl = this.sanitizer.bypassSecurityTrustResourceUrl(
`https://www.youtube-nocookie.com/embed/${encodeURIComponent(this.videoId())}`,
);
// ✗ Never on input that comes from the user or from an external API
unsafe(html: string): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(html); // guaranteed XSS if html contains an <img onerror>
}
}More dangerous are the paths that do not go through Angular at all: nativeElement.innerHTML, Renderer2.setProperty(…, 'innerHTML', …), document.write, eval and templates built from strings at runtime.
// All of these BYPASS Angular’s sanitization — avoid them with data from outside:
this.el.nativeElement.innerHTML = userHtml; // direct DOM
this.renderer.setProperty(this.el.nativeElement, 'innerHTML', userHtml);
document.write(userHtml);
new Function(userCode)(); eval(userCode);
// and templates built from strings with user input (JIT compilation at runtime)
// ✓ The alternative: [innerHTML] (sanitized) or, for markdown, a rendering library + sanitizationContent Security Policy
CSP is a header that tells the browser which scripts and styles are allowed to run. Even if an attacker manages to inject HTML, their script does not run without the correct nonce or hash. Angular adds inline styles (the component styles), so it needs a nonce. It gets one either through the ngCspNonce attribute on the root element (the server generates it on every response), or through the CSP_NONCE token.
The alternative without a server that templates the page is security.autoCsp. At build time, the CLI computes the hashes of the inline scripts and puts the policy in a <meta> tag, so index.html stays cacheable. The documentation lists its limits: it covers only scripts, some directives (for example frame-ancestors) still have to be sent in the header, and it does not work with SSR.
import { CSP_NONCE } from '@angular/core';
// Option 1 (server-side templating): the server puts the same nonce in the header and in index.html
// <app-root ngCspNonce="r4nd0m-per-request"></app-root>
// Option 2: the nonce is available at runtime and you want to be able to cache index.html
bootstrapApplication(App, {
providers: [{ provide: CSP_NONCE, useValue: globalThis.myRandomNonceValue }],
});Trusted Types
Trusted Types go one step further: the browser refuses any write to dangerous “sinks” (innerHTML, the src of a script…) that does not come through an approved policy. Angular has its own policies: angular for what the framework does, angular#unsafe-bypass if you use bypassSecurityTrust*, angular#bundler for the CLI’s lazy chunks. An innerHTML written directly in code becomes an error instead of a vulnerability.
# The minimum for an Angular application
Content-Security-Policy: trusted-types angular; require-trusted-types-for 'script';
# If you use bypassSecurityTrust*
Content-Security-Policy: trusted-types angular angular#unsafe-bypass; require-trusted-types-for 'script';
# If you have lazy chunks created by the CLI
Content-Security-Policy: trusted-types angular angular#bundler; require-trusted-types-for 'script';Content-Security-Policy: script-src 'self' 'nonce-RANDOM_PER_RESPONSE'; style-src 'self' 'nonce-RANDOM_PER_RESPONSE'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; trusted-types angular angular#bundler; require-trusted-types-for 'script';
<!-- index.html: the server replaces the value on every response -->
<app-root ngCspNonce="RANDOM_PER_RESPONSE"></app-root>
// or, if the nonce is known at runtime:
{ provide: CSP_NONCE, useValue: globalThis.myNonce }XSRF / CSRF
If authentication is done with a session cookie, a foreign page can send a form to your API, and the browser attaches the cookie automatically. HttpClient has built-in protection: it reads the XSRF-TOKEN cookie and sends the value in the X-XSRF-TOKEN header. Only code on your domain can read the cookie, so the server knows where the request comes from. The header is added only to requests that modify data (not to GET/HEAD), to relative URLs or to the same origin. The server side is mandatory: the server sets the cookie and verifies the header.
import { provideHttpClient, withXsrfConfiguration, withNoXsrfProtection } from '@angular/common/http';
// Default: HttpClient reads the XSRF-TOKEN cookie and sends the X-XSRF-TOKEN header
// on requests that MODIFY data (POST, PUT, DELETE…) to relative URLs / the same origin.
provideHttpClient(
withXsrfConfiguration({ cookieName: 'CSRF-TOKEN', headerName: 'X-CSRF-TOKEN' }), // if the backend uses other names
);
// Disabling it (e.g. an API with a Bearer token, without a session cookie):
provideHttpClient(withNoXsrfProtection());When YES, when NO
- Let Angular do the binding: interpolation and
[innerHTML], not the DOM directly. - A strict CSP (nonce or
autoCsp) and Trusted Types in production. - Session cookies that are
HttpOnly,Secure,SameSite, and XSRF protection active (see Authentication). For login through an external provider, see OAuth 2.1 / OIDC with PKCE. - Update Angular and the dependencies: security fixes arrive in patch versions.
bypassSecurityTrustHtmlon content from users or from external CMSs.- “Security” validation only in the frontend: every rule must be checked again on the server.
- Access tokens in
localStoragewithout a CSP: a single XSS can read them. - Disabling the sanitization warnings “so they stop appearing in the console”: they show you exactly where unsafe HTML enters.
Common pitfalls
- “The
safeHtmlpipe” in the project, which callsbypassSecurityTrustHtmlon anything: it is a reusable XSS. Delete it or limit it to safe sources. - Markdown rendered with
bypassSecurityTrustHtml: markdown can contain HTML. Render it, then pass the result through sanitization. hrefattributes built by concatenation innativeElement: they bypass URL sanitization.- CSP added “at the end” without testing: lazy chunks or inline styles get blocked and the application breaks in production. Start with
Content-Security-Policy-Report-Only. - XSRF disabled while a session cookie is active: any site can make POST requests on behalf of the user.
Cheat sheet
IMPLICIT (nothing to do)
{{ x }} escaped
[innerHTML] sanitized (script, on*, javascript:, iframe… removed) — dev warning when it removes something
[href] / [src] dangerous URLs → "unsafe:…"
BYPASSES (only for content you control — look for them at code review)
bypassSecurityTrustHtml / Url / ResourceUrl / Style / Script
nativeElement.innerHTML, Renderer2.setProperty('innerHTML'), document.write, eval, templates built from strings
CSP
ngCspNonce="…" on the root or { provide: CSP_NONCE, useValue } — nonce per response
"security": { "autoCsp": true } — hashes at build time, index.html cacheable, NOT with SSR, scripts only
TRUSTED TYPES
trusted-types angular [angular#unsafe-bypass] [angular#bundler]; require-trusted-types-for 'script'
XSRF
XSRF-TOKEN → X-XSRF-TOKEN, only requests that modify, only same-origin · withXsrfConfiguration · withNoXsrfProtection
SERVER
validate everything again · HttpOnly + Secure + SameSite cookies · npm audit / Angular updates