Mert Özen Further With Every Line
Security Frontend Security — #03

Applying a Strict CSP to Angular: Nonces, strict-dynamic, autoCsp and a Gradual Rollout with Report-Only

Mert Özen Nov 8, 2026 12 min 3 views
Applying a Strict CSP to Angular: Nonces, strict-dynamic, autoCsp and a Gradual Rollout with Report-Only

Allowlist-based CSPs can usually be bypassed; the right path for Angular is a strict policy built on a nonce or hash plus 'strict-dynamic'. In this post we show, with headless Chrome output, the hash-based policy autoCsp generates for static hosting, per-request nonces with ngCspNonce served from ASP.NET Core, the base-uri 'none' trap, getting along with third-party scripts and moving to enforcement with Report-Only without breaking anything.

Version note: The examples in this post were run with Angular 22.2, .NET 10, Node.js 24 and Chrome 156.

Guess First

Bookstore's Angular app is served from a CDN (Content Delivery Network), and the team added this CSP (Content Security Policy): script-src 'self' https://cdn.example.com; object-src 'none'. There is no 'unsafe-inline' in it, and scripts can only load from two origins. On paper it looks tight. If an attacker manages to inject HTML (HyperText Markup Language) into the page, does this policy stop them?

The answer depends on what lives on cdn.example.com, and you do not control that. If that host serves an old AngularJS version, an endpoint that returns a JSONP (JavaScript Object Notation with Padding) callback, or a folder with user content, the attacker runs their own code from an "allowed" origin. The paper "CSP Is Dead, Long Live CSP!", published by Google researchers at the ACM (Association for Computing Machinery) CCS (Conference on Computer and Communications Security) 2016 conference, measured this: 94.68% of the policies that tried to restrict script execution were ineffective. The same paper proposed today's answer: nonces or hashes plus 'strict-dynamic' instead of host lists. In this post we apply that strict policy to Angular with three different ways of serving it, reconcile it with third-party scripts and roll it out to production with Report-Only without breaking anything. What CSP is and why 'unsafe-inline' defeats it was covered in the Web Security (OWASP Top 10) series, in the post "OWASP A05: Injection (2)". Generating the CSP header with middleware in ASP.NET Core and writing a report endpoint is the topic of post 04 in the "ASP.NET Core Security" series; here we focus on the Angular side.

The Parts of a Strict CSP

The nonce-based policy recommended by the web.dev guide "Mitigate cross-site scripting (XSS) with a strict Content Security Policy" consists of three directives:

Content-Security-Policy:
  script-src 'nonce-{RANDOM}' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';

'nonce-{RANDOM}' lets only <script> elements that carry the same nonce attribute run. A nonce is an unpredictable value generated anew for every response; an attacker cannot put the right nonce on the script they inject. In the hash-based alternative you write the SHA (Secure Hash Algorithm) family SHA-256 hash of the inline script's content instead of a nonce, and the HTML can stay static. 'strict-dynamic' propagates trust: scripts that a nonced or hashed script adds with document.createElement('script') may run too. That way bundles doing lazy loading and tag manager loaders do not have to be listed in the policy one by one. With 'strict-dynamic' present, the browser ignores host lists and 'self'.

The guide also recommends adding https: and 'unsafe-inline' for older browsers: browsers that support CSP Level 3 (CSP3) ignore 'unsafe-inline' when they see a nonce or hash, and ignore https: when they see 'strict-dynamic'. According to MDN's (MDN Web Docs) browser-compat-data, 'strict-dynamic' has been supported since Chrome 52, Firefox 52 and Safari 15.4; today these fallbacks are mostly a safety net.

There is an Angular-specific detail about base-uri. Angular's index.html contains <base href="/en/">, and the router and relative script paths depend on that tag. When I took base-uri 'none' from the guide as is and opened a nested route such as /books/42:

[console.error] Setting the document's base URI to 'http://localhost:19431/' violates the following Content Security Policy directive: "base-uri 'none'". The action has been blocked.
[console.log] [csp-violation] directive=base-uri blocked=http://localhost:19431/ disposition=enforce sample=
[console.error] Failed to load module script: Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "text/html". Strict MIME type checking is enforced for module scripts per HTML spec.
[status] 200
[eval] "no h1"

With <base> blocked, the relative main-*.js path resolved under /books/, the server answered with HTML through its SPA (Single-Page Application) fallback, and the app never started. The right value for Angular is base-uri 'self'; the policy the Angular CLI (Command-Line Interface) generates with autoCsp uses it too. object-src 'none' turns plugins off. Against clickjacking you should also add frame-ancestors 'none', but this directive only works in the HTTP (HyperText Transfer Protocol) header; the browser ignores it in a CSP <meta> tag.

Setup 1: Static Hosting and autoCsp

If the app sits as static files on a CDN or in object storage, you have no server to generate a fresh nonce for every response. Since Angular CLI 19 there is a hash-based option for this: during the build it computes the hashes of the inline scripts in index.html and adds a <meta http-equiv="Content-Security-Policy"> tag. According to the description in the schema, the feature is still in an experimental/preview phase and defaults to false. In the lab I added a configuration on top of production:

"configurations": {
  "autocsp": {
    "security": {
      "autoCsp": true
    },
    "outputHashing": "all"
  }
}

The index.html produced by ng build --configuration production,autocsp:

<!doctype html>
<html lang="en" data-beasties-container="">
<head><meta http-equiv="Content-Security-Policy" content="script-src 'strict-dynamic' 'sha256-Jxq7Ku8wPURcenwJcJA2X6jP5a0H6l1jyi50NPSYFi0=' https: 'unsafe-inline';object-src 'none';base-uri 'self';">
  <meta charset="utf-8">
  <title>BookstoreWeb</title>
  <base href="/en/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="styles-5INURTSO.css"></head>
<body>
  <app-root></app-root>
<script>(() => {
  const scripts = [['main-DEUWGEUN.js', "module", false, false, null, null]];
  scripts.forEach(function(scriptUrl) {
    const s = document.createElement('script');
    s.src = scriptUrl[0];
    s.type = scriptUrl[1];
    s.async = scriptUrl[2];
    s.defer = scriptUrl[3];
    if (scriptUrl[4]) {
      s.integrity = scriptUrl[4];
    }
    if (scriptUrl[5]) {
      s.crossOrigin = scriptUrl[5];
    }
    document.lastElementChild.appendChild(s);
  });
})();
</script></body>
</html>

The CLI removed the <script src="main-...js"> tag, put a small loader that adds it dynamically in its place, and wrote only that loader's hash into the policy; trust passes to main.js through 'strict-dynamic'. I computed the hash myself as well and got Jxq7Ku8wPURcenwJcJA2X6jP5a0H6l1jyi50NPSYFi0=. Because the file stays static, the CDN can cache it freely.

The limits on angular.dev matter: autoCsp only covers scripts, so style-src must be configured separately; frame-ancestors, report-uri and sandbox do not work in <meta> and must be sent as an HTTP header; if you also send a CSP header it must not contain script-src and default-src; and it cannot be used with SSR (Server-Side Rendering). In the lab I gave the hosting header as style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'. The browser applies both policies together. I opened the vulnerable review component from the previous post with this build:

[console.error] Executing inline event handler violates the following Content Security Policy directive 'script-src 'strict-dynamic' 'sha256-Jxq7Ku8wPURcenwJcJA2X6jP5a0H6l1jyi50NPSYFi0=' https: 'unsafe-inline''. Note that 'unsafe-inline' is ignored if either a hash or nonce value is present in the source list. The action has been blocked.
[console.log] [csp-violation] directive=script-src-attr blocked=inline disposition=enforce sample=
[console.error] Running the JavaScript URL violates the following Content Security Policy directive 'script-src 'strict-dynamic' 'sha256-Jxq7Ku8wPURcenwJcJA2X6jP5a0H6l1jyi50NPSYFi0=' https: 'unsafe-inline''. Note that 'unsafe-inline' is ignored if either a hash or nonce value is present in the source list. The action has been blocked.

Six violation records in total came in for the onerror handlers in the three sinks, and the javascript: link was blocked as well; there is not a single XSS fired line in the console. The app itself opened without problems.

Setup 2: Serving with a Nonce from ASP.NET Core

If ASP.NET Core 10 serves the Angular build, you can generate a nonce per request. On the Angular side all you need is a placeholder on the root element:

<app-root ngCspNonce="__CSP_NONCE__"></app-root>

When the CLI sees this attribute it copies the value to every <script>, <style> and stylesheet <link> tag it generates. The relevant part of the build output:

<style nonce="__CSP_NONCE__">body{font-family:sans-serif}</style><link rel="stylesheet" href="styles-KQ4ZCH26.css" nonce="__CSP_NONCE__" media="print" data-beasties-media="all"><noscript><link rel="stylesheet" href="styles-KQ4ZCH26.css" nonce="__CSP_NONCE__"></noscript></head>
<body>
  <app-root ngcspnonce="__CSP_NONCE__"></app-root>
<script src="main-DEUWGEUN.js" type="module" nonce="__CSP_NONCE__"></script><script nonce="__CSP_NONCE__">document.querySelectorAll('link[data-beasties-media]').forEach(function(l){l.media=l.getAttribute('data-beasties-media');l.removeAttribute('data-beasties-media')})</script></body>

Note: when styles.css is not empty, the CLI's critical CSS (Cascading Style Sheets) inlining adds an inline <style> and an inline <script> that loads the stylesheet asynchronously. Without a nonce, that script hits the policy as well. The server side is a few lines of minimal API (Application Programming Interface):

using System.Security.Cryptography;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// index.html lives outside wwwroot so it is never served without a nonce
string indexTemplate = await File.ReadAllTextAsync(
    Path.Combine(builder.Environment.ContentRootPath, "App", "index.html"));

app.UseStaticFiles();

app.MapFallback(async context =>
{
    string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
    context.Response.Headers.ContentSecurityPolicy =
        $"script-src 'nonce-{nonce}' 'strict-dynamic' https: 'unsafe-inline'; " +
        $"style-src 'self' 'nonce-{nonce}'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'";
    context.Response.Headers.CacheControl = "no-store";
    context.Response.ContentType = "text/html; charset=utf-8";
    await context.Response.WriteAsync(indexTemplate.Replace("__CSP_NONCE__", nonce));
});

app.Run();

The nonce comes from 16 random bytes (128 bits) produced by RandomNumberGenerator. index.html is deliberately outside wwwroot: otherwise a request for /index.html would come back from the static file middleware with the placeholder still in it. Two consecutive requests:

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Date: Sun, 08 Nov 2026 05:27:15 GMT
Server: Kestrel
Cache-Control: no-store
Transfer-Encoding: chunked
Content-Security-Policy: script-src 'nonce-xSbIMOG5VW0fbCx1EGDiCg==' 'strict-dynamic' https: 'unsafe-inline'; style-src 'self' 'nonce-xSbIMOG5VW0fbCx1EGDiCg=='; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
nonce="M7ZmjQFZH6nNFUb+C61A/g=="
nonce="8N0gcjJJq9r7vSjpP3ZubQ=="

Every response carries a different nonce. ngCspNonce has a second job at runtime: Angular adds component styles as <style> elements and reads the nonce from this attribute. Since style-src has no 'unsafe-inline', I tested it. First the correct setup, then the version where I removed the ngcspnonce attribute from the template:

[eval] {"styles":["nonce=set body{font-family:sans-serif}","nonce=set h1[_ngcontent-ng-c582792440]{color:#639}"],"h1Color":"rgb(102, 51, 153)","sheets":3}
[console.error] Applying inline style violates the following Content Security Policy directive 'style-src 'self' 'nonce-aNQGzNFuW+FjvhGHLPQKRA==''. Either the 'unsafe-inline' keyword, a hash ('sha256-R8Tr0etxaurKdTOuzau1pawvaKOt9AHTbwflNz/d5tI='), or a nonce ('nonce-...') is required to enable inline execution. The action h
[console.log] [csp-violation] directive=style-src-elem blocked=inline disposition=enforce sample=
[eval] {"h1Color":"rgb(0, 0, 0)"}

Without the attribute the component style was blocked and the heading stayed black instead of purple. A CSP error does not crash the app; it silently breaks the look, which is why your tests must read the console.

If you want to cache index.html and get the nonce from somewhere else at runtime, Angular has the CSP_NONCE injection token. In the lab I tried a provider that reads the nonce from a meta tag; the edge's job, writing the nonce to the scripts and the meta tag, I did by hand in this experiment:

{
  provide: CSP_NONCE,
  useFactory: () => document.querySelector('meta[name="csp-nonce"]')?.getAttribute('content') ?? null,
},
[eval] {"styles":["nonce=set body{font-family:sans-serif}","nonce=set h1[_ngcontent-ng-c582792440]{color:#639}"],"h1Color":"rgb(102, 51, 153)"}

If you use inline critical CSS, angular.dev recommends autoCsp or ngCspNonce over CSP_NONCE; only ngCspNonce can write the nonce into the inline tags the CLI generates at build time.

Setup 3 and Choosing

With SSR the HTML is already produced on the server for every request, so a nonce is the natural choice and autoCsp cannot be used there. We will cover the SSR-specific nonce flow in post 07 of the series.

ServingScriptsComponent stylesNote
Static hosting, CDNautoCsp hashstyle-src 'unsafe-inline' or a nonce at the edgeframe-ancestors in the header
ASP.NET CorePer-request nonce via ngCspNonceSame nonceCache-Control: no-store
Angular SSRPer-request nonceSame nonceautoCsp not available

Getting Along with Third-Party Scripts

Analytics or a tag manager adding its own scripts is the most frequent question with a strict CSP. 'strict-dynamic' does its job here: trust propagates to the script a nonced loader adds and to the script that one adds. In the lab I simulated the third-party origin as http://127.0.0.1:19424 and put a loader into src/index.html:

<script>
  // tag manager loader
  const tm = document.createElement('script');
  tm.src = 'http://127.0.0.1:19424/analytics.js';
  document.head.append(tm);
</script>

The CLI wrote the nonce on this inline script as well. analytics.js itself adds a pixel.js. I also added a widget tag coming from the CMS (Content Management System) that the server inserts into the HTML without a nonce: <script src="http://127.0.0.1:19424/widget.js"></script>. Result:

[console.error] Loading the script 'http://127.0.0.1:19424/widget.js' violates the following Content Security Policy directive: "script-src 'nonce-zfQT/6DTkhpvifWgzNP1KQ==' 'strict-dynamic' https: 'unsafe-inline'". Note that 'script-src-elem' was not explicitl
[requestfailed] http://127.0.0.1:19424/widget.js csp
[console.log] [csp-violation] directive=script-src-elem blocked=http://127.0.0.1:19424/widget.js disposition=enforce sample=
[console.log] analytics.js loaded
[console.log] pixel.js loaded (created by analytics.js)

The loader chain worked, and the tag written straight into the HTML was blocked. This also shows the price of 'strict-dynamic': you trust everything a trusted script loads. The moment you give the tag manager a nonce, anyone who can add a script from its panel can put code into production. We will discuss that risk and isolation with iframes in post 06 of the series; on the DOM (Document Object Model) side, the way to limit the URL (Uniform Resource Locator) written to script.src is Trusted Types, the topic of the next post.

A Gradual Rollout with Report-Only

Enforcing a strict policy directly on an existing app means breaking a missed inline script in production. Send it with Content-Security-Policy-Report-Only first. I added this header to the plain build without nonces:

Content-Security-Policy-Report-Only: script-src 'nonce-r4nd0m' 'strict-dynamic'; object-src 'none'; base-uri 'self'; report-uri /csp-report

Report-Only blocks nothing: the XSS fired lines still showed up on the vulnerable page, but the browser sent every violation to /csp-report. Two of the reports that came in:

[report] application/csp-report {"csp-report":{"document-uri":"http://localhost:19423/sinks","referrer":"","violated-directive":"script-src-elem","effective-directive":"script-src-elem","original-policy":"script-src 'nonce-r4nd0m' 'strict-dynamic'; object-src 'none'; base-uri 'self'; report-uri /csp-report","disposition":"report","blocked-uri":"http://localhost:19423/main-ZMTY22ZN.js","status-code":200,"script-sample":""}}
[report] application/csp-report {"csp-report":{"document-uri":"http://localhost:19423/sinks","referrer":"","violated-directive":"script-src-attr","effective-directive":"script-src-attr","original-policy":"script-src 'nonce-r4nd0m' 'strict-dynamic'; object-src 'none'; base-uri 'self'; report-uri /csp-report","disposition":"report","blocked-uri":"inline","line-number":5,"column-number":91475,"source-file":"http://localhost:19423/main-ZMTY22ZN.js","status-code":200,"script-sample":""}}

The first report says that if you enforced the policy today, the app itself (main.js) would not load: you cannot switch without wiring the nonce into the build. The second is a real finding: an inline event handler was about to run from inside main.js. Seven reports came in total: main.js, the critical CSS loader and five inline handler reports.

The newer reporting mechanism is the Reporting-Endpoints header with the report-to directive. Chrome ignores report-uri when report-to is present, and the Reporting API sends reports in batches with a delay. When I gave both in the lab I waited 75 seconds and not a single report reached the endpoint; according to browser-compat-data, Firefox only supported report-to in version 149. During the transition, using report-uri on its own is still the most predictable path. Writing the endpoint that collects the reports in ASP.NET Core is in post 04 of the "ASP.NET Core Security" series.

A practical rollout plan:

  • Put the Report-Only policy into production and collect reports for at least one full business cycle (month-end campaign, billing period).
  • Group the reports by effective-directive, blocked-uri and source-file; filter out the noise from browser extensions.
  • Close each group with a code change: turn inline handlers into listeners, javascript: links into click handlers, move third-party tags into a nonced loader.
  • Enforce the same policy, and keep a Report-Only policy next to it for the next tightening.

In Production

A CDN caching nonced HTML is the sneakiest trap of a strict CSP: the same nonce goes to every user, and an attacker can read it from the page and write it on the script they inject. Send Cache-Control: no-store on nonced responses and keep HTML out of the cache in your CDN rules. angular.dev recommends generating the nonce at the edge rather than on the origin server when you use a CDN. If you cannot generate a nonce at the edge, the autoCsp hash is the right choice, because the hash changes together with the static content.

The nonce must come from a cryptographic random source and be at least 128 bits. Guid.NewGuid(), a request ID or a timestamp is not a nonce. Generate the policy in one place; when several layers (reverse proxy, application, CDN) add their own CSP headers, the browser applies all of them together and the strictest one wins. In the autoCsp setup, where the header and the meta tag are used together, we did that on purpose.

Manage policy changes like code: put a test in the CI (Continuous Integration) pipeline that opens the critical pages in a headless browser and counts securitypolicyviolation events. Every output in this post comes from such a script.

Common Pitfalls

First, adding 'unsafe-inline' as a temporary fix and leaving it there. Without a nonce or hash, 'unsafe-inline' allows every inline script and handler, and the whole benefit of a strict CSP is gone. Report-Only exists for this transition.

Second, copying base-uri 'none' into Angular. In the lab the app did not start on nested routes; use 'self' with Angular.

Third, writing frame-ancestors into the meta tag. The browser ignores it there; even with autoCsp this directive belongs in the hosting header.

Fourth, assuming ngCspNonce is only for scripts. The attribute is also the nonce for the component styles Angular adds at runtime; without it a style-src violation silently breaks the look.

Fifth, caching nonced HTML. A static nonce is not a nonce.

A Small Experiment

★☆☆ Remove the Cache-Control: no-store line from the ASP.NET Core host in the lab, request the same page twice with curl -i and confirm that the nonces still differ. Then think about what happens when you put a reverse proxy cache in front of it.

★★☆ Add optimization.styles.inlineCritical: false to the autoCsp build and compare the generated index.html and the hash list. How many hashes remain in the policy once the critical CSS loader is gone?

★★★ Add 'report-sample' to the Report-Only policy and open the vulnerable page again. Look at what the script-sample field in the reports shows, and assess whether this field could carry personal data into your logs.

CSP limits which scripts can run, but it does not question the string written to innerHTML or the URL given to script.src. In the next post we close that gap with Trusted Types: Angular's policy names, violations in third-party libraries, the danger of the default policy and the browser picture that changed with Baseline 2026.