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.
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.
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.
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 returnAccess-Control-Allow-Origin, as covered in How CORS and crossorigin Affect SRI. -
Pre-compressed files served without
Content-Encodingare hashed as compressed bytes. If the origin shipsapp.js.gzunder the URLapp.jsand 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 withcurl -sIthatcontent-encoding: gzipis 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
imghas nointegrityattribute. 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.
Related
Permalink to "Related"- Mapping CDN Origins to SRI Policies — assigning trust tiers to origins so edge configuration is auditable rather than ad hoc
- Debugging SRI Hash Mismatch Errors — the full decision path for a mismatch when no CDN is in the picture
- Verifying Deployed Assets Against a Hash Manifest — catching an edge transform in CI instead of in a user’s console