SRI Fallback with Multiple CDN Sources
Permalink to "SRI Fallback with Multiple CDN Sources"Part of Graceful Fallback Strategies, this page shows how to fail over to a second CDN origin — each with its own integrity attribute — when the primary serves a blocked, tampered, or unreachable resource.
Quick Reference
Permalink to "Quick Reference"| Concern | Rule |
|---|---|
| Failure signal | onerror on the primary <script> fires on SRI block or network error |
| Fallback trigger | Inject a new <script> pointing at a second origin |
Per-source integrity |
Required on every source (primary, secondary, local) |
Per-source crossorigin |
crossorigin="anonymous" required on every cross-origin source |
| Hash reuse across origins | Same token works only if both origins serve byte-identical files |
| Secondary check | Verify the expected global (window.<lib>) after load |
| Last resort | Local self-hosted copy with its own token |
A script tag has one src, so the browser cannot try a second origin on its own — you drive the failover in an onerror handler.
What multi-source fallback protects against
Permalink to "What multi-source fallback protects against"A single CDN reference is a single point of failure. If that origin is unreachable, rate-limited, or — worse — serving bytes that no longer match your integrity token because of tampering or an edge transform, the browser blocks the script and any code depending on it throws. A multi-source fallback keeps the integrity guarantee intact while adding availability: when the primary is blocked, an onerror handler loads the identical library from an independent origin that carries its own hash. Because SRI is keyed to file content rather than to a URL, a byte-identical mirror validates against the same token, so you never trade security for uptime. The browser-side mechanics of how and when onerror fires are covered in Handling SRI Failures with onerror Handlers; this page focuses on wiring a second origin behind the first.
Canonical Implementation
Permalink to "Canonical Implementation"Load the primary from CDN A with its own token. Its onerror injects a byte-identical copy from CDN B, again with its own integrity and crossorigin. Both origins serve the same file, so the same token validates either way.
<script
src="https://cdn-a.example.com/lib.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"
onerror="loadFallback()"></script>
<script>
function loadFallback() {
var s = document.createElement('script');
s.src = 'https://cdn-b.example.com/lib.min.js';
// Same content on both origins → same token validates from either.
s.integrity = 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC';
s.crossOrigin = 'anonymous';
s.onerror = function () {
console.error('Both CDN sources failed for lib.min.js');
};
document.head.appendChild(s);
}
</script>
The crossOrigin property must be set before the element is appended, otherwise the browser initiates the fetch without CORS and the fallback is blocked exactly like an opaque response. Insertion into the document is the point of no return: appendChild starts the fetch synchronously, and the request mode, the credentials mode and the integrity metadata are all snapshotted from the element at that instant. Assigning s.integrity on the following line mutates the DOM attribute but not the in-flight request, which is why the failure looks so confusing — DevTools shows an element that carries a perfectly good token next to a request that was never subject to it. The same CORS reasoning that governs the primary tag governs every injected one, and it is worked through in detail in How CORS and crossorigin Affect SRI.
Variant Examples
Permalink to "Variant Examples"Presence check fallback (window.<lib>)
Permalink to "Presence check fallback (window.<lib>)" onerror catches a blocked or failed load, but not the case where a script loaded yet did not define the global you expected (a truncated or wrong build). Add a presence check that runs after the primary:
<script
src="https://cdn-a.example.com/jquery.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>
<script>
// If the primary did not define window.jQuery, load the fallback origin.
if (!window.jQuery) {
var s = document.createElement('script');
s.src = 'https://cdn-b.example.com/jquery.min.js';
s.integrity = 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC';
s.crossOrigin = 'anonymous';
document.head.appendChild(s);
}
</script>
Local self-hosted last resort
Permalink to "Local self-hosted last resort"When both public origins may be blocked (corporate proxies, ad blockers, regional restrictions), chain a same-origin local copy as the final tier. A same-origin file does not require crossorigin, but still carries its own token:
<script>
function loadLocal() {
var s = document.createElement('script');
s.src = '/vendor/lib.min.js'; // same-origin, self-hosted
s.integrity = 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC';
// No crossorigin needed for same-origin; the browser can read the body.
document.head.appendChild(s);
}
</script>
Distinct tokens per origin
Permalink to "Distinct tokens per origin"If your mirrors are not guaranteed byte-identical (different compression re-encoding at rest, differing build), give each origin its own token and select the token with the URL:
const SOURCES = [
{ src: 'https://cdn-a.example.com/lib.min.js', integrity: 'sha384-AAA…' },
{ src: 'https://cdn-b.example.com/lib.min.js', integrity: 'sha384-BBB…' },
];
function loadFrom(i) {
if (i >= SOURCES.length) return console.error('All sources exhausted');
const s = document.createElement('script');
s.src = SOURCES[i].src;
s.integrity = SOURCES[i].integrity;
s.crossOrigin = 'anonymous';
s.onerror = () => loadFrom(i + 1);
document.head.appendChild(s);
}
loadFrom(0);
Keying the token to the URL costs one extra table entry per origin and buys independence from your mirrors’ byte-level behaviour. The trade-off runs the other way at upgrade time: a shared token is a single constant to rotate, while a per-origin table has to be regenerated for every mirror on every version bump, and a stale entry there fails closed — the loader walks past a perfectly healthy origin because its token is out of date.
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"-
Omitting
crossorigin="anonymous"on the fallback silently defeats it. This is the single most common bug in multi-source fallback: the primary is tagged correctly, but the injected fallback tag setsintegritywithoutcrossorigin. The browser then makes a no-CORS request, receives an opaque response it cannot read, and blocks the fallback exactly as it blocked the primary — so your “resilience” layer never runs. Every source, including dynamically injected ones, needscrossoriginset before insertion. -
A shared token only works if the bytes are truly identical. Two CDNs mirroring the “same” library can differ by a trailing newline, a different minifier, or version drift. If they are not byte-identical, a single token validates on one origin and blocks the other. Verify with
curl … | openssl dgstagainst both, or give each origin its own token. Edge platforms are a common source of this drift because they rewrite responses in flight — minification, HTML/JS post-processing and image or script optimisation all change the bytes the browser hashes, as covered in SRI with Cloudflare and Fastly Edge Transforms. A mirror that is byte-identical at origin can still arrive different at the browser if one of the two edges has an optimisation feature switched on. -
onerrordoes not fire for a script that loads but is semantically wrong. A 200 response that defines nothing useful passes SRI (if the hash matches) yet leaves your app broken. Paironerrorwith awindow.<lib>presence check. -
Setting
integrity/crossOriginafterappendChildhas no effect. The fetch begins at insertion, so configure the element fully first. -
Do not fall back to an untrusted origin without a hash. Failing over to a public registry URL with no
integritytrades an availability problem for a supply-chain hole. Every tier must be pinned. -
A fallback that always fires is an outage you are not seeing. Once the chain works, the page keeps rendering whether tier one succeeds or fails, so a permanently broken primary produces no user-visible symptom and no error budget alarm — only a slower first paint and an extra request per page view. Count failovers deliberately: increment a metric inside each
onerrorbefore injecting the next source, and treat a non-zero rate on the primary as a production incident rather than as the safety net doing its job.
Verification Steps
Permalink to "Verification Steps"1. Force a primary failure and watch the fallback fire
Permalink to "1. Force a primary failure and watch the fallback fire"Temporarily corrupt the primary token (change one character) and reload with DevTools open. You should see the primary blocked with net::ERR_SRI_FAILED, then a second request to the fallback origin returning 200 OK.
lib.min.js (cdn-a) net::ERR_SRI_FAILED
lib.min.js (cdn-b) 200 OK
DevTools proves the chain works on your machine; it says nothing about the field. Because the browser emits a securitypolicyviolation-style report for blocked subresources under a reporting endpoint, the same failover signal can be collected centrally — see Alerting on SRI Failures from CSP Reports for wiring that into an alert instead of a dashboard nobody reads.
2. Confirm both origins serve a hash-matching file
Permalink to "2. Confirm both origins serve a hash-matching file"for host in cdn-a cdn-b; do
printf '%s: sha384-' "$host"
curl -sL --compressed "https://$host.example.com/lib.min.js" \
| openssl dgst -sha384 -binary | openssl base64 -A
echo
done
Both lines must print the same token for a shared-token setup. If they differ, switch to distinct per-origin tokens.
3. Confirm the fallback carries crossorigin
Permalink to "3. Confirm the fallback carries crossorigin"# Inspect the rendered DOM after failover in the console
document.querySelectorAll('script[src*="cdn-b"]').forEach(s =>
console.log(s.src, s.integrity, s.crossOrigin));
Expected output shows a non-empty integrity and crossOrigin === "anonymous" for the injected fallback tag.
Frequently Asked Questions
Permalink to "Frequently Asked Questions"Does the fallback script need its own integrity attribute?
Yes. Every source you load — primary, secondary CDN, or local copy — must carry its own integrity attribute and crossorigin. SRI is keyed to the file content, so if both origins serve byte-identical files the same token validates from either; if they differ at all, each needs its own token.
Why does my fallback script load without crossorigin but still get blocked?
Because SRI cannot validate an opaque response. A fallback tag with integrity but no crossorigin=“anonymous” triggers a no-CORS fetch, the browser cannot read the body, and it blocks the resource — so the fallback silently fails just like the primary. Add crossorigin to every source.
Should I detect failure with onerror or a global presence check?
Use both. onerror fires when the browser blocks or fails to load the script, which covers SRI mismatch and network errors. A follow-up check for the library’s global (for example window.jQuery) catches the rarer case where a script loaded but did not define what you expected.
Can the browser pick a CDN automatically like it picks a hash?
No. Multiple space-separated tokens in one integrity attribute select the strongest algorithm, not an alternate URL. A script tag has exactly one src, so failing over to a second origin requires an onerror handler that injects a new tag pointing at the fallback URL.
Related
Permalink to "Related"- Handling SRI Failures with onerror Handlers — the browser-side mechanics of when
onerrorfires and how to build robust failure handlers - Graceful Fallback Strategies — parent page covering the full range of SRI recovery patterns
- Serving Local Fallback Bundles — how to build, version and hash the same-origin copy that sits at the end of this failover chain
- CDN Trust Mapping & Routing — keeping every failover origin inside an explicit trust boundary so a fallback never routes to an unverified source