Graceful Fallback Strategies for SRI

Permalink to "Graceful Fallback Strategies for SRI"

This workflow is part of Core SRI Fundamentals & Browser Security Boundaries. Without a deliberate recovery architecture, a single CDN outage or hash mismatch blocks every asset load on your page — no JavaScript executes, stylesheets disappear, and users see a broken experience. The goal of fallback design is to preserve cryptographic integrity guarantees while eliminating that single point of failure.

When the browser’s built-in SRI enforcement rejects an asset, it raises a blocking error with no automatic retry. Application-level recovery must intercept that error, fetch the same asset from a pre-authorized, hash-verified secondary origin, and inject it before dependent code runs. Every link in that chain — the secondary CDN, the CSP policy, the CI pipeline — must be pre-provisioned and continuously validated or the fallback provides a false sense of resilience.


Prerequisites

Permalink to "Prerequisites"

Conceptual foundation: why SRI failures are synchronous and irreversible

Permalink to "Conceptual foundation: why SRI failures are synchronous and irreversible"

The W3C SRI specification mandates that the browser perform hash verification before executing or applying a fetched resource. When verification fails, the resource is discarded and the element fires an error event — there is no partial execution, no retry, and no way to recover the original fetch. This design is intentional: it prevents downgrade attacks in which a tampered script might suppress its own detection.

The architecture diagram below shows the three layers where recovery can be introduced after a primary-origin failure:

Three-layer SRI fallback architecture Diagram showing how a hash mismatch or CDN outage triggers recovery at the browser onerror layer, the Nginx edge routing layer, and the CI parity gate layer. LAYER 1 — BROWSER (onerror) LAYER 2 — EDGE (Nginx / CDN routing) LAYER 3 — CI (manifest parity gate) Primary fetch + integrity attr Hash mismatch → error event onerror handler injects fallback el Verified load secondary CDN Primary CDN 502 / timeout Nginx upstream proxy_next_upstream Fallback CDN identical bytes Browser receives same integrity hash Build output primary hash Parity script compare hashes Gate: pass / fail blocks deploy on drift Deploy all origins in sync

The three layers are independent defences: Layer 2 (edge routing) handles CDN outages before the browser ever sees a failure; Layer 1 (onerror) handles hash mismatches that reach the browser; Layer 3 (CI gate) prevents deployment when primary and fallback hashes disagree in the first place.


Step 1 — Map failure modes before writing any code

Permalink to "Step 1 — Map failure modes before writing any code"

Fallbacks that are designed without a clear failure taxonomy end up handling only the happy-path cases. SRI assets fail in three distinct ways:

  1. Hash mismatch — the fetched bytes do not match the integrity attribute. The browser fires an error event on the element. This is an SRI-specific failure.
  2. CDN outage or network error — the primary origin returns 5xx, times out, or is unreachable. The browser fires an error event that looks identical to a hash mismatch from the DOM API’s perspective, but the cause is different.
  3. CORS rejection — the server does not include Access-Control-Allow-Origin in its response. The browser cannot verify the hash and treats the fetch as if no integrity attribute was present, which under strict CSP require-sri-for results in a block.

Verification signal: open DevTools → Console. A hash mismatch produces:

Failed to find a valid digest in the 'integrity' attribute for resource 'https://cdn.example.com/app.js'

A CORS failure produces:

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource

Distinguish these in your monitoring by logging event.target.src and the response headers separately.


Step 2 — Prepare secondary origins with hash parity

Permalink to "Step 2 — Prepare secondary origins with hash parity"

Both primary and secondary CDNs must serve byte-for-byte identical files. Even a single trailing newline difference produces a different SHA-384 digest. The safe approach is to upload the same build artifact to both origins from the same CI run.

Compute hashes once per build and store them in a manifest:

# generate-sri-manifest.sh
#!/usr/bin/env bash
set -euo pipefail

DIST="./dist"
MANIFEST="./sri-manifest.json"

echo "{" > "$MANIFEST"
first=true
for f in "$DIST"/**/*.{js,css}; do
  [ -f "$f" ] || continue
  hash=$(openssl dgst -sha384 -binary "$f" | openssl base64 -A)
  rel="${f#$DIST/}"
  $first || echo "," >> "$MANIFEST"
  first=false
  printf '  "%s": "sha384-%s"' "$rel" "$hash" >> "$MANIFEST"
done
echo "" >> "$MANIFEST"
echo "}" >> "$MANIFEST"

Upload the same dist/ directory to both CDN origins. The manifest is the single source of truth consumed by both the HTML template and the CI parity gate.


Step 3 — Wire onerror fallback handlers

Permalink to "Step 3 — Wire onerror fallback handlers"

The canonical pattern for Handling SRI Failures with onerror Handlers uses the HTML onerror attribute to inject a fallback element. Note the mandatory crossorigin="anonymous" attribute on both the primary and fallback elements — without it the browser cannot perform the CORS fetch required for hash verification.

Inline HTML fallback (synchronous, render-blocking scripts only):

<script
  src="https://cdn-primary.example.com/app.min.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxKPuB2vTgqDNxmtBxFFiMBMFMjJlF"
  crossorigin="anonymous"
  onerror="this.onerror=null;
           var s=document.createElement('script');
           s.src='https://cdn-fallback.example.com/app.min.js';
           s.integrity='sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxKPuB2vTgqDNxmtBxFFiMBMFMjJlF';
           s.crossOrigin='anonymous';
           document.head.appendChild(s);">
</script>

The this.onerror=null guard prevents infinite loops if the fallback URL itself fails. The fallback integrity value must be identical — the same hash — because the bytes are the same file.

JavaScript-driven fallback (for dynamically loaded modules):

// sri-loader.js
function loadWithFallback({ primary, fallback, integrity, type = 'script' }) {
  return new Promise((resolve, reject) => {
    const el = document.createElement(type === 'script' ? 'script' : 'link');

    if (type === 'script') {
      el.src = primary;
      el.integrity = integrity;
      el.crossOrigin = 'anonymous';
      el.onload = resolve;
      el.onerror = () => {
        // Primary failed — try fallback
        const fb = document.createElement('script');
        fb.src = fallback;
        fb.integrity = integrity;      // same hash — same bytes
        fb.crossOrigin = 'anonymous';
        fb.onload = resolve;
        fb.onerror = reject;
        document.head.appendChild(fb);
      };
    } else {
      el.rel = 'stylesheet';
      el.href = primary;
      el.integrity = integrity;
      el.crossOrigin = 'anonymous';
      el.onload = resolve;
      el.onerror = () => {
        const fb = document.createElement('link');
        fb.rel = 'stylesheet';
        fb.href = fallback;
        fb.integrity = integrity;
        fb.crossOrigin = 'anonymous';
        fb.onload = resolve;
        fb.onerror = reject;
        document.head.appendChild(fb);
      };
    }

    document.head.appendChild(el);
  });
}

// Usage
loadWithFallback({
  primary:   'https://cdn-primary.example.com/app.min.js',
  fallback:  'https://cdn-fallback.example.com/app.min.js',
  integrity: 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxKPuB2vTgqDNxmtBxFFiMBMFMjJlF',
}).catch(() => console.error('All origins failed — application cannot continue.'));

Expected output: DevTools → Network tab shows two requests for the same file when the primary fails — one blocked (red), one successful (green). The X-SRI-Fallback-Active: true header (set by Nginx in Step 4) is visible in the successful response.


Step 4 — Align Content Security Policy before enabling multi-origin routing

Permalink to "Step 4 — Align Content Security Policy before enabling multi-origin routing"

Configuring Content-Security-Policy with SRI must list the fallback origin explicitly or the browser blocks the fallback fetch before hash verification can even begin. Update your CSP header:

Content-Security-Policy:
  default-src 'none';
  script-src
    https://cdn-primary.example.com
    https://cdn-fallback.example.com
    'strict-dynamic';
  style-src
    https://cdn-primary.example.com
    https://cdn-fallback.example.com;
  require-sri-for script style;

The require-sri-for script style directive forces the browser to reject any script or stylesheet that lacks an integrity attribute, regardless of origin. This means your fallback elements must carry the integrity attribute or they will be blocked by this directive even when they pass the CSP origin check.

Deploy in Content-Security-Policy-Report-Only mode first to catch any unlisted origins before switching to enforcement.


Step 5 — Add edge-layer fallback routing for CDN outages

Permalink to "Step 5 — Add edge-layer fallback routing for CDN outages"

Layer-2 recovery at the Nginx or CDN edge handles origin outages transparently before the browser sees a failure. Configure proxy_next_upstream to retry on upstream errors:

upstream primary_cdn {
  server cdn-primary.example.com:443;
  keepalive 32;
}

upstream fallback_cdn {
  server cdn-fallback.example.com:443;
  keepalive 32;
}

server {
  listen 443 ssl;

  location /assets/ {
    proxy_pass          https://primary_cdn;
    proxy_ssl_server_name on;
    proxy_connect_timeout  3s;
    proxy_read_timeout     5s;
    proxy_next_upstream    error timeout http_502 http_503 http_504;
    proxy_next_upstream_timeout 6s;
    proxy_next_upstream_tries   2;
    error_page 404 502 503 504 = @fallback_origin;
  }

  location @fallback_origin {
    proxy_pass          https://fallback_cdn;
    proxy_ssl_server_name on;
    add_header          X-SRI-Fallback-Active "true" always;
  }
}

proxy_next_upstream_tries 2 prevents Nginx from looping through more than two upstream attempts, capping the worst-case latency added by failover. The X-SRI-Fallback-Active header surfaces in monitoring dashboards as a direct signal of edge-layer activation.


Step 6 — Gate fallback readiness in CI

Permalink to "Step 6 — Gate fallback readiness in CI"

The entire multi-origin architecture collapses if the fallback CDN serves a file with a different hash than what is encoded in your HTML — that scenario forces browsers to reject the fallback too, with no further recovery path. A CI parity gate catches this before deployment.

# .github/workflows/sri-gate.yml
name: SRI Manifest Parity Gate
on: [push, pull_request]

jobs:
  verify-parity:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Generate SRI manifest
        run: bash scripts/generate-sri-manifest.sh

      - name: Verify primary CDN hashes match manifest
        run: node scripts/verify-sri-parity.js --origin primary
        env:
          PRIMARY_CDN_BASE: $

      - name: Verify fallback CDN hashes match manifest
        run: node scripts/verify-sri-parity.js --origin fallback
        env:
          FALLBACK_CDN_BASE: $
          STRICT_HASH_MATCH: "true"

verify-sri-parity.js fetches each asset from the target CDN, computes its SHA-384, and diffs against sri-manifest.json. Any mismatch exits with code 1, halting the deployment pipeline.

// scripts/verify-sri-parity.js
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';

const args = process.argv.slice(2);
const originFlag = args[args.indexOf('--origin') + 1];
const base = originFlag === 'primary'
  ? process.env.PRIMARY_CDN_BASE
  : process.env.FALLBACK_CDN_BASE;

const manifest = JSON.parse(readFileSync('./sri-manifest.json', 'utf8'));
let failed = false;

for (const [path, expected] of Object.entries(manifest)) {
  const url = `${base}/${path}`;
  const res = await fetch(url);
  const buf = Buffer.from(await res.arrayBuffer());
  const actual = 'sha384-' + createHash('sha384').update(buf).digest('base64');
  if (actual !== expected) {
    console.error(`MISMATCH ${path}: expected ${expected}, got ${actual}`);
    failed = true;
  } else {
    console.log(`OK ${path}`);
  }
}

if (failed) process.exit(1);

Configuration reference

Permalink to "Configuration reference"
Option Valid values Security implication
integrity on fallback element Must equal primary hash (same bytes) Any deviation causes browser rejection — even one extra byte
crossorigin on both elements "anonymous" or "use-credentials" Omitting blocks hash verification regardless of integrity value
CSP script-src / style-src List all CDN origins explicitly Missing fallback origin blocks recovery before hash check runs
require-sri-for script, style, or script style Forces integrity on fallback elements; absence means unprotected fallback
proxy_next_upstream_tries 2 (recommended maximum) Higher values compound latency; 2 covers primary + one fallback
proxy_connect_timeout 2–5 s for CDN upstreams Lower values fail faster to fallback; too low causes false positives on slow networks
Fallback activation threshold Alert at > 0.5 % of requests Sustained elevation above threshold indicates supply-chain event or misconfiguration

Integration with adjacent tooling

Permalink to "Integration with adjacent tooling"

The output of this workflow — a verified sri-manifest.json with hashes for all primary and secondary CDN assets — feeds directly into the next stage of your supply chain: Automated SBOM Generation tools can consume this manifest to associate each frontend asset hash with its upstream provenance record. CycloneDX components for frontend assets can include the SHA-384 hash as an externalReference with type distribution, establishing a chain from build artifact to deployed CDN asset.

Static-asset-first workflows can also integrate with Static Asset Hash Generation to produce the manifest automatically during the Webpack or Vite build step, eliminating the separate generate-sri-manifest.sh script shown in Step 2.


Troubleshooting

Permalink to "Troubleshooting"

Refused to execute script from '...' because its MIME type ('text/html') is not executable The CDN is serving an error page (HTML) instead of the JavaScript file — typically a 404 or a misconfigured origin path. The hash of the error page will not match, so SRI correctly rejects it. Fix: verify the asset path in the CDN configuration and that the origin server’s directory structure matches the manifest.

Failed to find a valid digest in the 'integrity' attribute for resource '...' on the fallback URL The fallback CDN is serving a file that does not match the committed hash. Caused by: (a) the fallback CDN was not updated in the same deploy as the primary, (b) the CDN is serving a cached stale version — run curl -I to check Cache-Control and ETag headers, or © a build environment difference caused minifier output to diverge. The CI parity gate in Step 6 is designed to catch this before it reaches users.

Refused to load the script '...' because it violates the following Content Security Policy directive The fallback origin is not listed in script-src. Add it to your CSP header and redeploy. Use Content-Security-Policy-Report-Only to validate the updated policy before switching to enforcement mode.

DOMException: Failed to read the 'responseText' property of 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'text' A service worker is intercepting the fetch and returning an opaque response. Opaque responses cannot be SRI-verified. Ensure your service worker’s fetch handler passes integrity-checked requests through to the network without modification, or excludes CDN asset URLs from the cache-first strategy.

onerror fires on the fallback element as well (infinite loop) The this.onerror=null guard was omitted or the fallback element was created inside the onerror handler without resetting its own onerror. Add fb.onerror = reject (to a Promise-based rejection) rather than re-entering the handler. The JavaScript-driven loader in Step 3 shows the correct pattern.

Edge Nginx fallback not activating despite primary CDN returning 502 proxy_next_upstream must explicitly list http_502. Verify the directive includes the exact HTTP status code the upstream returns — error alone only matches connection-level failures, not HTTP error responses.


Security boundary

Permalink to "Security boundary"

Fallback strategies protect against availability failures — CDN outages, network partitions, and transient errors. They do not protect against:

  • Compromised build pipeline — if an attacker modifies the build output before hash generation, the committed hash reflects the malicious file and SRI verification passes.
  • Compromised manifest storage — if the sri-manifest.json or the HTML template can be modified post-build, an attacker can replace both the hash and the file simultaneously.
  • Both CDN origins compromised simultaneously — if a threat actor controls both primary and fallback origins and replaces files before CI runs the parity gate, the gate will pass with matching (malicious) hashes.
  • HTTPS stripping attacks — SRI requires a valid TLS connection. On an HTTP page, integrity attributes are ignored by the browser.

The parity gate in Step 6 closes the window between primary and fallback divergence, but it runs at deploy time — not at runtime. Continuous monitoring of fallback activation rates (alerting above 0.5 %) is the runtime detective control that catches post-deploy compromise.


Frequently asked questions

Permalink to "Frequently asked questions"
Does adding a fallback weaken SRI security?

Not if the fallback hash is pre-committed and verified in CI. The risk arises only when the fallback element is injected without a matching integrity attribute, which reduces the guarantee to HTTPS-only. Always carry the identical sha384- hash on the fallback element — the hash is the same because the bytes are the same.

Why does my fallback script still get blocked even with a correct hash?

The most common cause is a Content Security Policy that does not include the fallback origin in script-src. The browser evaluates the CSP directive before attempting to check the integrity hash — a CSP violation produces a block that looks identical to an integrity failure in DevTools if you are not watching the right console message. Check for Refused to load the script because it violates CSP first.

Can service workers interfere with SRI fallback?

Yes. A service worker that intercepts the primary fetch and returns a cached (potentially stale) response prevents the browser from seeing the integrity failure at all — masking both legitimate and malicious hash drift. Implement cache-busting tied to the manifest version and check navigator.serviceWorker.controller before registering fallback logic.

What is the right timeout before triggering a fallback?

SRI failures are synchronous rejections, not timeouts. The browser blocks the resource immediately on hash mismatch without waiting for a network round trip. Timeouts only apply to CDN outage scenarios handled at the edge layer — keep Nginx proxy_connect_timeout at 2–5 seconds to fail fast without causing false positives on congested networks.

Do I need crossorigin="anonymous" on the fallback element too?

Yes, without exception. SRI verification requires a CORS-enabled response, and the browser only sends the required CORS request headers when crossorigin="anonymous" (or "use-credentials") is present on the element. Omitting this attribute is the most common reason an otherwise correct fallback silently bypasses integrity checking — the hash is ignored and no error is raised, leaving you unprotected.


Permalink to "Related"

Articles in This Topic

Handling SRI Failures with onerror Handlers
SRI Fallback with Multiple CDN Sources
Back to Core SRI Fundamentals & Browser Security Boundaries