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

What Changed in Frontend Security from Angular 11 to 22 and in Browsers

Mert Özen Nov 8, 2026 13 min 3 views
What Changed in Frontend Security from Angular 11 to 22 and in Browsers

From Angular 11 to 22 most security features arrived in patches rather than new majors, so auditing an old project means checking the exact version, not the major. In this post we verify the version of each feature from Trusted Types to autoCsp and SSR allowedHosts from package tarballs, show in headless Chrome that 22.0.8 does not send the XSRF header, collect the browser changes and produce a runnable audit script.

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

Guess First

Bookstore's web application is on Angular 22.0.8. There is no provideHttpClient() in app.config.ts, because since Angular 21 HttpClient is provided in root automatically and inject(HttpClient) just works. The server sets the XSRF-TOKEN cookie with the page, and the component POSTs an order on startup:

import { HttpClient } from '@angular/common/http';
import { Component, inject, signal, VERSION } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `<p>Angular {{ version }}: {{ status() }}</p>`,
})
export class App {
  private readonly http = inject(HttpClient);
  protected readonly version = VERSION.full;
  protected readonly status = signal('pending');

  constructor() {
    this.http.post<{ id: number }>('/api/orders', { bookId: 1, quantity: 2 })
      .subscribe(order => this.status.set(`order ${order.id} created`));
  }
}

The Angular docs say XSRF (Cross-Site Request Forgery) protection is on by default and that HttpClient puts the cookie value into the X-XSRF-TOKEN header. Is the header sent on this request? Does the result change if you build the same code with 22.2.5?

We will see the answer below with a real run; the short version: on 22.0.8 it is not sent, on 22.2.5 it is. In this last post of the series we collect, version by version, what changed in security from Angular 11 to 22 and in browsers, including CSP (Content Security Policy), Trusted Types and SSR (Server-Side Rendering), and at the end we produce a runnable checklist for auditing an old Angular project.

Angular, Version by Version

I took the version numbers from the changelog, but I also verified the first version of each feature from the package itself. Changelogs sometimes mix up backports and renames; the tarball on npm does not lie:

#!/bin/sh
# Prints how often an API name appears in a published package tarball.
# Usage: ./check-first-version.sh <package> <version> <pattern>
curl -sL "https://registry.npmjs.org/$1/-/$(basename "$1")-$2.tgz" | tar -xzO | grep -c "$3"
@angular/core 10.2.0 createPolicy: 0
@angular/core 11.0.0 createPolicy: 8
@angular/core 15.2.0 CSP_NONCE: 0
@angular/core 16.0.0 CSP_NONCE: 7
@angular/ssr 21.1.4 allowedHosts: 0
@angular/ssr 21.1.5 allowedHosts: 41

With the same method I saw that withNoXsrfProtection arrived in @angular/common 15.0.0, autoCsp in @angular/build 19.0.0, and that HttpClient became providedIn: 'root' in 21.0.0. The table, together with where to look during an audit:

VersionDateChangeWhat to look for
11.0.0November 2020Trusted Types support, the angular policyIs require-trusted-types-for in the CSP
15.0.0November 2022provideHttpClient, withXsrfConfiguration, withNoXsrfProtectionUses of withNoXsrfProtection
16.0.0May 2023ngCspNonce and CSP_NONCE'unsafe-inline' in style-src
18.0.0May 2024HttpClientModule and HttpClientXsrfModule deprecatedNgModule-based HttpClient configuration
19.0.0November 2024security.autoCspangular.json
21.0.0November 2025HttpClient provided in rootUsage without provideHttpClient()
21.1.5, 20.3.17, 19.2.21February 2026allowedHosts and NG_ALLOWED_HOSTS in SSRWildcards and *
21.2.9, 20.3.25, 19.2.25April 2026trustProxyHeadersX-Forwarded-* behind a proxy
22.0.0June 2026400 for an unrecognized HostDeployments relying on the CSR (Client-Side Rendering) fallback
22.1.0, 21.2.19July 2026XSRF interceptor for the root HttpClient21.0.0 to 21.2.18 and 22.0.x

We implemented the Trusted Types and CSP side in the series posts "Taking Trusted Types to Production" and "Applying a Strict CSP to Angular", and the SSR side in "Angular SSR Security". The focus here is which version lacks what.

2025 and 2026: The Year of Advisories

Looking at the last rows of the table, one thing stands out: most of Angular's security features of the past year arrived in patches, not in new majors. The GitHub Advisory Database lists 38 advisories published in 2026 for Angular packages: 1 critical, 24 high, 13 medium. They fall into three clusters.

The first cluster is SSR and SSRF (Server-Side Request Forgery). The CVE (Common Vulnerabilities and Exposures) entry CVE-2026-27739, marked critical in February 2026, describes how Angular SSR trusted the Host and X-Forwarded-* headers without validation and how relative HttpClient requests could therefore be steered to another host. allowedHosts arrived with that fix. The advisories that followed closed the edges of this new check: protocol-relative and backslash URLs (Uniform Resource Locators) in April (CVE-2026-41423), hostname hijacking in May (CVE-2026-46417), and CVE-2026-50168 in June. The last one is instructive: the allowedHosts check used the WHATWG (Web Hypertext Application Technology Working Group) URL parser, while the server's emulated DOM (Document Object Model) used the more lenient Domino parser; a malformed host such as evil.com:80:80 failed to parse in the first, skipping the check, and was accepted by the second. The fix shipped in 21.2.15, 20.3.22 and 19.2.23; according to the advisory there is no fix for 18.2.14 and earlier.

The second cluster is sanitization. XSS (Cross-Site Scripting) advisories came one after another through SVG (Scalable Vector Graphics) animation attributes, MathML URLs, i18n attribute bindings, two-way bindings and host bindings on concrete hosts. What most of them share is that the sanitizer did not put an attribute or binding into the correct security context. The only thing to do on the code side is to take the patch; that is why version tracking is the first item of the audit.

The third cluster is HttpClient. In November 2025, CVE-2025-66035 closed the sending of the XSRF token to protocol-relative URLs such as //evil.example (21.0.1, 20.3.14, 19.2.16). Later the opposite problem was fixed: on some paths the token was not sent at all. The opening question is of this second kind.

The calendar changed too. According to the release table on angular.dev, v22 is active until June 2027 and in LTS (Long-Term Support) until June 2028; v21's LTS ends in June 2027, v20's on November 28, 2026, and v2–v19 are no longer supported. After v22, major releases drop to one per year. This means a project still on v19 or earlier will not officially get the patches for any of the advisories above.

Seeing a Regression for Real

I built the opening component with two versions and served it with a small Node server. The server sets an obviously fake token as a cookie with the page and logs the header on POST:

// Usage: node xsrf-server.mjs <dist-dir> <port>
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import { join, extname } from 'node:path';

const [dir, port] = process.argv.slice(2);
const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css' };

http.createServer(async (req, res) => {
  const path = new URL(req.url, 'http://localhost').pathname;
  if (req.method === 'POST' && path === '/api/orders') {
    const token = req.headers['x-xsrf-token'];
    console.log(`POST /api/orders x-xsrf-token=${token ?? '(missing)'}`);
    res.writeHead(token === 'dev-only-xsrf-token' ? 201 : 403, { 'Content-Type': 'application/json' });
    res.end(token ? '{"id":42}' : '{"error":"xsrf"}');
    return;
  }
  const file = extname(path) ? join(dir, path) : join(dir, 'index.html');
  try {
    const body = await readFile(file);
    const headers = { 'Content-Type': types[extname(file)] ?? 'application/octet-stream' };
    if (file.endsWith('index.html')) headers['Set-Cookie'] = 'XSRF-TOKEN=dev-only-xsrf-token; Path=/; SameSite=Strict';
    res.writeHead(200, headers).end(body);
  } catch {
    res.writeHead(404).end();
  }
}).listen(Number(port), '127.0.0.1', () => console.log(`listening on http://localhost:${port}`));

I opened both builds with headless Chrome 156. First 22.2.5:

[status] 200
[eval] "Angular 22.2.5: order 42 created"
POST /api/orders x-xsrf-token=dev-only-xsrf-token

Then the same source code with @angular/* 22.0.8:

[console.error] Failed to load resource: the server responded with a status of 403 (Forbidden)
[console.error] ERROR Ht
[status] 200
[eval] "Angular 22.0.8: pending"
POST /api/orders x-xsrf-token=(missing)

The header was not sent. The reason is a single line in the changelog: the "enable xsrf for root-provided HttpClient" fix in 22.1.0 and 21.2.19. According to the commit message, the XSRF interceptor was not included in the factory of the root HttpClient used without calling provideHttpClient(). So from 21.0.0 to 21.2.18 and in 22.0.x, the protection the docs call "on by default" was off on this path. Tests do not find a gap like this, because tests usually set up a separate configuration with provideHttpClientTesting(). If you cannot upgrade, the fix is one line:

import { provideHttpClient } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes),
    provideHttpClient(),
  ]
};
[eval] "Angular 22.0.8: order 42 created"
POST /api/orders x-xsrf-token=dev-only-xsrf-token

An explicit provideHttpClient() sent the header on 22.0.8 as well. Keep it as a habit: make security behaviour visible in code instead of relying on the default. Validating the token on the server is your job; Angular only does the client half.

What Changed in Browsers

Trusted Types became Baseline in February 2026. According to web-features data, the "Baseline low" date is February 24, 2026; support is Chrome 83, Firefox 148 and Safari 26. So require-trusted-types-for 'script' is no longer an extra layer that works only in Chromium. An old browser without support ignores the directive and Angular's own sanitization stays in place.

The Element.setHTML() method of the HTML (HyperText Markup Language) Sanitizer API (Application Programming Interface) is not Baseline yet: according to MDN (MDN Web Docs) compatibility data it is in Chrome 146 and Firefox 148, not in Safari. I tried it in Chrome 156 with a short page:

const review = document.getElementById('review');
const input = '<b>Great book</b><img src=x onerror="alert(1)"><script>alert(2)<\/script>';

console.log('trustedTypes:', typeof window.trustedTypes?.createPolicy);
console.log('setHTML:', typeof Element.prototype.setHTML);
console.log('Sanitizer:', typeof window.Sanitizer);

if (typeof review.setHTML === 'function') {
  review.setHTML(input);
  console.log('setHTML result:', review.innerHTML);
}
[console.log] trustedTypes: function
[console.log] setHTML: function
[console.log] Sanitizer: function
[console.log] setHTML result: <b>Great book</b>

With the default configuration, <img> and <script> were dropped entirely; in a separate run an href starting with javascript: was removed as well. Until Safari supports it, use setHTML with feature detection, and next to a library like DOMPurify rather than instead of it.

On the cookie side, the expected break did not come. On October 17, 2025 Google announced that it is retiring most Privacy Sandbox technologies (Topics, Protected Audience, Attribution Reporting, IP (Internet Protocol) Protection, Related Website Sets and others) and will keep its current approach of offering users a third-party cookie choice in Chrome. CHIPS (Cookies Having Independent Partitioned State) and FedCM (Federated Credential Management) continue. The practical outcome: an embed or SSO (Single Sign-On) flow that relies on third-party cookies still works in Chrome, but the restrictions in Safari and Firefox are in place; do not tie your design to third-party cookies.

The new direction against session theft is DBSC (Device Bound Session Credentials). On April 9, 2026 Google wrote that it opened DBSC to general use on Windows with Chrome 146 and that macOS will follow in a later release. The session is bound to a key that lives in hardware such as the TPM (Trusted Platform Module) and cannot be exported; a stolen cookie is useless on another machine. The standard is progressing in the W3C (World Wide Web Consortium) WebAppSec group; I did not find a commitment from other browsers. For today it is an extra layer and does not replace a BFF (Backend for Frontend) and short-lived sessions.

Auditing an Old Project

Instead of remembering all of the above by hand, I put it into a script. It has no dependencies and looks at node_modules, angular.json, src and .npmrc at the root of an Angular project:

// Usage: node audit-angular-security.mjs <project-dir>
import { readFile, readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';

const root = process.argv[2] ?? '.';
const results = [];
const report = (level, id, message) => results.push(`${level.padEnd(4)} ${id.padEnd(14)} ${message}`);

const readJson = async path => JSON.parse(await readFile(join(root, path), 'utf8'));
const parseVersion = v => v.split('.').map(Number);
const inRange = (v, from, to) => {
  const cmp = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
  return cmp(parseVersion(v), parseVersion(from)) >= 0 && cmp(parseVersion(v), parseVersion(to)) < 0;
};

async function sourceFiles(dir) {
  const files = [];
  for (const entry of await readdir(dir, { withFileTypes: true })) {
    const path = join(dir, entry.name);
    if (entry.isDirectory()) files.push(...await sourceFiles(path));
    else if (/\.(ts|html)$/.test(entry.name) && !entry.name.endsWith('.spec.ts')) files.push(path);
  }
  return files;
}

async function grep(pattern) {
  const hits = [];
  for (const file of await sourceFiles(join(root, 'src'))) {
    (await readFile(file, 'utf8')).split('\n').forEach((line, i) => {
      if (pattern.test(line)) hits.push(`${file.slice(root.length + 1)}:${i + 1}`);
    });
  }
  return hits;
}

// 1. Support window (angular.dev/reference/releases, November 2026)
const core = (await readJson('node_modules/@angular/core/package.json')).version;
const ltsEnds = { 22: '2028-06', 21: '2027-06', 20: '2026-11-28' };
const major = parseVersion(core)[0];
if (ltsEnds[major]) report('OK', 'support', `Angular ${core}, security fixes until ${ltsEnds[major]}`);
else report('FAIL', 'support', `Angular ${core} no longer receives security fixes`);

// 2. XSRF for the root-provided HttpClient (fixed in 21.2.19 and 22.1.0)
const usesProvideHttpClient = (await grep(/provideHttpClient\(/)).length > 0;
const xsrfGap = inRange(core, '21.0.0', '21.2.19') || inRange(core, '22.0.0', '22.1.0');
if (xsrfGap && !usesProvideHttpClient) report('FAIL', 'xsrf', 'HttpClient used without provideHttpClient(): XSRF header is not sent');
else report('OK', 'xsrf', usesProvideHttpClient ? 'provideHttpClient() found' : 'root-provided HttpClient includes XSRF interceptor');

const xsrfOff = await grep(/withNoXsrfProtection|HttpClientXsrfModule\.disable/);
if (xsrfOff.length) report('WARN', 'xsrf-off', `XSRF protection disabled: ${xsrfOff.join(', ')}`);

// 3. Sanitizer bypasses and raw DOM sinks
const bypass = await grep(/bypassSecurityTrust(Html|Script|Style|Url|ResourceUrl)/);
if (bypass.length) report('WARN', 'bypass', bypass.join(', '));
const sinks = await grep(/\.(innerHTML|outerHTML)\s*=|insertAdjacentHTML|document\.write/);
if (sinks.length) report('WARN', 'dom-sink', sinks.join(', '));

// 4. CSP: autoCsp for static hosting, nonce for SSR
const isSsr = existsSync(join(root, 'node_modules/@angular/ssr'));
const angularJson = await readJson('angular.json');
const project = Object.values(angularJson.projects)[0];
const autoCsp = project.architect.build.options?.security?.autoCsp;
const nonce = (await grep(/ngCspNonce|CSP_NONCE/)).length > 0;
if (isSsr) report(nonce ? 'OK' : 'WARN', 'csp', nonce ? 'nonce wiring found' : 'SSR app without ngCspNonce/CSP_NONCE');
else report(autoCsp ? 'OK' : 'WARN', 'csp', autoCsp ? 'security.autoCsp enabled' : 'security.autoCsp not enabled');

// 5. SSR host validation
if (isSsr) {
  const hosts = await grep(/allowedHosts/);
  const commonEngine = await grep(/CommonEngine/);
  report(hosts.length ? 'OK' : 'WARN', 'ssr-hosts',
    hosts.length ? `allowedHosts set: ${hosts.join(', ')}` : 'no allowedHosts in code; set NG_ALLOWED_HOSTS at deploy');
  if (commonEngine.length) report('WARN', 'ssr-engine', `CommonEngine in use: ${commonEngine.join(', ')}`);
}

// 6. npm install policy (see the npm supply chain post)
const npmrc = existsSync(join(root, '.npmrc')) ? await readFile(join(root, '.npmrc'), 'utf8') : '';
const pkg = await readJson('package.json');
report(/min-release-age/.test(npmrc) ? 'OK' : 'WARN', 'npm-age', /min-release-age/.test(npmrc) ? 'min-release-age set' : 'no min-release-age in .npmrc');
report(pkg.allowScripts || /ignore-scripts\s*=\s*true/.test(npmrc) ? 'OK' : 'WARN', 'npm-scripts',
  pkg.allowScripts ? 'allowScripts policy present' : /ignore-scripts/.test(npmrc) ? 'ignore-scripts=true' : 'no install script policy');

console.log(results.join('\n'));
process.exitCode = results.some(r => r.startsWith('FAIL')) ? 1 : 0;

First the 22.0.8 project from the opening, in its state before provideHttpClient() was added:

OK   support        Angular 22.0.8, security fixes until 2028-06
FAIL xsrf           HttpClient used without provideHttpClient(): XSRF header is not sent
WARN csp            security.autoCsp not enabled
WARN npm-age        no min-release-age in .npmrc
WARN npm-scripts    no install script policy

Then an untouched Angular 22.2.5 SSR project created with ng new --ssr:

OK   support        Angular 22.2.5, security fixes until 2028-06
OK   xsrf           root-provided HttpClient includes XSRF interceptor
WARN csp            SSR app without ngCspNonce/CSP_NONCE
WARN ssr-hosts      no allowedHosts in code; set NG_ALLOWED_HOSTS at deploy
WARN npm-age        no min-release-age in .npmrc
WARN npm-scripts    no install script policy

Even a new project gets four warnings in its default state; that is not Angular's fault, these settings depend on the environment. In the client-only Bookstore project I added "security": { "autoCsp": true } to angular.json, wrote min-release-age=3 into .npmrc, denied the install scripts with npm install-scripts deny as in the previous post, and ran it again:

OK   support        Angular 22.2.5, security fixes until 2028-06
OK   xsrf           root-provided HttpClient includes XSRF interceptor
OK   csp            security.autoCsp enabled
OK   npm-age        min-release-age set
OK   npm-scripts    allowScripts policy present

The meta tag that autoCsp added to the built index.html: script-src 'strict-dynamic' 'sha256-55O+w+DkRtC/tV9/VmCsljePVyEYJEhsMbYAfvv3N84=' https: 'unsafe-inline';object-src 'none';base-uri 'self';. A browser that supports hashes ignores 'unsafe-inline' and https:; they are only a fallback for old browsers.

In Production

The script is a starting point, not a decision maker. It searches the source for strings; it misses code that hides a bypassSecurityTrustHtml call in a wrapper, or a sink assigned to a variable. The real guarantee is the version: if the support line says FAIL, green lines elsewhere mean nothing, because unsupported versions do not get patches; for example, the advisory for CVE-2026-104871 published in October 2026 lists no fixed version for 19.x. Run the audit in your CI (Continuous Integration) pipeline and make exit code 1 a step that stops the build; update the dates in the support table from angular.dev with every major release.

When updating an old project, order matters. First move to a supported major, move to the latest patch the same day, then fill allowedHosts in SSR with your real domains and test, together with your load balancer health checks, that unrecognized hosts get a 400 after 22.0.0. Leave CSP and Trusted Types for last, starting with Report-Only.

Common Pitfalls

First, trusting the sentence "on by default". In the XSRF example the docs were right and the code was not, on one path. Write security settings explicitly: provideHttpClient(withXsrfConfiguration(...)), allowedHosts, autoCsp.

Second, looking only at the major version. "We are on Angular 21" can mean 21.0.0 or 21.2.26; the difference is dozens of advisories. During an audit, look at the exact version in node_modules/@angular/core/package.json.

Third, allowedHosts: ['*']. Angular supports it, but its docs recommend it only if another layer in front validates the host. The day you change the load balancer, your protection silently disappears.

Fourth, assuming Baseline means "every user has it". Baseline low means it is in the latest versions; in corporate environments, unupdated browsers stay around for a long time. Trusted Types and setHTML are extra layers, not the only defense.

A Small Experiment

★☆☆ Compare the version in your project's node_modules/@angular/core/package.json with the release table on angular.dev and put the LTS end date on your team's calendar.

★★☆ Run the audit script on your own project. For each WARN line, either apply the fix or write one sentence in the README explaining why it does not apply.

★★★ Repeat the XSRF experiment: build the same component with 21.2.18 and 21.2.19, open them in headless Chrome and see the header difference in the server log, then turn this check into a Playwright test that runs in CI.

This is where the Frontend Security series ends. From the browser's security model to DOM sinks, from strict CSP to Trusted Types, from token storage to SSR and the supply chain, every post covered one layer of the browser side. The server half, such as generating the CSP header from ASP.NET Core and setting up a reporting endpoint, is in the upcoming "ASP.NET Core Security" series.