Mapping CDN Origins to SRI Policies

Permalink to "Mapping CDN Origins to SRI Policies"

This page is part of CDN Trust Mapping & Routing, which sits within Asset Hashing & Dynamic Script Injection. It covers exactly one workflow: building a deterministic, machine-readable map from CDN origin URLs to SHA-384 integrity hashes, then consuming that map to construct Content-Security-Policy headers that enforce both origin and payload verification simultaneously.

Quick reference

Permalink to "Quick reference"
Attribute / directive Required value Notes
integrity on <script> sha384-<base64> SHA-384 is the industry default; SHA-512 is also accepted
crossorigin on <script> "anonymous" Mandatory for cross-origin resources; omitting it makes hash comparison impossible
script-src in CSP https://cdn.example.com 'sha384-<hash>' List the origin URL and each hash value
Access-Control-Allow-Origin on CDN response * or your domain Required for the browser’s CORS check that gates SRI
Algorithm identifier in integrity sha384- prefix Must match the algorithm used during hash generation

Browser support: all modern browsers (Chrome 45+, Firefox 43+, Safari 11.1+, Edge 17+). IE 11 ignores the integrity attribute silently.


CDN origin to SRI policy mapping flow Diagram showing four stages: CI build produces hashes; a manifest maps origin URLs to hashes; edge injects CSP headers; browser validates integrity attribute against downloaded bytes. CI Build sha384 per asset Origin→Hash manifest.json Edge / Nginx injects CSP header Browser SRI check webpack / Vite keyed by origin URL script-src + sha384- pass / block

Why origin-to-hash mapping exists

Permalink to "Why origin-to-hash mapping exists"

A <script integrity="sha384-..."> attribute alone protects the payload but says nothing about where the file came from. Without a Content-Security-Policy script-src directive that also names the CDN origin, the browser’s fetch is permitted to any origin that returns the right bytes — including an attacker-controlled mirror. Conversely, listing a CDN origin in script-src without integrity hashes allows that CDN to serve any bytes it chooses.

The origin-to-hash map is the connective tissue: it records that a specific CDN base URL is authorized to serve only the assets whose hashes are listed alongside it. At deploy time the map is consumed by the edge layer or CI step that emits the CSP header, ensuring both the origin and the payload constraints are present in the same directive and stay synchronized across deployments.

The two controls answer different questions. script-src answers “may the browser talk to this host at all”, and it is evaluated before a single byte is read. The integrity attribute answers “are these the exact bytes I signed off on”, and it is evaluated after the response body has been decoded. Crossing the two gives four states, only one of which is the state you want in production:

Origin listing crossed with the integrity attribute A two by two matrix crossing whether the CDN origin appears in the script-src directive with whether the script tag carries an integrity attribute, showing that only the combination of both proves the origin and the payload together. Origin absent from script-src Origin listed in script-src No integrity attribute integrity sha384- present Blocked at fetch time no matching source in CSP Loads unverified bytes the CDN may serve anything Blocked before hashing the origin check runs first Origin and payload proven the state the map produces

Note the asymmetry in the two failing cells. The unverified-bytes cell fails silently: the page works, nothing appears in the console, and the compromise is only visible if you diff the delivered file against what you published. The blocked cells fail loudly and immediately. That asymmetry is why an origin allowlist without hashes is the more dangerous half-configuration of the two.

Canonical implementation: manifest-driven CSP injection

Permalink to "Canonical implementation: manifest-driven CSP injection"

The pattern below generates an sri-manifest.json in Webpack, then reads it in a Node.js edge-startup script to build the exact Content-Security-Policy string.

Step 1 — emit the manifest in Webpack

Permalink to "Step 1 — emit the manifest in Webpack"
// webpack.config.js
const { SubresourceIntegrityPlugin } = require('webpack-subresource-integrity');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const SriManifestPlugin = require('./scripts/sri-manifest-plugin'); // local plugin below

module.exports = {
  output: {
    crossOriginLoading: 'anonymous',
    filename: '[name].[contenthash].js',
  },
  plugins: [
    new HtmlWebpackPlugin(),
    new SubresourceIntegrityPlugin({ hashFuncNames: ['sha384'] }),
    new SriManifestPlugin({ cdnOrigin: 'https://cdn.example.com' }),
  ],
};

SriManifestPlugin is a small custom plugin that iterates compilation.assets after webpack-subresource-integrity has annotated them:

// scripts/sri-manifest-plugin.js
class SriManifestPlugin {
  constructor({ cdnOrigin }) {
    this.cdnOrigin = cdnOrigin;
  }
  apply(compiler) {
    compiler.hooks.emit.tapAsync('SriManifestPlugin', (compilation, cb) => {
      const entries = [];
      for (const [filename, asset] of Object.entries(compilation.assets)) {
        const integrity = asset.integrity; // injected by webpack-subresource-integrity
        if (integrity && /\.(js|css)$/.test(filename)) {
          entries.push({
            origin: this.cdnOrigin,
            path: `/${filename}`,
            integrity,
          });
        }
      }
      const json = JSON.stringify({ generated: new Date().toISOString(), entries }, null, 2);
      compilation.assets['sri-manifest.json'] = {
        source: () => json,
        size: () => json.length,
      };
      cb();
    });
  }
}
module.exports = SriManifestPlugin;

The resulting sri-manifest.json looks like:

{
  "generated": "2026-06-23T08:00:00Z",
  "entries": [
    {
      "origin": "https://cdn.example.com",
      "path": "/main.a1b2c3d4.js",
      "integrity": "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
    },
    {
      "origin": "https://cdn.example.com",
      "path": "/vendor.e5f6a7b8.js",
      "integrity": "sha384-H8BRh8j48O9oYatfu5AZzq6A9RINhZO5H16dQZngK7T62em8MUt1FLm52t+eX4v0"
    }
  ]
}

Step 2 — consume the manifest to build the CSP header

Permalink to "Step 2 — consume the manifest to build the CSP header"
// scripts/build-csp-header.js  (runs in CI or at edge startup)
const manifest = require('../dist/sri-manifest.json');

function buildScriptSrc(manifest) {
  const origins = new Set();
  const hashes = [];

  for (const entry of manifest.entries) {
    origins.add(entry.origin);
    hashes.push(`'${entry.integrity}'`);
  }

  const originList = [...origins].join(' ');
  const hashList = hashes.join(' ');
  return `script-src 'self' ${originList} ${hashList}`;
}

const scriptSrc = buildScriptSrc(manifest);
const cspHeader = `${scriptSrc}; object-src 'none'; base-uri 'self'`;

console.log(cspHeader);
// script-src 'self' https://cdn.example.com 'sha384-oqVu...' 'sha384-H8BR...'; object-src 'none'; base-uri 'self'

Every token in that emitted directive comes from a different part of the map, and each one carries a distinct obligation:

Anatomy of a generated script-src directive The string script-src quote self https colon slash slash cdn dot example dot com sha384 hash, split into four labelled tokens: the directive name, the same-origin keyword, the CDN origin the browser may fetch from, and the digest the decoded response body must match. One line of the Content-Security-Policy header, token by token script-src 'self' https://cdn.example.com 'sha384-oqVu...' directive under control same-origin scripts host the browser is allowed to fetch from digest the decoded body must match Drop the origin token and the fetch never happens. Drop the hash tokens and any bytes that host returns will execute.

The 'sha384-...' tokens are quoted CSP source expressions, not the raw value of an integrity attribute — the surrounding single quotes are part of the grammar and a directive missing them is discarded as an unrecognized source. Because the generator writes both halves from the same manifest entry, the quoted hash in the header and the unquoted hash in the tag can never drift apart. If you produce the manifest inside a hosted pipeline rather than at edge startup, Generating an SRI Manifest in GitHub Actions covers the workflow steps and artifact upload that make the file available to the deploy job.

Step 3 — inject the header at the edge

Permalink to "Step 3 — inject the header at the edge"

For Nginx, write the computed string to a variable file that the configuration includes at runtime:

# /etc/nginx/conf.d/sri.conf  — generated by build-csp-header.js in CI
geo $csp_script_src {
  default "script-src 'self' https://cdn.example.com 'sha384-oqVu...' 'sha384-H8BR...'";
}
# /etc/nginx/sites-enabled/app.conf
server {
  listen 443 ssl http2;
  server_name app.example.com;

  include conf.d/sri.conf;

  location / {
    add_header Content-Security-Policy "${csp_script_src}; object-src 'none'; base-uri 'self'" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header Cross-Origin-Resource-Policy "cross-origin" always;
    try_files $uri $uri/ =404;
  }
}

For a Cloudflare Worker, fetch the manifest from R2 or a KV namespace at startup and build the header string dynamically — the parent topic, CDN Trust Mapping & Routing, describes that arrangement.

Two details in the Nginx form are easy to get wrong. The always flag on add_header matters: without it the header is attached only to 2xx, 204, 301, 302 and 304 responses, so an error page rendered from the same server block ships with no policy at all. And add_header directives do not merge across nesting levels — if any location block declares its own add_header, every inherited header from the server level is dropped inside that location, silently removing the policy from exactly the paths that declared extra headers. Keep the SRI-related headers in one block or repeat them in full wherever you override.

Variant configurations

Permalink to "Variant configurations"

Multiple CDN origins with separate trust tiers

Permalink to "Multiple CDN origins with separate trust tiers"

When assets come from a primary CDN and a secondary failover CDN, list both origins but assign hashes only once — the hash covers the payload, not the origin:

Content-Security-Policy:
  script-src 'self'
    https://primary.cdn.example.com
    https://fallback.cdn.example.com
    'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC'
    'sha384-H8BRh8j48O9oYatfu5AZzq6A9RINhZO5H16dQZngK7T62em8MUt1FLm52t+eX4v0';
  style-src 'self'
    https://primary.cdn.example.com
    'sha384-abc123...';
  object-src 'none';
  base-uri 'self'

Vite project with vite-plugin-subresource-integrity

Permalink to "Vite project with vite-plugin-subresource-integrity"

Vite does not expose a plugin hook as early as Webpack’s emit, so the manifest is built by a post-build script that reads the generated HTML:

// vite.config.js
import { defineConfig } from 'vite';
import sri from 'vite-plugin-subresource-integrity';

export default defineConfig({
  build: { rollupOptions: { output: { entryFileNames: '[name].[hash].js' } } },
  plugins: [sri({ algorithms: ['sha384'] })],
});
# post-build: extract hashes from dist/index.html into sri-manifest.json
node scripts/extract-sri-from-html.js dist/index.html \
  --origin https://cdn.example.com \
  --out dist/sri-manifest.json

Third-party script pinning (no build tool)

Permalink to "Third-party script pinning (no build tool)"

For a third-party library served from a public CDN, compute the hash manually and hard-code it. Re-verify the hash whenever the vendor ships a new version:

<script
  src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"
  integrity="sha384-dHdlP36K5RFVVJ3Q8HBqMreYqNR7fVSChzTh0dSNuMEUhYaUCBV2dA2Hm1KhDi/"
  crossorigin="anonymous"
  defer></script>

Use How to Calculate SHA-256 vs SHA-384 for SRI to generate the hash value from the file locally before embedding it.

Gotchas and edge cases

Permalink to "Gotchas and edge cases"
  • Missing crossorigin="anonymous" breaks hash comparison. When a <script> tag on a cross-origin resource omits this attribute, the browser performs a no-CORS fetch and receives an opaque response. The browser cannot read the response body to compute a hash, so the integrity check fails unconditionally. Every <script> or <link> that references a cross-origin resource and carries an integrity attribute must also carry crossorigin="anonymous".

  • CDN-side byte-altering transforms invalidate hashes. Standard Content-Encoding compression (gzip, Brotli) is safe — the browser computes the hash over the decoded body. But CDN-level minification, whitespace removal, byte injection (ad networks, analytics snippets), or character-encoding normalization alter the decoded bytes and will cause every integrity check to fail. Disable all such transforms for SRI-protected paths.

  • CSP must name the origin, not just the hash. Listing only 'sha384-...' in script-src causes Chrome to allow the script if the hash matches regardless of origin, but only when there is no explicit origin in the directive. Firefox and Safari behave differently. The safe and spec-compliant approach is always to list both the origin URL and the hash together.

  • Hash drift on hotfix deployments. Deploying a patched asset file without updating the integrity attribute in the HTML and the script-src CSP header simultaneously causes an immediate integrity mismatch and a broken site. Use atomic deployments (deploy HTML and assets in a single pipeline step) and content-hashed filenames so the manifest stays in sync with the served files. A pipeline gate that recomputes the digests and refuses to promote a build whose hashes moved without the manifest moving with them is the cheapest way to catch this class of drift; Failing CI on SRI Hash Drift walks through that gate.

  • Cache-Control on HTML entry points must not cache stale hashes. If an HTML page is cached at a CDN edge for hours but assets have been redeployed with new hashes, returning browsers will load the old HTML with stale integrity values that no longer match the new assets. Set Cache-Control: no-cache or max-age=0, must-revalidate on all HTML entry points.

Verification steps

Permalink to "Verification steps"

DevTools console — what a correct deployment looks like

Permalink to "DevTools console — what a correct deployment looks like"

Open the browser console after a page load. If SRI is working correctly, there are no integrity-related messages. A failure looks like:

Failed to find a valid digest in the 'integrity' attribute for resource
'https://cdn.example.com/main.a1b2c3d4.js' with computed SHA-384 integrity
'sha384-DIFFERENT_HASH_HERE'. The resource has been blocked.

If you see this in production, the asset bytes the CDN served do not match the hash compiled into the HTML. Check for CDN-side transforms, cache staleness, or a deployment ordering issue.

A second, very different message means the CSP half of the map is wrong rather than the hash half:

Refused to load the script 'https://cdn.example.com/main.a1b2c3d4.js' because
it violates the following Content Security Policy directive: "script-src 'self'
'sha384-oqVu...'". Note that 'script-src-elem' was not explicitly set, so
'script-src' is used as a fallback.

Read the quoted directive in that message carefully — it is the policy the browser actually received, which is frequently not the one you think you deployed. If the origin is missing from it, the generator ran against a manifest whose entries carried no origin field, or a proxy in front of the application stripped and rewrote the header. Distinguishing the two messages is the fastest triage step available: “Failed to find a valid digest” means the request completed and the bytes were wrong, while “Refused to load” means the request was never allowed to start and no hash was ever computed.

CLI — verify the manifest before deployment

Permalink to "CLI — verify the manifest before deployment"

Run this check in CI before the edge-inject step to catch drift early. It reads every manifest entry, rehashes the file on disk, and exits non-zero on the first mismatch — the same comparison Verifying Deployed Assets Against a Hash Manifest performs against the live CDN once the assets are published:

#!/usr/bin/env bash
# verify-sri-manifest.sh
MANIFEST="dist/sri-manifest.json"
FAIL=0

while IFS= read -r entry; do
  path=$(echo "$entry" | jq -r '.path')
  expected=$(echo "$entry" | jq -r '.integrity')
  file="dist${path}"

  if [ ! -f "$file" ]; then
    echo "MISSING: $file"
    FAIL=1
    continue
  fi

  actual=$(openssl dgst -sha384 -binary "$file" | openssl base64 -A)
  actual_sri="sha384-${actual}"

  if [ "$actual_sri" != "$expected" ]; then
    echo "MISMATCH: $file"
    echo "  expected: $expected"
    echo "  actual:   $actual_sri"
    FAIL=1
  else
    echo "OK: $file"
  fi
done < <(jq -c '.entries[]' "$MANIFEST")

exit $FAIL

Expected output on a clean build:

OK: dist/main.a1b2c3d4.js
OK: dist/vendor.e5f6a7b8.js

Network tab — confirm CORS headers are present

Permalink to "Network tab — confirm CORS headers are present"

In DevTools → Network, click the asset request and check the response headers. The CDN must return:

Access-Control-Allow-Origin: *

If this header is absent, the browser’s CORS check blocks the integrity verification step. For Configuring Content-Security-Policy with SRI, CORS is equally critical — both CSP enforcement and SRI depend on a valid CORS response for cross-origin resources.


Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Do I need to list a CDN origin in script-src if I already list its sha384- hashes?

Yes. Hash values in script-src authorize the specific payload, but the browser still checks that the request was permitted to that origin. Without the origin in the directive the resource is blocked before the hash is even evaluated.

Does CDN gzip or Brotli compression break SRI hashes?

No. Browsers compute the SRI hash over the decoded (decompressed) body bytes, matching the hash you compute against the uncompressed file. Only transformations that alter the actual source bytes — minification, byte injection, re-encoding — will break verification.

What happens when a CDN serves a resource from a different edge PoP than expected?

Nothing — the hash covers the file contents, not the delivery path. As long as every PoP caches the same unmodified bytes, SRI verification passes regardless of which edge node responds.

How large can the script-src directive grow before it becomes a problem?

Each SHA-384 hash costs about 72 bytes inside the directive, so a hundred hashed bundles add roughly 7 KB to every response header. Most servers cap total response headers between 8 KB and 16 KB, and Nginx defaults to 4 KB per header buffer. Hash only the entry points and rely on the origin allowlist plus per-tag integrity attributes for lazily loaded chunks.

Should the origin-to-hash manifest be deployed alongside the assets or fetched at runtime?

Deploy it alongside the assets. A manifest fetched at runtime introduces a window in which the edge builds a policy from hashes that do not describe the bytes currently in the CDN cache. If a runtime fetch is unavoidable, key the manifest by the build identifier and have the edge refuse to serve a policy whose build identifier does not match the deployed HTML.

Permalink to "Related"

Related Articles

Configuring SRI for jsDelivr and unpkg
SRI with Cloudflare and Fastly Edge Transforms
CDN Trust Mapping & Routing Asset Hashing & Dynamic Script…