Security: XSS, Sanitisation & CSP
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 <, 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:
| Context | Example binding | What 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:
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:
p <b>Great dosa!</b><img src="x" onerror="alert(1)"><script>alert(2)</script> div <b>Great dosa!</b><img src="x"> a href="unsafe:javascript:alert(3)"
In development mode Angular also prints warnings to the console:
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:
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:
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.
- 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 thebuildoptions inangular.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:
<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.
- Send security headers from the server. Add a
style-srcrule (Angular adds component styles at runtime, so static hosting usually needsstyle-src 'self' 'unsafe-inline'),frame-ancestors 'none'so other sites cannot frame your login page, andX-Content-Type-Options: nosniff.
- 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) andangular#unsafe-bypass(only if you callbypassSecurityTrustmethods):
Content-Security-Policy: trusted-types angular angular#bundler; require-trusted-types-for 'script';- 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,
HttpClientreads a cookie namedXSRF-TOKENand sends it back as theX-XSRF-TOKENheader on requests that change data (such asPOST, notGETorHEAD) to your own origin. The server must set the cookie and check the header. Change the names withprovideHttpClient(withXsrfConfiguration({ cookieName: 'LEAVEDESK-XSRF', headerName: 'X-LEAVEDESK-XSRF' })). UsewithNoXsrfProtection()only if your API uses tokens in headers instead of cookies.
- Keep secrets out of the bundle. Everything in
src/, includingenvironment.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.
- Audit and update dependencies. Run
npm auditin CI, read its report, and keep Angular current withng 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 auditand 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 withngCspNonceorCSP_NONCEfor 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
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]?
The HTML sanitiser strips scripts and event handler attributes.
2. A link bound with [href]="'javascript:alert(1)'" renders as:
Angular prefixes unsafe URLs with unsafe: so they do nothing.
3. When is bypassSecurityTrustResourceUrl acceptable?
Trust only values you control; validate every variable part.
4. Where should a payment gateway's secret key live?
Front-end code is public, so secrets must stay on the server.
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
