Subresource Integrity for Remote Entry Files #

A Content Security Policy allows an origin; Subresource Integrity pins the exact bytes served from it — this page adds SRI to dynamically-injected remote entries without a hash anyone has to maintain by hand.

The difficulty is not computing the hash. It is that federation injects the script itself, remotes deploy independently, and a hash the host maintains goes stale on the next remote release. The design below moves hash generation into each remote’s pipeline and reduces the host to a pass-through. It is the second control described in securing federated remotes.

Where the integrity hash is produced and consumed Generating the hash in the remote's own pipeline is what lets remotes keep deploying independently while the host stays unchanged. One hash, produced once, by the team that built the artefact Remote builds entry fingerprinted Pipeline hashes sha384 over the bytes Manifest carries it published atomically Host passes through never computes a hash
The host is deliberately dumb here — the moment it knows how to compute a hash, remote releases start needing host releases.

Prerequisites #

Step 1 — Understand what SRI actually protects #

SRI verifies that the bytes the browser received hash to the value you declared. If they do not, the script is not executed at all — no partial evaluation, no side effects.

That covers a compromised CDN, a bucket overwritten by the wrong pipeline, a cache serving a truncated file, and a transparent proxy injecting content. It does not cover a compromised build: if the remote’s pipeline produced malicious output, the hash is computed over that output and matches perfectly.

Naming that limit matters, because it decides where the control belongs. SRI is a transport-integrity control, and it pairs with build-provenance measures rather than replacing them.

Step 2 — Generate the hash in the remote’s own pipeline #

The hash must be produced by the same job that produces the artefact. Anything else drifts.

# remote pipeline — hash the built entry, base64-encoded, SHA-384
ENTRY=$(ls dist/remoteEntry.*.js)
HASH=$(openssl dgst -sha384 -binary "$ENTRY" | openssl base64 -A)
echo "INTEGRITY=sha384-$HASH" >> "$GITHUB_ENV"
echo "ENTRY_FILE=$(basename "$ENTRY")" >> "$GITHUB_ENV"

SHA-384 is the usual choice: SHA-256 is acceptable, SHA-512 is longer for no practical gain. Whichever you pick, be consistent — a manifest mixing algorithms makes validation harder to reason about for no benefit.

Compute the hash after every transformation the asset will undergo. If a CDN re-compresses or a deploy step rewrites a URL inside the bundle, a hash taken before that step will never match what the browser receives.

Step 3 — Publish the hash with the manifest, atomically #

Publishing and consuming an integrity-pinned remote The asset is uploaded before the manifest advertises it, so no user can ever resolve a URL that does not yet exist. Remote CI CDN Manifest Host 1. upload immutable entry 2. write entry + integrity 3. GET remotes.json 4. url + sha384 5. load with integrity attribute
Reverse the first two messages and every user in the upload window gets a hard failure.

The asset and its hash must become visible together. A manifest that advertises a hash for an asset not yet uploaded produces a hard failure for every user in that window.

// scripts/publish-manifest.mjs — upload first, then flip the pointer
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';

const { remote, version, entry, integrity } = parseArgs(process.argv);

// 1. The immutable asset is already uploaded by the previous step.
// 2. Read, amend and write the manifest in one operation.
const manifest = JSON.parse(await readObject('remotes.json'));
manifest[remote] = { entry, integrity, version, publishedAt: new Date().toISOString() };

await s3.send(new PutObjectCommand({
  Bucket: process.env.BUCKET,
  Key: 'remotes.json',
  Body: JSON.stringify(manifest, null, 2),
  ContentType: 'application/json',
  // Short TTL: this is the only mutable file in the system.
  CacheControl: 'public, max-age=30, must-revalidate',
}));

Ordering is the whole correctness argument: upload the immutable asset, then update the manifest. Reversing it advertises a URL that does not exist yet.

If several remotes publish concurrently, use a conditional write or a lock so one release does not overwrite another’s entry. A read-modify-write on a shared JSON file is a race, and it will eventually bite.

Step 4 — Apply the integrity attribute when injecting the script #

The host reads the hash and applies it. It never computes one.

// src/inject-script.js
export function injectScript({ url, integrity }) {
  return new Promise((resolve, reject) => {
    const el = document.createElement('script');
    el.src = url;
    // Both attributes are required: SRI is only enforced on CORS-enabled requests.
    el.crossOrigin = 'anonymous';
    if (integrity) el.integrity = integrity;

    el.onload = () => resolve();
    el.onerror = () => reject(new Error(
      `remote entry failed integrity or network check: ${url}`
    ));
    document.head.appendChild(el);
  });
}

crossOrigin = 'anonymous' is not optional. Without it the request is not CORS-enabled and the browser ignores the integrity attribute entirely — the script loads, unverified, and nothing indicates that the check was skipped.

Step 5 — Refuse to load an entry with no hash #

An optional integrity check is not a control. Decide the policy explicitly and enforce it by environment.

// src/load-remote.js
const REQUIRE_INTEGRITY = import.meta.env.MODE === 'production';

export async function loadRemote(name, module) {
  const spec = (await getManifest())[name];
  if (!spec) throw new Error(`unknown remote: ${name}`);

  if (REQUIRE_INTEGRITY && !/^sha(256|384|512)-[A-Za-z0-9+/=]+$/.test(spec.integrity ?? '')) {
    // Fail closed: render without this remote rather than loading it unverified.
    throw new Error(`remote "${name}" has no valid integrity hash — refusing to load`);
  }

  await injectScript({ url: spec.entry, integrity: spec.integrity });
  return getExposedModule(name, module);
}

Validating the shape of the hash matters as much as its presence. An empty string, the literal null, or a truncated value would otherwise pass a simple truthiness check and disable verification silently.

Step 6 — Handle chunks, not just the entry #

The entry is one file. A remote typically loads several more chunks after it, and those are requested by the webpack runtime rather than by your helper.

Webpack 5 can carry integrity for these through a plugin that writes hashes into the chunk-loading runtime:

// remote webpack.config.js
const SubresourceIntegrityPlugin = require('webpack-subresource-integrity').SubresourceIntegrityPlugin;

module.exports = {
  output: {
    filename: '[name].[contenthash].js',
    // Required by the plugin so the runtime can attach integrity to chunk requests.
    crossOriginLoading: 'anonymous',
  },
  plugins: [
    new SubresourceIntegrityPlugin({ hashFuncNames: ['sha384'], enabled: true }),
  ],
};

Be honest about the coverage you get. Chunk integrity is embedded in the entry, so verifying the entry transitively protects the chunks it lists. If you can only do one, do the entry — it is the file that describes all the others.

Step 7 — Make a mismatch observable #

Telling an integrity failure apart from a network failure All three failures look identical to the loading code, but they have different owners and different urgency, so the report has to distinguish them. The remote entry did not execute. Which failure is it? Asset reachable, script refused Security event Bytes did not match Page someone Asset unreachable Availability event CDN or network Different owner No hash in the manifest Configuration event Publishing step skipped Fix the pipeline
A HEAD request against the same URL is enough to separate the first two, and it costs nothing on a path that has already failed.

An integrity failure looks like a network failure to your code, and telling them apart is what makes the control operationally useful.

// src/report-integrity.js — a security event, not a flaky-network event
export function reportEntryFailure(name, url, integrity) {
  // A same-origin HEAD tells us whether the asset is reachable at all.
  fetch(url, { method: 'HEAD', mode: 'cors' })
    .then((res) => {
      navigator.sendBeacon('/rum', JSON.stringify({
        metric: res.ok ? 'integrity-mismatch' : 'remote-unreachable',
        remote: name, url, expected: integrity, status: res.status,
      }));
    })
    .catch(() => {
      navigator.sendBeacon('/rum', JSON.stringify({
        metric: 'remote-unreachable', remote: name, url,
      }));
    });
}

If the asset is reachable but the script did not execute, the bytes did not match — that is a security event and should page someone. If it is unreachable, it is a CDN or network problem with a completely different owner. Conflating the two means the important signal is buried in the noisy one.

Alert on the mismatch rate rather than on individual events. A single mismatch is usually a deploy race; a sustained rate across many users is not.

Verification #

Troubleshooting #

Symptom: every remote fails after a deploy, then recovers.

Diagnosis: the manifest was updated before the asset finished uploading. Fix: enforce upload-then-publish ordering, as in step 3.

Symptom: the hash matches locally but the browser rejects it.

Diagnosis: something between the build and the browser modified the file — most often a CDN transformation or a deploy-time URL rewrite. Fix: compute the hash after every transformation, ideally by fetching the published URL and hashing the response.

Symptom: integrity is ignored; a wrong hash still loads.

Diagnosis: the request is not CORS-enabled, so the browser skips verification. Fix: set crossOrigin = 'anonymous' on the element and confirm the remote origin returns a permissive Access-Control-Allow-Origin.

Symptom: local development is constantly broken by integrity errors.

Diagnosis: dev builds are not fingerprinted and rebuild on every change, so any recorded hash is immediately stale. Fix: gate enforcement on the production mode as in step 5, and make the exemption explicit rather than incidental.

Symptom: a rollback re-introduces integrity failures.

Diagnosis: the rollback re-pointed the manifest at a previous version whose entry was purged from the CDN as part of a cleanup job. Fix: never purge fingerprinted assets. Immutability is what makes rollback a pointer change, and a cleanup policy that deletes old entries quietly removes the ability to revert.

Cost, coverage and what to do first #

The runtime cost is negligible — a hash over bytes the browser already downloaded, on the order of a millisecond for a typical entry. The real cost is operational, and it lands entirely in the publishing pipeline: hashes must be generated at the right moment, published atomically, and never maintained by hand.

That cost profile suggests a clear order of adoption. Start with the entry file for every remote, because it is one file per remote, it describes all the others, and it is where a compromise would be most valuable to an attacker. Add chunk integrity later, if at all, and only once entry integrity has been running long enough to be boring.

Be equally clear about what you have not bought. SRI tells you the bytes are the ones the pipeline produced; it says nothing about whether the pipeline should have produced them. Pairing it with the dependency-supply-chain requirements in securing federated remotes is what turns transport integrity into something closer to end-to-end assurance.