Debugging SRI Hash Mismatch Errors
Permalink to "Debugging SRI Hash Mismatch Errors"Part of Browser Enforcement & Security Boundaries, this page is a diagnostic runbook for the moment a browser blocks a resource with an SRI error — how to read the message, re-hash the served bytes, and trace the mismatch back to its root cause.
Quick Reference
Permalink to "Quick Reference"The exact console strings you will encounter, and what each one actually means:
| Console string (abridged) | Engine | Real cause |
|---|---|---|
Failed to find a valid digest in the 'integrity' attribute … The resource has been blocked. |
Chromium | Computed digest did not match any token, or algorithm unsupported |
None of the "sha384" hashes in the integrity attribute match the content of the subresource. |
Firefox | Served bytes differ from the declared hash |
net::ERR_SRI_FAILED (Network tab status) |
Chromium | Integrity check failed; resource never executed |
Refused to load … because it does not match a valid integrity hash (opaque) |
Chromium | Cross-origin fetch was no-CORS; body unreadable |
| No error, script silently absent | All | integrity present but crossorigin missing on cross-origin tag |
If the console reports a computed hash, the bytes were readable and the mismatch is real. If it complains about opacity or CORS, the body was never hashed at all.
Chromium’s full message is worth reading field by field rather than skimming: it names which check failed, the exact URL to re-fetch, the digest it computed from the bytes it received, and what it did about the failure.
Firefox phrases the same failure differently and omits the computed digest, so on Firefox you always have to re-hash the served bytes yourself before you can say anything about drift.
What a “mismatch” actually is
Permalink to "What a “mismatch” actually is"A hash mismatch means the digest the browser computed over the bytes it received does not equal the token in your integrity attribute. Because the check runs on the decoded response body — after TLS terminates and after Content-Encoding is stripped — a mismatch is almost always one of two things: the bytes genuinely changed somewhere between your build and the browser, or the browser could never read the bytes in the first place because the response was opaque. Distinguishing those two cases is the whole job, and the console string tells you which one you have. The precise fetch-time enforcement rules behind these errors are documented in Browser Enforcement & Security Boundaries; if the digest itself is correct but you are unsure it was generated properly, cross-check against How to Calculate SHA-256 vs SHA-384 for SRI.
Canonical Debugging Workflow
Permalink to "Canonical Debugging Workflow"Reproduce exactly what the browser did: fetch the live URL, decode transfer encoding, hash the result, and compare it to the token in your HTML. Use --compressed so curl strips Content-Encoding the same way the browser does.
# 1. Re-hash the bytes the CDN actually serves (decoded, like the browser)
curl -sL --compressed https://cdn.example.com/lib.min.js \
| openssl dgst -sha384 -binary | openssl base64 -A
# → sha384 value the browser computed
# 2. Extract the token you declared in the built HTML
grep -oE 'sha384-[A-Za-z0-9+/=]+' dist/index.html | head -1
# → the token in your integrity attribute
Interpret the two outputs:
- They differ — the served bytes are not the bytes you hashed. The cause is upstream drift (minifier, line endings, gzip-before-hash, or a CDN transform). Regenerate the token from the served artifact.
- They are identical — the browser could read the body but still blocked it. The problem is not the hash; it is a missing
crossoriginattribute or a CORS response header, covered below.
Once you know which branch you are in, the fix is deterministic:
<!-- Correct: token regenerated from the served bytes, crossorigin present -->
<script
src="https://cdn.example.com/lib.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>
Variant Causes and Fixes
Permalink to "Variant Causes and Fixes"Five causes account for nearly every mismatch that reaches production, and each leaves a distinct fingerprint in the evidence you already collected. Match the middle column to what you observed, then apply the fix on the right — the last row is the odd one out, because in that case nothing was ever hashed.
Minifier or bundler drift
Permalink to "Minifier or bundler drift"The most frequent cause: you hashed the source file, or a different minifier version produced different bytes than the one in the deploy pipeline. Always hash the final artifact, and confirm the tool versions match between the hash step and the build step.
# Diff local build against the served file to localize the drift
curl -sL --compressed https://cdn.example.com/lib.min.js -o served.js
diff <(cat dist/lib.min.js) served.js && echo "identical" || echo "bytes differ"
CRLF vs LF line endings
Permalink to "CRLF vs LF line endings"A checkout on Windows, or a CI runner with autocrlf enabled, can rewrite line endings before hashing. The served LF file then mismatches the CRLF hash.
# Detect CRLF in the artifact before hashing
file dist/lib.min.js # look for "with CRLF line terminators"
# Enforce LF in .gitattributes: *.js text eol=lf
gzip-before-hash
Permalink to "gzip-before-hash"If your pipeline compresses the asset and then hashes the .gz, the token describes bytes the browser never sees — the browser hashes the decoded body. Hash the uncompressed artifact and let the CDN apply transfer encoding.
# Wrong: hashing the compressed file
openssl dgst -sha384 -binary lib.min.js.gz | openssl base64 -A # do NOT ship this
# Right: hash the uncompressed artifact
openssl dgst -sha384 -binary lib.min.js | openssl base64 -A
CDN edge transform
Permalink to "CDN edge transform"Some CDNs minify, inject analytics, or rewrite HTML at the edge. Any payload mutation invalidates the hash. Disable transformation for SRI-protected paths; the provider-specific toggles that cause this are enumerated in SRI with Cloudflare and Fastly Edge Transforms, and the wider question of which origins you trust with hashed assets is analyzed in Mapping CDN Origins to SRI Policies.
Missing crossorigin (matching hash, still blocked)
Permalink to "Missing crossorigin (matching hash, still blocked)"If step 2 above showed identical hashes, the body was readable during your manual test but not during the real cross-origin load — curl has no same-origin policy, so it never reproduces the opaque response the browser got. Add crossorigin="anonymous" and confirm the server returns an Access-Control-Allow-Origin header; the full interaction between the CORS mode of the request and the integrity check is set out in How CORS and crossorigin Affect SRI.
# Confirm the CDN sends a CORS header
curl -sI https://cdn.example.com/lib.min.js | grep -i access-control-allow-origin
# → access-control-allow-origin: *
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"-
A correct-looking hash that still blocks is almost always a
crossoriginbug, not a hashing bug. Withoutcrossorigin="anonymous"on a cross-origin tag the browser fetches with no-CORS, gets an opaque response, and cannot read the body — so it blocks before comparing anything. Adding the attribute (and a CORS response header) fixes it. Treat this as the first thing to rule out when the token appears identical to the served bytes. -
The browser hashes the decoded body, so always re-hash with
--compressed. Piping raw gzip bytes into openssl produces a value that will never match. Let curl decodeContent-Encodingexactly as the browser does. -
Intermittent mismatches across users point at CDN PoP drift, not your build. If only some geographies fail, one edge cache is serving a stale bundle built before your current hash. Use content-addressed filenames so old and new bundles never collide at the same URL.
-
A
sha1-or unsupported-algorithm token reports the same “no valid digest” string as a real mismatch. Confirm the token prefix issha256,sha384, orsha512before assuming byte drift. A malformed token is indistinguishable from drift in the console, and so is a digest that was hex-encoded, URL-safe-encoded, or stripped of its=padding — the encoding requirements are spelled out in Base64 Encoding Rules for SRI Hashes. -
Service workers can mask or cause mismatches. A worker returning a cached older response will fail against a newer hash. Bypass the worker (DevTools → Application → Service Workers → Bypass for network) while debugging.
Verification Steps
Permalink to "Verification Steps"1. Confirm the token now matches the served bytes
Permalink to "1. Confirm the token now matches the served bytes"DECL=$(grep -oE 'sha384-[A-Za-z0-9+/=]+' dist/index.html | head -1)
LIVE="sha384-$(curl -sL --compressed https://cdn.example.com/lib.min.js | openssl dgst -sha384 -binary | openssl base64 -A)"
[ "$DECL" = "$LIVE" ] && echo "MATCH" || echo "STILL DRIFTING"
Expected output after the fix:
MATCH
2. Confirm a clean load in the browser
Permalink to "2. Confirm a clean load in the browser"Reload with DevTools open and the service worker bypassed. A resolved mismatch produces no console error, and the Network tab shows 200 OK (not (blocked:csp) or net::ERR_SRI_FAILED) for the resource.
3. Guard against regressions in CI
Permalink to "3. Guard against regressions in CI"# GitHub Actions — re-hash served assets post-deploy and fail on drift
- name: Verify SRI tokens against live CDN
run: |
node scripts/verify-sri.js --manifest dist/sri-manifest.json --live
The script should exit non-zero on any mismatch so a future edge transform or minifier bump is caught before it reaches users.
Frequently Asked Questions
Permalink to "Frequently Asked Questions"Why does my SRI hash mismatch only in production and not locally?
The bytes your CDN serves differ from your local build. Common causes are CDN-side minification or HTML rewriting, a gzip-before-hash pipeline, or an edge transform that injects or normalizes content. Re-hash the live URL with curl piped to openssl and compare it to your local artifact to see the drift.
The browser says the resource was blocked but the hash looks correct — why?
This is usually a missing crossorigin=“anonymous” attribute on a cross-origin tag. The browser makes a no-CORS request, receives an opaque response whose body it cannot read, and blocks the resource. It is not a hash comparison failure — the hash was never computed.
How do I find exactly which bytes the browser hashed?
The browser hashes the decoded response body — after Content-Encoding is removed. Reproduce it with curl --compressed to let curl decode gzip or Brotli, then pipe to openssl dgst. If that value matches the integrity token, the mismatch is upstream of delivery; if it differs, the CDN mutated the payload.
Can line endings cause an SRI mismatch?
Yes. A CRLF-normalized file produces a different digest than the same file with LF. If a checkout, editor, or CI runner rewrites line endings before the hash is computed, the deployed token will not match the served bytes. Enforce line endings with .gitattributes and hash the final artifact.
Related
Permalink to "Related"- Browser Enforcement & Security Boundaries — parent page detailing the fetch-time enforcement lifecycle that produces these errors
- How to Calculate SHA-256 vs SHA-384 for SRI — regenerating a correct token from the served artifact once you have found the drift
- Handling SRI Failures with onerror Handlers — recovering gracefully in production when a mismatch does block a resource
- Failing CI on SRI Hash Drift — turning the post-deploy re-hash from step 3 into a pipeline gate that blocks the release instead of the browser