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.
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"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; this class of failure is analyzed in depth 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. Add crossorigin="anonymous" and confirm the server returns an Access-Control-Allow-Origin header.
# 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. -
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.
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