Handling SRI Failures with onerror Handlers

Permalink to "Handling SRI Failures with onerror Handlers"

This page is part of Graceful Fallback Strategies, which sits within the Core SRI Fundamentals & Browser Security Boundaries section. It covers exactly one topic: how to intercept a browser-blocked SRI validation failure, inject a verified replacement asset, and do so without breaking your Content Security Policy or creating infinite retry loops.

Quick Reference

Permalink to "Quick Reference"
Attribute / property Required value Notes
integrity on primary <script> sha384-<base64> SHA-384 is the industry default; SHA-512 for high-assurance
crossorigin on primary <script> anonymous Mandatory on cross-origin resources; omitting it blocks SRI
onerror attachment method addEventListener('error', fn) Avoid inline onerror="" — requires unsafe-inline in CSP
Fallback src Self-hosted path (/assets/…) Eliminates need for a second integrity check
State guard window.__sriFallbackTriggered Prevents infinite injection loops
Error event bubbles? No Must capture directly on element or via document capture phase

Browser support: SRI is supported in Chrome 45+, Firefox 43+, Safari 11.1+, and Edge 17+. The onerror element event has universal support across all of these.

Why onerror Handlers Exist in an SRI Context

Permalink to "Why onerror Handlers Exist in an SRI Context"

When an SRI validation fails the browser aborts resource loading before any script bytes execute, then dispatches an error event on the originating element. Without an onerror handler your application silently loses the dependency — no console recovery, no fallback, no user notification.

The onerror handler gives you a deterministic hook to substitute a verified local copy of the asset before application logic tries to call functions that no longer exist. This matters because SRI failures are not always attacks: a CDN cache-buster that rotates assets without updating your integrity attribute causes the same blocked-resource event as a genuine supply chain compromise. Your fallback path handles both causes identically, while your telemetry separates them.

The trust boundary is strict: onerror fires after the cryptographic decision has been made. You are not bypassing integrity enforcement — you are responding to it. The browser’s hash comparison, described in detail at Browser Enforcement & Security Boundaries, is complete and irrevocable by the time onerror runs.

SRI Failure Lifecycle Diagram

Permalink to "SRI Failure Lifecycle Diagram" SRI failure and onerror fallback lifecycle Sequence diagram showing browser fetch pipeline from HTML parse through SRI hash comparison to onerror handler and fallback injection HTML Parser Browser Fetch SRI Engine onerror Handler Parse <script integrity> CORS fetch (anonymous) Compute SHA-384 Compare vs attribute MISMATCH → block error event dispatched → inject fallback script

Canonical Implementation Example

Permalink to "Canonical Implementation Example"

The complete production pattern below uses programmatic event binding, a state guard, violation telemetry, and a local fallback — all without inline event attributes.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <!-- State guard initialised before any script tags -->
  <script>window.__sriFallback = {};</script>
</head>
<body>

<!-- Primary CDN script with SRI -->
<script
  id="vendor-lib"
  src="https://cdn.vendor.com/lib.min.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
  crossorigin="anonymous">
</script>

<script>
(function () {
  var el = document.getElementById('vendor-lib');
  if (!el) return;

  el.addEventListener('error', function handleSriFailure(evt) {
    var scriptId = evt.target.id || evt.target.src;

    // State guard — prevents infinite loop if the fallback also fails
    if (window.__sriFallback[scriptId]) return;
    window.__sriFallback[scriptId] = true;

    // Telemetry — fire-and-forget before starting fallback load
    fetch('/api/sri-violation', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        type: 'sri-onerror',
        src: evt.target.src,
        timestamp: Date.now()
      })
    }).catch(function () {});

    // Inject verified local fallback
    var fallback = document.createElement('script');
    fallback.src = '/assets/vendor/lib.min.js'; // same-origin — no integrity attr needed
    fallback.onerror = function () {
      console.error('[SRI] Both primary and fallback failed for', scriptId);
    };
    document.head.appendChild(fallback);
  });
}());
</script>

</body>
</html>

Key points to notice:

  • crossorigin="anonymous" is present on the primary element. Without it the browser makes a no-CORS request, receives an opaque response whose body cannot be read for hash comparison, and blocks the resource before onerror even fires.
  • The state guard uses the element’s id as a key so the same pattern scales to multiple scripts on the same page without a collision.
  • Telemetry is sent before the fallback loads, not after, to ensure capture even if the fallback itself fails.
  • The local fallback path begins with /assets/… (same origin). Same-origin resources do not need an integrity attribute because the browser’s SRI enforcement only applies to cross-origin fetches.

Variant Examples

Permalink to "Variant Examples"

Variant 1 — Promise-based loader for dynamic injection

Permalink to "Variant 1 — Promise-based loader for dynamic injection"

When scripts are loaded programmatically rather than parsed from HTML, wrap the pattern in a Promise for clean async/await composition:

function loadWithSriFallback(primaryUrl, integrityHash, fallbackUrl) {
  return new Promise(function (resolve, reject) {
    var script = document.createElement('script');
    script.src = primaryUrl;
    script.integrity = integrityHash;
    script.crossOrigin = 'anonymous';

    script.onload = resolve;

    script.onerror = function () {
      // Telemetry
      navigator.sendBeacon('/api/sri-violation', JSON.stringify({
        type: 'sri-onerror',
        src: primaryUrl
      }));

      // Fallback
      var fb = document.createElement('script');
      fb.src = fallbackUrl;
      fb.onload = resolve;
      fb.onerror = function () {
        reject(new Error('SRI fallback failed: ' + fallbackUrl));
      };
      document.head.appendChild(fb);
    };

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

// Usage
loadWithSriFallback(
  'https://cdn.vendor.com/react.production.min.js',
  'sha384-GLdf3rnORLAB3Vx71uM2PF6cda1PFt/6LFrO9nO1xsOLMQXa3gC1ParIF28EQIM',
  '/assets/vendor/react.production.min.js'
).then(function () {
  console.log('React loaded (primary or fallback)');
}).catch(function (err) {
  console.error('Critical load failure:', err);
});

navigator.sendBeacon is preferred here over fetch because it guarantees delivery even if the page is navigated away immediately after the error, and it does not block the fallback injection.

Variant 2 — Document-level capture for multiple scripts

Permalink to "Variant 2 — Document-level capture for multiple scripts"

For pages with many third-party scripts, a single capture-phase listener on document handles all of them without per-element setup:

document.addEventListener('error', function (evt) {
  var el = evt.target;

  // Only handle <script> and <link rel="stylesheet"> SRI failures
  if (el.tagName !== 'SCRIPT' && el.tagName !== 'LINK') return;
  if (!el.integrity) return; // not an SRI-tagged element

  var src = el.src || el.href;
  if (window.__sriFallback && window.__sriFallback[src]) return;

  window.__sriFallback = window.__sriFallback || {};
  window.__sriFallback[src] = true;

  var fallbackSrc = '/assets/fallbacks/' + src.split('/').pop();
  var fallback = document.createElement(el.tagName);

  if (el.tagName === 'SCRIPT') {
    fallback.src = fallbackSrc;
  } else {
    fallback.rel = 'stylesheet';
    fallback.href = fallbackSrc;
  }

  document.head.appendChild(fallback);
}, true); // capture phase — resource errors do not bubble

The true third argument switches to capture phase, which is required because resource-load errors do not bubble up the DOM. Without it, the listener on document never receives the event.

Variant 3 — CSP-nonce-compatible injection

Permalink to "Variant 3 — CSP-nonce-compatible injection"

When your page uses a strict Configuring Content-Security-Policy with SRI policy with strict-dynamic and per-request nonces, injected scripts must carry the nonce:

function injectFallbackWithNonce(fallbackSrc) {
  // Retrieve the nonce from an existing trusted script element
  var nonceSource = document.querySelector('script[nonce]');
  var nonce = nonceSource ? nonceSource.nonce : '';

  var fallback = document.createElement('script');
  fallback.src = fallbackSrc;
  if (nonce) fallback.setAttribute('nonce', nonce);
  document.head.appendChild(fallback);
}

With strict-dynamic, a script injected by a nonce-trusted script inherits trust automatically in modern browsers — but assigning the nonce explicitly preserves compatibility with browsers that do not yet implement the propagation rule.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • Missing crossorigin="anonymous" causes a silent non-SRI block. The browser makes a no-CORS request, cannot read the opaque response body, and refuses the resource. onerror fires but the failure is not an SRI mismatch — it is a CORS restriction. Always include crossorigin="anonymous" on every cross-origin <script> and <link> with an integrity attribute.

  • Inline onerror="" requires unsafe-inline in CSP. Inline event handlers are treated as inline scripts. A strict CSP blocks them before they execute, meaning your fallback never fires. Use addEventListener attached from an external or nonce-trusted script block instead.

  • Resource errors do not bubble. Attaching a non-capturing listener to window or document misses element-level error events. Use the third argument true on document.addEventListener or bind directly on the element.

  • No state guard means infinite loops. If the fallback asset is also cross-origin with an integrity attribute that fails, onerror fires again for the fallback, which then injects another fallback, and so on until the call stack overflows. The guard shown in the canonical example prevents this.

  • Fallback timing for render-blocking scripts. The onerror handler runs asynchronously relative to script parsing. Scripts that are render-blocking (no async or defer) hold up the parser while the primary fetch completes. If the primary fails, your page hangs until the fallback loads. Use async on non-critical scripts to avoid layout shift and FOUC during fallback injection.

  • onerror does not identify SRI failures specifically. HTTP 404s, network timeouts, and CORS rejections all trigger the same error event. To distinguish SRI failures from network failures, also listen for SecurityPolicyViolationEvent on document and correlate by blockedURI.

Verification Steps

Permalink to "Verification Steps"

Chrome DevTools — confirm the failure type

Open the Network panel. A blocked SRI resource shows status (blocked:integrity) in the Status column. The Console logs:

Failed to find a valid digest in the 'integrity' attribute
for resource 'https://cdn.vendor.com/lib.min.js'
with computed SHA-384 integrity 'sha384-ACTUAL_HASH_HERE'.
The resource has been blocked.

If you see this message and then see your fallback URL load successfully in the Network panel, the handler is working correctly.

Firefox DevTools — confirm the failure type

Firefox logs:

None of the "sha384" hashes in the integrity attribute match
the content of the subresource.

The request appears in the Network panel with a red status indicator.

Automated test with Playwright

import { test, expect } from '@playwright/test';

test('SRI onerror fallback loads local asset', async ({ page }) => {
  const sriViolations = [];
  const fallbackLoads = [];

  page.on('console', msg => {
    if (msg.text().includes('sri-onerror')) sriViolations.push(msg.text());
  });

  page.on('request', req => {
    if (req.url().includes('/assets/vendor/')) fallbackLoads.push(req.url());
  });

  // Intercept primary CDN and return body with wrong hash
  await page.route('https://cdn.vendor.com/lib.min.js', route =>
    route.fulfill({ body: '/* tampered */', contentType: 'application/javascript' })
  );

  await page.goto('http://localhost:3000/');

  // Verify telemetry was sent and fallback was requested
  expect(fallbackLoads.length).toBeGreaterThan(0);
});

Verify CSP alignment with a report-only header

Add Content-Security-Policy-Report-Only: script-src 'self' https://cdn.vendor.com; report-uri /csp-report during staging. Any fallback injection from an origin not in script-src generates a report before you promote to enforcement. For the full CSP and dynamic injection interaction, see the Dynamic Script Loading Patterns discussion.


Permalink to "Related"

Related Articles

SRI Fallback with Multiple CDN Sources
Graceful Fallback Strategies Core SRI Fundamentals & Browse…