SRI with Cloudflare and Fastly Edge Transforms

Permalink to "SRI with Cloudflare and Fastly Edge Transforms"

Part of CDN Trust Mapping & Routing, this page covers the single most common cause of a correct hash failing in production: a CDN feature that rewrites the response body between your origin and the browser, so the bytes the browser hashes are not the bytes you hashed at build time.

Quick Reference

Permalink to "Quick Reference"
Edge feature Platform Effect on the decoded body Off switch
Auto Minify (deprecated) Cloudflare Strips whitespace and comments from JS, CSS, HTML Zone setting minify, or a Configuration Rule
Rocket Loader Cloudflare Rewrites and re-injects <script> elements data-cfasync="false", zone setting rocket_loader
Email Obfuscation Cloudflare Rewrites mailto: links, injects a decoder script <!--email_off--> markers, zone setting email_obfuscation
Polish / Mirage Cloudflare Re-encodes and lazy-loads images Zone settings polish and mirage
HTMLRewriter in a Worker Cloudflare Whatever your element handlers do Path guard that returns the response untouched
Edge Side Includes Fastly Parses the body and splices in fragments set beresp.do_esi = false;
Compute body rewrite Fastly Whatever your handler writes Return the upstream response without reading it
gzip / Brotli Both None — removed before the digest is computed Nothing to change

Default rule: everything that carries an integrity attribute should travel from build output to browser as an opaque blob, and every body-rewriting feature should be scoped away from that path.

The mental model

Permalink to "The mental model"

Subresource Integrity is a check on the decoded payload. When the browser fetches a script or stylesheet, the network stack strips Content-Encoding first and hands the plain bytes to the fetch layer, and only then is the digest computed and compared against the metadata in the integrity attribute. That ordering is the reason compression is a non-event for SRI. Your origin can serve an uncompressed file, Cloudflare can Brotli-compress it on the way out, a corporate proxy can decompress and re-gzip it, and the digest is identical at every step because none of those hops changed a single byte of the decoded content.

Minification is a different category entirely. When an edge strips comments and whitespace from JavaScript, it produces a different file that happens to be semantically equivalent. Semantic equivalence is worth nothing to a hash function: one removed newline flips roughly half the bits of the digest. The same is true of an HTML rewriter that adds an attribute, an ESI processor that splices a fragment into the body, and an image optimiser that re-encodes a PNG as WebP. Compression is a transport concern; transformation is a content concern. SRI is deliberately blind to the first and absolutely unforgiving about the second.

Where the bytes change between build and browser A four stage pipeline runs from build output through the origin server and the CDN edge to the browser. Compression applied between origin and edge is decoded before hashing and is safe, while minification, Rocket Loader and ESI at the edge change the decoded body and produce a console integrity error. build output SHA-384 computed origin server bytes as built CDN edge optional rewrite browser re-hash + compare gzip and Brotli are decoded first the digest never sees the wire bytes minify, Rocket Loader, ESI change the decoded body console: Failed to find a valid digest in the 'integrity' attribute

The error the reader has almost certainly already seen looks like this in Chrome and Edge:

Failed to find a valid digest in the 'integrity' attribute for resource
'https://cdn.example.com/static/app.4f2a91.js' with computed SHA-384 integrity
'Zx1n8Hy0uQ9wKQ0m3rC7ZzYb1L9pT5Vv2Rk8sN4dU6fG3hJ1kL0aB7cD9eF2gH4i'.
The resource has been blocked.

Firefox phrases it as None of the "sha384" hashes in the integrity attribute match the content of the subresource. Safari reports a shorter message about a failed integrity metadata check without printing the computed value. Chrome’s version is the useful one, because the digest it prints is a fingerprint of the transformed body — save it, because you will match it against a digest you compute yourself in a moment.

Which edge features rewrite the bytes

Permalink to "Which edge features rewrite the bytes"

On Cloudflare, the historical offender is Auto Minify. It was deprecated in August 2024 and the dashboard toggle was withdrawn for zones that were not already using it, but the setting persists on older zones and inside long-lived Page Rules, and plenty of third-party runbooks still tell people to enable it. Rocket Loader is the feature most likely to bite a modern zone: it rewrites <script> elements in the HTML document, defers them, and re-injects them through its own loader at runtime, which both changes the HTML body and means the eventual request is issued by a script element Rocket Loader constructed rather than the one you wrote. Anything Rocket Loader does not copy across — including integrity and crossorigin — is simply absent, which is the same failure mode described in Adding Integrity to Runtime-Injected Scripts.

Email Obfuscation rewrites mailto: links into an encoded span and injects a decoder script served from a /cdn-cgi/scripts/.../email-decode.min.js path. That script arrives with no integrity attribute at all, which matters if you enforce Combining require-sri-for with CSP, because the injected file will be refused by the policy. Polish and Mirage operate on images: Polish recompresses and can convert to WebP or AVIF, Mirage rewrites img markup for lazy loading. Neither triggers a console SRI error, because an img element has no integrity attribute, but both will invalidate a digest you recorded for those files — the exact problem that surfaces in Service Worker Cache Integrity Checks. Finally, any Worker of your own that pipes a response through HTMLRewriter is an edge transform, and it is the one you control.

Fastly ships no equivalent of Auto Minify — nothing rewrites your body unless you asked for it. The two things that do are Edge Side Includes, enabled by setting beresp.do_esi in vcl_fetch, and body manipulation in a Compute service. ESI is the sharper edge: once enabled for a response, the ESI processor parses the body looking for <esi:include> tags, and the delivered bytes are the result of that parse. Enabling ESI on a broad content-type condition rather than a narrow path condition is the classic way to accidentally run a JavaScript bundle through an XML-ish parser. Fastly’s on-the-fly gzip and Brotli (beresp.gzip), header manipulation in vcl_deliver, and shielding all leave the body alone.

Edge features versus integrity impact A table listing gzip and Brotli compression, Auto Minify, Rocket Loader, Email Obfuscation, Polish and Mirage, Fastly ESI, and header rewrites, showing for each whether the decoded body changes and the resulting effect on integrity checking. edge feature changes decoded bytes? effect on integrity gzip / Brotli compression no — transport only safe Auto Minify (deprecated) yes — JS, CSS, HTML breaks SRI Rocket Loader yes — rewrites script tags breaks SRI Email Obfuscation yes — injects a script unhashed script Polish / Mirage yes — image bytes breaks manifests Fastly ESI yes — parses the body breaks SRI header rewrites (VCL) no — headers only safe

Detecting that the edge changed the bytes

Permalink to "Detecting that the edge changed the bytes"

The diagnosis is a two-fetch comparison: hash what the edge serves, hash what the origin serves, and see which one matches the integrity value in your HTML. Use --compressed on both so curl negotiates and then decodes compression, putting you on exactly the same footing as the browser. The hashing pipeline is the same one described in Generating SRI Hashes with OpenSSL and shasum.

# 1. What the edge serves
curl -sS --compressed https://cdn.example.com/static/app.4f2a91.js \
  | openssl dgst -sha384 -binary | openssl base64 -A; echo

# 2. What the origin serves, with the edge cut out of the path
curl -sS --compressed --resolve cdn.example.com:443:203.0.113.10 \
  https://cdn.example.com/static/app.4f2a91.js \
  | openssl dgst -sha384 -binary | openssl base64 -A; echo

--resolve keeps the Host header and SNI intact while forcing the connection to the origin address, so virtual hosting and TLS still work. If the origin presents a certificate your trust store does not accept — a Cloudflare Origin CA certificate, for example — add --insecure for the test only. A cleaner alternative on Cloudflare is a DNS-only (“grey cloud”) record such as origin-direct.example.com pointing at the same origin, which gives you an unproxied hostname to fetch without certificate gymnastics.

Deciding whether the edge or the build broke the hash Starting from an SRI failure in the console, the tree asks whether the digest of the edge response matches the integrity value. If it matches, the markup carries a stale hash. If it does not, the origin response is hashed next, and either the feature is disabled at the edge or the hash is rebuilt. SRI failure in the console hash the edge response does it match integrity? yes no bytes match at the edge suspect stale HTML or hash edge changed the bytes now hash origin-direct origin digest matches: turn the feature off origin also differs: rebuild the hash

When the digests differ, a byte diff tells you what changed, which usually names the culprit outright. Missing newlines mean minification; a type="text/rocketloader" attribute means Rocket Loader; a __cf_email__ span means Email Obfuscation.

curl -sS --compressed https://cdn.example.com/static/app.4f2a91.js > edge.js
curl -sS --compressed --resolve cdn.example.com:443:203.0.113.10 \
  https://cdn.example.com/static/app.4f2a91.js > origin.js
cmp edge.js origin.js
diff <(xxd edge.js) <(xxd origin.js) | head -20

Response headers are the corroborating evidence. Cloudflare returns cf-ray and cf-cache-status on every proxied response, and adds cf-polished when Polish has re-encoded an image. Fastly answers Fastly-Debug: 1 with expanded diagnostics alongside the usual X-Served-By, X-Cache and X-Cache-Hits.

Canonical example: exempt the hashed asset path on Cloudflare

Permalink to "Canonical example: exempt the hashed asset path on Cloudflare"

Scope the exemption to the prefix that serves content-addressed files. A Configuration Rule is the modern mechanism — it sets zone features per request, evaluates before caching, and supersedes the equivalent Page Rule toggles. This rule turns off every body-mutating feature for /static/:

curl -sS -X POST \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/rulesets/phases/http_config_settings/entrypoint/rules" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "description": "No body transforms on hashed assets",
    "expression": "(starts_with(http.request.uri.path, \"/static/\"))",
    "action": "set_config",
    "action_parameters": {
      "rocket_loader": false,
      "email_obfuscation": false,
      "mirage": false,
      "polish": "off"
    }
  }'

Pair it with a Cache-Control: no-transform header from the origin, which asks any conforming intermediary — including proxies you do not operate — to leave the payload alone. Treat it as a second line of defence rather than a guarantee: not every optional feature honours it, so the explicit off switch stays.

The tag it protects is unremarkable, and that is the point:

<script src="https://cdn.example.com/static/app.4f2a91.js"
        integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
        crossorigin="anonymous"
        defer></script>

Variants

Permalink to "Variants"

Fastly: keep ESI and Compute rewrites off hashed paths

Permalink to "Fastly: keep ESI and Compute rewrites off hashed paths"

Guard beresp.do_esi with a path condition rather than a content-type condition, so a bundle can never be fed to the ESI parser:

sub vcl_fetch {
  if (req.url.path ~ "^/static/") {
    set beresp.do_esi = false;
    set beresp.gzip = true;   # transport only, safe for SRI
  } else if (beresp.http.Content-Type ~ "^text/html") {
    set beresp.do_esi = true;
  }
  return(deliver);
}

In a Compute service the equivalent discipline is to return the upstream response object without ever reading its body — once you call text() and reconstruct a Response, you own the bytes and any accidental normalisation is yours:

addEventListener("fetch", (event) => event.respondWith(handleRequest(event)));

async function handleRequest(event) {
  const url = new URL(event.request.url);
  const res = await fetch(event.request, { backend: "origin" });

  // Hashed assets stream through untouched.
  if (url.pathname.startsWith("/static/")) return res;

  const html = await res.text();
  return new Response(html.replace("<!--BANNER-->", "<p>Sale</p>"), {
    headers: res.headers,
  });
}

Cloudflare Workers: guard HTMLRewriter with a path and type check

Permalink to "Cloudflare Workers: guard HTMLRewriter with a path and type check"

HTMLRewriter only parses responses you hand it, so the fix is a pair of early returns:

export default {
  async fetch(request) {
    const url = new URL(request.url);
    const response = await fetch(request);

    if (url.pathname.startsWith("/static/")) return response;
    if (!(response.headers.get("content-type") || "").includes("text/html")) {
      return response;
    }

    return new HTMLRewriter()
      .on("a[target=_blank]", {
        element(el) { el.setAttribute("rel", "noopener noreferrer"); },
      })
      .transform(response);
  },
};

Zone-wide off switches when you cannot scope by route

Permalink to "Zone-wide off switches when you cannot scope by route"

If hashed assets are scattered across the zone, turn the features off globally. Each is a single settings endpoint:

for s in rocket_loader email_obfuscation mirage; do
  curl -sS -X PATCH "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/settings/$s" \
    -H "Authorization: Bearer $CF_API_TOKEN" \
    -H "Content-Type: application/json" \
    --data '{"value":"off"}'
done

The minify setting still exists on zones that predate the deprecation and takes an object ({"css":"off","html":"off","js":"off"}); on newer zones the endpoint no longer applies. Serving your own already-minified build output is the correct answer either way, and it is what public registry CDNs do — see Configuring SRI for jsDelivr and unpkg for how the same guarantee is expressed on a third-party origin.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • Omitting crossorigin="anonymous" fails the same way a transform does. A cross-origin script without CORS gives the browser an opaque response it cannot hash, so it blocks the resource — and the console message is close enough to a digest mismatch to send you hunting the wrong bug. Every hashed tag needs the attribute and the CDN needs to return Access-Control-Allow-Origin, as covered in How CORS and crossorigin Affect SRI.

  • Pre-compressed files served without Content-Encoding are hashed as compressed bytes. If the origin ships app.js.gz under the URL app.js and forgets the header, the browser never decodes it and hashes gzip framing instead. The digest will be stable but wrong, and no edge feature is to blame. Check with curl -sI that content-encoding: gzip is actually present.

  • A Page Rule that “disables performance” is not a complete off switch. Page Rule aggregate actions do not cover every feature, and where a Page Rule and a Configuration Rule both match, only one wins. Prefer one explicit Configuration Rule that names each setting, and verify the result on a live request rather than trusting the dashboard summary.

  • Image transforms fail silently. Polish and Mirage cannot produce a browser SRI error because img has no integrity attribute. The failure shows up later, when a manifest verifier or a cache validator compares a stored digest against a re-encoded file, and it will look like corruption rather than configuration.

  • Purge after every configuration change. Turning a feature off does not rewrite objects already in cache. A transformed body can sit at an edge node for the rest of its TTL and keep failing for a subset of users, which is exactly the pattern that makes Alerting on SRI Failures from CSP Reports worth wiring up before you need it.

Verification Steps

Permalink to "Verification Steps"

1. Confirm compression is not the variable

Permalink to "1. Confirm compression is not the variable"
curl -sS --compressed https://cdn.example.com/static/app.4f2a91.js \
  | openssl dgst -sha384 -binary | openssl base64 -A; echo
curl -sS -H 'Accept-Encoding: identity' https://cdn.example.com/static/app.4f2a91.js \
  | openssl dgst -sha384 -binary | openssl base64 -A; echo

Both commands must print the same base64 string. If they do, compression is behaving and the difference lies in the content.

2. Compare edge and origin digests

Permalink to "2. Compare edge and origin digests"

Run the two-fetch comparison from the diagnosis section. A match on the origin fetch and a mismatch on the edge fetch is conclusive:

origin: oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC
edge:   Zx1n8Hy0uQ9wKQ0m3rC7ZzYb1L9pT5Vv2Rk8sN4dU6fG3hJ1kL0aB7cD9eF2gH4i

3. Confirm the feature is actually off

Permalink to "3. Confirm the feature is actually off"

Rocket Loader announces itself in the delivered markup. Fetch the HTML document and grep for its signature:

curl -sS --compressed https://www.example.com/ | grep -c 'rocketloader\|cloudflare-static/rocket-loader'

Expected output once the rule is live:

0

4. Purge and re-verify from a cold edge

Permalink to "4. Purge and re-verify from a cold edge"
curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
  -H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json" \
  --data '{"files":["https://cdn.example.com/static/app.4f2a91.js"]}'

curl -sS -D- --compressed -o body.js https://cdn.example.com/static/app.4f2a91.js | grep -i cf-cache-status
openssl dgst -sha384 -binary body.js | openssl base64 -A; echo

The header should read cf-cache-status: MISS on the first request after the purge, and the digest must now equal the value in your integrity attribute. Reload the page with the network panel open and confirm the console error is gone.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Does gzip or Brotli compression break SRI?

No. Subresource Integrity is checked against the response body the fetch layer hands to the consumer, which is after Content-Encoding has been removed. An edge that compresses an uncompressed origin response, or that re-encodes Brotli as gzip, leaves the digest untouched. What breaks the check is any change to the decoded bytes: minification, script rewriting, ESI splicing, or image re-encoding.

Cloudflare removed Auto Minify, so why does my hash still fail?

Auto Minify was deprecated in August 2024, but the setting survives on older zones and inside long-lived Page Rules, and several other features mutate bodies. Rocket Loader rewrites script tags, Email Obfuscation injects a decoder script, and a Worker running HTMLRewriter changes whatever its handlers touch. Hash the edge response and the origin response separately before blaming minification.

Does Rocket Loader preserve the integrity attribute?

Do not rely on it. Rocket Loader takes script elements out of the parser’s hands and re-injects them through its own loader, and any attribute it does not copy across is gone by the time the request is issued. Mark every hashed tag with data-cfasync set to false so Rocket Loader skips it, or turn the feature off on routes that serve hashed markup.

How do I tell whether the edge or my build changed the bytes?

Hash three things: the file in your build output, the response fetched straight from the origin, and the response through the edge. If the build and origin digests agree but the edge digest differs, an edge feature is rewriting the body. If the origin already differs from the build output, the fault is in deployment or in an origin-side module, not the CDN.

Do I need to disable Polish and Mirage for images?

Only if something in your stack hashes image bytes. An img element cannot carry an integrity attribute, so Polish and Mirage never raise a console SRI error. They will, however, break a build-time hash manifest, a service worker that verifies cached image digests, or any signature computed over the original file. Send Cache-Control: no-transform and switch Polish off for those paths.

Permalink to "Related"

Related Articles

Mapping CDN Origins to SRI Policies
Configuring SRI for jsDelivr and unpkg
CDN Trust Mapping & Routing Asset Hashing & Dynamic Script…