EthicsComputerCourses
Join training +91 90532 10052

Security: XSS, Sanitisation & CSP

Angular · Lesson 30 of 41 Advanced 8 min read Updated

Security bugs in a front end are rarely dramatic. A product review that contains a hidden script, an API key left in the JavaScript bundle, or an outdated package with a known hole is enough to leak customer data. Angular protects you from the most common attack by default, but only if you do not switch that protection off by accident.

In this lesson you will see what Angular sanitises and why, when it is safe to bypass that, and the step-by-step settings that harden a production build.

Cross-site scripting in one paragraph

Cross-site scripting (XSS) happens when text from a user reaches the page as code. Imagine a food delivery app in Lucknow where Rohan writes a review. If his "review" is <img src="x" onerror="stealCookies()"> and the page inserts it as HTML, every visitor who reads the review runs his code in their own logged-in session. The attacker can then read data on the page, call your API as the victim, or show a fake login form.

Angular's first defence is that interpolation is always text. {{ comment }} turns < into &lt;, so the browser shows the characters instead of creating elements (see Templates & Interpolation).

Sanitisation: what Angular cleans

When you bind to a property that the browser treats as HTML or as a link, Angular sanitises the value: it removes the dangerous parts and keeps the rest. There are four security contexts:

ContextExample bindingWhat Angular does
HTML[innerHTML]Keeps safe tags, removes scripts and event attributes
Style[style]Allows CSS values only
URL[href], <img [src]>Blocks javascript: and similar URLs
Resource URL<iframe [src]>, <script [src]>Refuses any value you have not explicitly trusted

Here a component shows a review in three ways:

TypeScript
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-review',
  template: `
    <p>{{ comment() }}</p>
    <div [innerHTML]="comment()"></div>
    <a [href]="website()">Website</a>
  `,
})
export class Review {
  comment = signal('<b>Great dosa!</b><img src="x" onerror="alert(1)"><script>alert(2)</script>');
  website = signal('javascript:alert(3)');
}

The HTML inside each element, captured in a test:

Output
p     &lt;b&gt;Great dosa!&lt;/b&gt;&lt;img src="x" onerror="alert(1)"&gt;&lt;script&gt;alert(2)&lt;/script&gt;
div   <b>Great dosa!</b><img src="x">
a     href="unsafe:javascript:alert(3)"

In development mode Angular also prints warnings to the console:

Output
WARNING: sanitizing HTML stripped some content, see https://angular.dev/best-practices/security#preventing-cross-site-scripting-xss
WARNING: sanitizing unsafe URL value javascript:alert(3) (see https://angular.dev/best-practices/security#preventing-cross-site-scripting-xss)

The bold text survives, the onerror attribute and the whole <script> element are gone, and the link is made harmless with an unsafe: prefix. If you see these warnings, treat them as a sign that untrusted data is flowing into HTML. Usually the right fix is to show plain text with interpolation instead.

Bypassing sanitisation, carefully

Sometimes you really need a value Angular would block, such as an embedded YouTube player. A resource URL that is not trusted throws an error:

Output
NG0904: unsafe value used in a resource URL context (see https://angular.dev/best-practices/security#preventing-cross-site-scripting-xss).

DomSanitizer offers bypassSecurityTrustHtml, bypassSecurityTrustStyle, bypassSecurityTrustUrl, bypassSecurityTrustResourceUrl and bypassSecurityTrustScript. Each marks a value as safe for one context. Only use them on values you control, and build the URL from validated parts, never from raw user input:

TypeScript
import { Component, computed, inject, input } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';

@Component({
  selector: 'app-recipe-video',
  template: `<iframe [src]="embedUrl()" title="Recipe video" width="560" height="315"></iframe>`,
})
export class RecipeVideo {
  readonly videoId = input.required<string>();
  private sanitizer = inject(DomSanitizer);

  protected embedUrl = computed(() => {
    const id = this.videoId();
    if (!/^[A-Za-z0-9_-]{11}$/.test(id)) {
      throw new Error(`Invalid video id: ${id}`);
    }
    return this.sanitizer.bypassSecurityTrustResourceUrl(`https://www.youtube-nocookie.com/embed/${id}`);
  });
}

The fixed domain and the strict pattern for the ID make sure a user cannot turn this into javascript: or another site. In code review, every bypassSecurityTrust call deserves a second look.

Angular cannot protect code that goes around it. Writing elementRef.nativeElement.innerHTML = text, calling document.write, using eval or new Function, or building component templates from user strings skips sanitisation completely. Use bindings instead.

Step by step: harden a production app

Follow these steps on any app before it goes live.

  1. Turn on automatic CSP. A Content Security Policy (CSP) is a rule set, sent by the server or a <meta> tag, that tells the browser which scripts may run. Even if an attacker injects a <script>, the browser refuses it. Add this to the build options in angular.json:
JSON
"options": {
  "security": {
    "autoCsp": true
  }
}

ng build then hashes the app's own loader script and adds a policy tag at the top of index.html, similar to:

HTML
<meta http-equiv="Content-Security-Policy" content="script-src 'strict-dynamic' 'sha256-ST0D...2Bc=' https: 'unsafe-inline';object-src 'none';base-uri 'self';">

'strict-dynamic' lets scripts loaded by your trusted loader run. Modern browsers ignore 'unsafe-inline' when a hash is present and ignore https: when 'strict-dynamic' is present; both are only fallbacks for very old browsers. Auto CSP covers scripts only and cannot be used with server-side rendering; for SSR, generate a fresh random nonce per request on the server and give it to Angular with the ngCspNonce attribute on the root element or the CSP_NONCE token.

  1. Send security headers from the server. Add a style-src rule (Angular adds component styles at runtime, so static hosting usually needs style-src 'self' 'unsafe-inline'), frame-ancestors 'none' so other sites cannot frame your login page, and X-Content-Type-Options: nosniff.
  1. Consider Trusted Types. This browser feature blocks raw strings from reaching dangerous DOM APIs. Angular supports it through policies named angular (always needed), angular#bundler (for lazy chunks) and angular#unsafe-bypass (only if you call bypassSecurityTrust methods):
Text
Content-Security-Policy: trusted-types angular angular#bundler; require-trusted-types-for 'script';
  1. Keep XSRF protection working. Cross-site request forgery (XSRF or CSRF) tricks a logged-in browser into sending a request from another site. If your backend uses cookie-based sessions, HttpClient reads a cookie named XSRF-TOKEN and sends it back as the X-XSRF-TOKEN header on requests that change data (such as POST, not GET or HEAD) to your own origin. The server must set the cookie and check the header. Change the names with provideHttpClient(withXsrfConfiguration({ cookieName: 'LEAVEDESK-XSRF', headerName: 'X-LEAVEDESK-XSRF' })). Use withNoXsrfProtection() only if your API uses tokens in headers instead of cookies.
  1. Keep secrets out of the bundle. Everything in src/, including environment.ts, ships to every visitor's browser and can be read with DevTools. A Razorpay key secret, a database password or an admin API key must live on the server. Public identifiers that are designed to be public, such as a payment gateway's key ID or a maps key restricted to your domain, are fine.
  1. Audit and update dependencies. Run npm audit in CI, read its report, and keep Angular current with ng update. Most real-world front-end incidents start with a known hole in an old package.

Remember: the server has the final word

Every check in the browser can be bypassed by someone with DevTools. Guards, hidden buttons and form validators are for user experience. The API must check the logged-in user's permissions and validate every field again. If the Angular app hides the "Approve leave" button from Farhan but the API approves leave for anyone who calls it, the app is not secure.

Common mistakes

  • Using [innerHTML] for plain text. Interpolation is safer and simpler.
  • Calling bypassSecurityTrust... on user input, which disables the protection you wanted.
  • Storing secrets in environment.ts. Anything shipped to the browser is public.
  • Relying on route guards for security instead of server-side permission checks.
  • Ignoring npm audit and skipping updates for years.

Key points

  • Interpolation always produces text; property bindings such as [innerHTML] and [href] are sanitised.
  • Resource URLs (<iframe [src]>) must be explicitly trusted; error NG0904 means you passed an untrusted one.
  • Use DomSanitizer.bypassSecurityTrust* only on values you build from fixed, validated parts.
  • "security": { "autoCsp": true } adds a hash-based CSP; use nonces with ngCspNonce or CSP_NONCE for SSR.
  • HttpClient handles XSRF cookies automatically; the server must set and verify them.
  • Secrets never belong in front-end code, and the server must enforce every permission.

Exercise

A college notice board lets teachers write notices with bold and italic text and an optional link. Write a NoticeCard component with title, body (HTML from the teacher) and link inputs. Show the title as text, the body with [innerHTML], and the link only if it starts with https://. Explain what happens if a teacher's account is hacked and someone posts a body with a <script> tag.

Show solution
TypeScript
import { Component, computed, input } from '@angular/core';

@Component({
  selector: 'app-notice-card',
  template: `
    <h3>{{ title() }}</h3>
    <div [innerHTML]="body()"></div>
    @if (safeLink(); as url) {
      <a [href]="url" rel="noopener" target="_blank">Read more</a>
    }
  `,
})
export class NoticeCard {
  readonly title = input.required<string>();
  readonly body = input('');
  readonly link = input('');

  protected safeLink = computed(() => (this.link().startsWith('https://') ? this.link() : null));
}

The title uses interpolation, so it is always text. Angular sanitises the body, so <b> and <i> stay but a <script> element is removed and a warning appears in development. The link check is extra protection on top of Angular's URL sanitisation. The server should still sanitise and validate notices when they are saved.

Quick Quiz

1. What does Angular do with <script> inside a value bound to [innerHTML]?

2. A link bound with [href]="'javascript:alert(1)'" renders as:

3. When is bypassSecurityTrustResourceUrl acceptable?

4. Where should a payment gateway's secret key live?

Learn Angular with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call