Subresource Integrity is one of the few web-platform security features with zero downsides in theory and a real footgun in practice. The theory is simple: you pin a cryptographic hash of the file you expect, and if a CDN is ever compromised and starts serving something different, the browser refuses to run it. In practice, the browser also refuses to run the file when nothing malicious happened at all — someone just released a patch version, or the minifier output shifted, or a new encoding header changed the bytes. From the browser’s point of view, the two cases are indistinguishable.
And here is the uncomfortable part: your uptime monitor cannot tell the difference either. From a status-code perspective, everything looks fine.
What SRI is and what the browser does with it
Subresource Integrity is a W3C spec, finalized in 2016, that lets you pin a cryptographic hash of a resource directly in the HTML. It looks like this:
<script src="https://code.jquery.com/jquery-3.6.0.min.js"
integrity="sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK"
crossorigin="anonymous"></script>
When the browser encounters this element, it fetches the resource normally, computes its SHA-384 hash, compares the result to the integrity attribute, and one of two things happens:
Match. The script runs. Everything works.
Mismatch. The browser discards the response and refuses to execute it. The fetch itself succeeded — status 200, content downloaded, TLS fine. But the body is thrown away and a console error is logged:
Failed to find a valid digest in the ‘integrity’ attribute for resource ‘…’. The resource has been blocked.
Two details worth knowing. First, the crossorigin="anonymous" attribute is required for SRI to work on cross-origin resources — without it, the browser cannot read the response body to hash it at all, and the check degrades to “always fail.” Second, the <script> element’s onerror handler does not fire reliably for SRI failures in every browser, and even when it does, by that point your application code has already been denied a dependency it was going to need.
Why an uptime monitor cannot see this
A traditional uptime monitor sends one request to your origin URL and checks the status code. That request never touches code.jquery.com, so it never sees the script that was meant to load. I wrote about that category limit in detail in the post on CDN outages.
But even a dependency monitor — one that follows the <script> and <link> tags and independently fetches the referenced URLs — gets the same 200 OK from the CDN that the browser did. The CDN responded with a valid, complete, fully-formed file. What the monitor cannot see is that the file’s bytes do not hash to the value the HTML promised. Without the integrity attribute from the original HTML as ground truth, and without running the SHA computation on the response body, the monitor has no way to catch this class of failure.
The consequence is a narrow but nasty mode: your page references a script, the CDN serves the script, the monitor confirms the CDN served the script, and the browser silently refuses to run it. Whatever that script was supposed to do — analytics, a form validator, a payment widget, the UI itself — just does not happen. No error surfaces anywhere the monitoring or ops team will see.
When the hash actually mismatches
The SRI spec implicitly assumes you pin against an immutable URL. In practice, plenty of integrity attributes point at things that are not as immutable as they look.
Version-range URLs. https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js looks like a specific version, but jsDelivr (and unpkg, and esm.sh) will happily serve the most recent patch in the 3.6.x range. When 3.6.1 is published, the URL keeps resolving, but the byte content changes. Your pinned SHA was calculated against 3.6.0 and the file served today is different. The browser rejects it.
“Latest” URLs. https://cdn.jsdelivr.net/npm/lodash/lodash.min.js with no version at all. An integrity attribute on @latest is a contradiction — the hash can only be correct until the next release. Linters and generator tools should flag this, but many do not.
Your own CDN. You built the SRI hash from the file your CI produced, then uploaded to S3 behind CloudFront. On a subsequent deploy, a Content-Encoding header, a text charset, or a minifier output changed by a single byte. The URL stayed https://cdn.yoursite.com/app.v2.js, the hash no longer matches, and production breaks until someone happens to open DevTools.
Cache poisoning and real tampering. The case SRI was designed for. Rare, but documented: a shared CDN gets a poisoned cache entry, a compromised publisher account pushes a malicious release. SRI catches this as intended — and also catches every mundane mismatch above with the same rejection.
The first two cases are by far the most common. The third happens on the first production deploy after anyone touches the asset pipeline. The fourth is what the attribute is supposed to exist for.
How to actually monitor SRI
The check you want is the exact check the browser runs: fetch the resource, compute the hash, compare it to the integrity attribute on the referring HTML. It is about fifty lines of Go:
package sri
import (
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"errors"
"fmt"
"hash"
"io"
"net/http"
"strings"
)
// ScriptRef is an element extracted from the page HTML that carries both
// the URL to fetch and the integrity attribute the browser will check.
type ScriptRef struct {
URL string
Integrity string // e.g. "sha384-vtX... sha512-bKy..."
}
// CheckIntegrity fetches the URL and verifies at least one of the hashes
// listed in the integrity attribute matches the response body. The SRI
// spec allows multiple space-separated hashes; any one matching counts
// as valid — the browser uses the strongest supported algorithm.
func CheckIntegrity(ref ScriptRef) error {
resp, err := http.Get(ref.URL)
if err != nil {
return fmt.Errorf("fetch %s: %w", ref.URL, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("%s returned %d", ref.URL, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read %s: %w", ref.URL, err)
}
for _, spec := range strings.Fields(ref.Integrity) {
algo, expected, ok := strings.Cut(spec, "-")
if !ok {
continue
}
var h hash.Hash
switch algo {
case "sha256":
h = sha256.New()
case "sha384":
h = sha512.New384()
case "sha512":
h = sha512.New()
default:
continue
}
h.Write(body)
got := base64.StdEncoding.EncodeToString(h.Sum(nil))
if got == expected {
return nil
}
}
return errors.New("integrity mismatch — browser will refuse to execute this file")
}
Extracting <script> and <link> elements with their integrity attributes from the page HTML is the same pass I walked through in the previous post, with one extra field to capture:
case "script":
if src := attr(n, "src"); src != "" {
refs = append(refs, ScriptRef{
URL: resolve(base, src),
Integrity: attr(n, "integrity"),
})
}
Run this check on every monitoring cycle alongside the normal availability fetch. If the Integrity field is empty, skip the hash comparison and just check the status code. If it is present and the hash does not match, alert with the URL, the expected hash, and the computed hash — that is enough to fix the problem by hand.
What about require-sri-for?
The CSP directive require-sri-for, which was supposed to make SRI mandatory for all scripts or styles, has been dropped from the CSP Level 3 spec and is not shipping in any current browser. Do not rely on it. If you want the guarantee that every third-party resource on a page has an integrity attribute, enforce it at build time with a linter or a template check, not at runtime with a header.
How to avoid hash drift in the first place
Two rules, in order of importance.
Pin to exact immutable versions. Do not use @latest. Do not use version ranges like @3.6. Use @3.6.4, and when you bump to @3.6.5 regenerate the hash in the same commit as the URL change. Tools like srihash.org compute the correct hash for a given URL on demand, and any good CDN will serve an identical byte stream for a fully-qualified version path.
Prefer CDNs that guarantee immutable content by version. jsDelivr and cdnjs both promise that a specific version path will always return the same bytes — once a release is published, the @3.6.4 path is frozen. This is exactly the property SRI relies on. CDNs that lazily rewrite version ranges, or that serve through an intermediate build step, are hostile to SRI by design.
If you cannot satisfy either rule — for example because you are pinning against your own CDN where deploys sometimes produce slightly different output even for nominally identical builds — the monitoring check above is your safety net, and in practice the only way to know production is in the state you think it is.
What Upsonar does
Upsonar parses the HTML from your origin on every monitoring cycle, extracts every <script> and <link> that carries an integrity attribute, fetches the referenced URL from all 9 monitoring regions, computes the appropriate SHA hash of the response body, and compares it against the pinned value. When any region reports a mismatch, the check fails with the URL, the expected hash, and the hash that was actually computed — the same three pieces of information a browser would have used to block the resource.
The normal availability check runs alongside this, so every cycle answers both questions — “is the CDN serving 200” and “is what it is serving actually going to run in a browser.”
Free plan includes dependency and integrity checks. No credit card.