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.


SRI Mismatch Diagnostic Flow A decision tree that starts from a blocked resource, re-hashes the served bytes with curl and openssl, and branches on whether the re-hashed value matches the integrity attribute to point at either a CORS problem or byte drift. Resource blocked (SRI error) Re-hash served bytes: curl | openssl dgst Matches the integrity token? yes Body was readable → check crossorigin/CORS no Byte drift → minifier, CRLF, gzip, CDN xform

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 crossorigin attribute 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 crossorigin bug, not a hashing bug. Without crossorigin="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 decode Content-Encoding exactly 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 is sha256, sha384, or sha512 before 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.


Permalink to "Related"

Related Articles

Configuring Content Security Policy with SRI
Browser Enforcement & Security Boundaries Core SRI Fundamentals & Browse…