
Angular 22.0.0 shipped on 3 June 2026. The releases table still lists 22 as Active.Robust is the wrong word. The framework encodes interpolation. bypassSecurityTrustHtml takes that encode back.
I opened the current Angular security guide. It says treat every bound value as untrusted, never concatenate user text into a template, and avoid APIs marked Security Risk. The XSS page is the sink model. This page is the Angular hatches. Pair it with XSS and input validation. If the leftover app is still 1.x, leave this URL and read the AngularJS end-of-life page. The React sibling is React XSS hatches.
Interpolation writes a text node. [innerHTML] still runs the sanitizer. bypassSecurityTrustHtml is the hole you punch when a comment preview looked wrong.
SecureCoding
Interpolation is the safe path
GitHub tagged 20.0.0 on 28 May 2025 and 21.0.0 on 19 November 2025. 22.0.0 shipped 3 June 2026. I am citing those tags and the first-party support table. The security model did not flip in those jumps. Bound values are untrusted. Templates are trusted.
Interpolation writes a text node. Angle brackets stay visible. That is the default you want for a display name, a search term, an error string, a file name in an admin queue.
@Component({
selector: "app-name",
template: `<p class="name">{{ displayName }}</p>`,
standalone: true,
})
export class NameComponent {
displayName = "";
}
Keep displayName a string you parsed on the server. An 80 character cap is CWE-20. The braces are CWE-79. You want both. TypeScript will not parse HttpClient JSON for you. A schema on the handler still matters.
React JSX also encodes. Both grow a hatch the moment the product wants rich text. The hatch names differ. The job does not.
[innerHTML] still sanitizes
The guide’s own sample binds the same htmlSnippet twice: once with braces, once with [innerHTML]. Interpolation shows the tags as text. The property binding runs the HTML sanitizer. A script element is stripped. A b element is kept.
@Component({
selector: "app-snippet",
template: `
<p class="as-text">{{ htmlSnippet }}</p>
<p class="as-html" [innerHTML]="htmlSnippet"></p>
`,
standalone: true,
})
export class SnippetComponent {
htmlSnippet = "";
}
Contexts the sanitizer knows, from that same guide:
| Context | Typical bind | What Angular does |
|---|---|---|
| HTML | [innerHTML] | Strip unsafe markup |
| Style | [style] | Drop dangerous CSS |
| URL | [href] | Block unsafe schemes |
| Resource URL | [src] on a frame | Cannot sanitize. Must be trusted |
Resource URLs are the ones that load code. You cannot clean a script src into safety. If you did not mint that URL, do not bind it. In development, Angular logs when it changes a value during sanitization. Treat that log as a review item, not as noise.
bypassSecurityTrustHtml is the hatch
The five methods the guide lists are all marked as the way to tell Angular you inspected the value:
bypassSecurityTrustHtmlbypassSecurityTrustScriptbypassSecurityTrustStylebypassSecurityTrustUrlbypassSecurityTrustResourceUrl
Use them on values you built from constants you wrote: a help page compiled at build time, a blob: URL you created from bytes you already typed. Do not use them on htmlSnippet from the API, a query string, or a postMessage. The method name is the opposite of a warning. That is the same hatch split entuno named, with more syllables.
import { DomSanitizer, SafeHtml } from "@angular/platform-browser";
// FIX: only for HTML you authored at build time.
const HELP_HTML = "<p>Reset lives under Settings.</p>";
export class HelpComponent {
help: SafeHtml;
constructor(sanitizer: DomSanitizer) {
this.help = sanitizer.bypassSecurityTrustHtml(HELP_HTML);
}
}
Identifiers stay htmlSnippet, displayName, HELP_HTML, and sanitizer. A named bypass is acceptable when the string is a constant in the repo. A bypass of htmlSnippet is not.
ElementRef and document skip Angular
The guide is blunt: document, ElementRef.nativeElement, and third-party DOM libraries do not run the sanitizer. Trusted Types, if you enforce them, catch some of those writes. Without that policy, el.innerHTML = htmlSnippet is the same bug as vanilla JS.
import { ElementRef, afterNextRender } from "@angular/core";
export class PaintComponent {
displayName = "";
constructor(host: ElementRef<HTMLElement>) {
afterNextRender(() => {
const el = host.nativeElement.querySelector(".name");
if (el) el.textContent = this.displayName;
});
}
}
If you must touch the node, set textContent. If you must set HTML, call DomSanitizer.sanitize(SecurityContext.HTML, value) and then write the return. sanitize returns null on a rejected value. Treat null as empty. Do not fall back to the raw string.
jQuery, chart plugins, and “drop this snippet in index.html” widgets are the usual skip. Audit them as innerHTML by another name. Prefer an Angular-binding wrapper that only accepts parsed fields.
CSP and Trusted Types
The guide calls Content Security Policy and Trusted Types an extra layer at the DOM, where a lower-level write cannot walk around the template. I agree. The document host emits the header. This page only needs the Angular half: a nonce on the scripts you serve, and Trusted Types so a raw string cannot hit innerHTML.
Enable Trusted Types in the policy. Configure Angular’s trusted-types support so template writes go through a policy you named. A string that is not TrustedHTML then cannot hit innerHTML. The bypass methods become the only mint, which is why they must stay off user data.
A CSP that still lists unsafe-inline is a comment, not a policy. Mint a nonce per response. Put it on the Angular scripts you serve. Drop 'unsafe-eval' unless a leftover build truly needs it, then fix the build.
Never build a template from a user string
Templates are executable. The guide says never concatenate user input into template syntax. AOT in production is the compiler you want. JIT against a string the client sent is a code-eval feature, not a view.
Concrete misses:
TemplateRefbuilt from a CMS field that includes{{ }}or bindings.- Server-rendered HTML that already contains Angular bindings, then bootstrapped on the same nodes. That is the old AngularJS expression-injection shape. Modern Angular still treats a template as code.
- Dynamic component lists are fine when you map a token to a component class you imported. They are not fine when the token is a URL to a module you do not own.
const PANELS = {
bio: BioPanel,
prefs: PrefsPanel,
} as const;
function panelFor(token: string) {
return Object.hasOwn(PANELS, token) ? PANELS[token as keyof typeof PANELS] : null;
}
Map the token. Do not import(userUrl). Do not interpolate token into a template string that Angular will compile.
Route guards belong here only as a reminder. canActivate hiding a component is UX. The API that returns htmlSnippet or a note body still checks ownerId. A functional guard that reads authState and returns false does not replace that check.
HttpClient can attach an XSRF cookie name you configure. That is a browser cookie problem, not an Angular-sanitizer problem. If the session is a cookie, the document host still has to refuse a cross-site POST. If the session is a bearer header you set in an interceptor, CSRF falls away and XSS can read that header. Pick one model. Do not store a long-lived access token in localStorage to “avoid cookies.”
Server-side rendering does not change the bind rules. A string you transfer from the server into htmlSnippet is still untrusted if a user typed it. The transfer state is a cache, not a trust upgrade. Encode on the way into the DOM the same way a client-only render would.
Prove the binding
You are proving your template encoded, and that the hatch is absent on user fields. You are not walking a payload against a shared host.
- Set
displayNameto<em>Ada</em>in your own fixture. RenderNameComponent. Expect a text node, not italics. - Set
htmlSnippetto a string that includes ascripttag and abtag. Render the[innerHTML]bind. Expect the bold, not a script node. That is the sanitizer doing its job. - Grep the repo for
bypassSecurityTrust. Every hit must point at a constant you authored, or it is a defect. - Grep for
innerHTML,nativeElement, anddocument.write. Each hit needs atextContentrewrite or asanitizecall whose null is not replaced by the raw field.
rg -n "bypassSecurityTrust|innerHTML|nativeElement\\.innerHTML|document\\.write" \
--glob '!node_modules' --glob '!dist'
// Jasmine-style fixture. Expect visible tags, not italics.
it("encodes displayName", () => {
const fixture = TestBed.createComponent(NameComponent);
fixture.componentInstance.displayName = "<em>Ada</em>";
fixture.detectChanges();
const text = fixture.nativeElement.querySelector(".name").textContent;
expect(text).toContain("<em>Ada</em>");
});
If step 1 shows italics, the template used a hatch or a raw write. If step 3 lists a component that reads the API, stop shipping that call.
Questions we keep getting
Does Angular replace server validation?
No. Templates encode. They do not type-check a JSON body. Parse on the handler. Length-cap displayName. The door and the sink are still two jobs.
Is [innerHTML] always a defect?
No. It runs the sanitizer. It is the right bind when the product must show a small allowlisted subset of markup. It becomes a defect when you bypass first, or when you also write the same string through nativeElement.
Do I still need a CSP if Angular sanitizes?
Yes. A library, an extension, or a raw DOM call can skip the template. Trusted Types and a nonce CSP catch those writes. Sanitization does not.



