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 the browser fetch pipeline from HTML parse through the CORS fetch and SHA-384 comparison to the blocked resource, the dispatched error event and the 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 /api/sri-violation endpoint is deliberately separate from your CSP report-uri, but the two feeds should land in the same alerting pipeline — see Alerting on SRI Failures from CSP Reports for the correlation rules.
  • 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. Keeping that copy byte-identical to the CDN build is the hard part; Serving Local Fallback Bundles covers the vendoring and cache-header side of it.

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. The propagation path below is what makes this asymmetric: the capture phase walks down from document to the failing element, so a capturing listener sees the event on the way in, while the return leg that a normal listener depends on is never taken for resource errors.

Capture-phase propagation of a resource error event The error event travels down from document through document.head to the script element that failed its integrity check; the capture-phase listener fires on that descent, while no bubble phase carries the event back up to document. document document.head script#vendor-lib error event target capture phase: listener fires on the way down error events do not bubble Omit the third argument true and the document listener never runs.

One consequence worth planning for: a capture-phase listener on document also sees error events from images, iframes and media elements, which is why the guard filters on tagName and on the presence of an integrity property before doing anything. Without that filter, a broken avatar image would trigger a fallback injection for a script that never failed.

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; How CORS and crossorigin Affect SRI sets out the response headers the origin has to send for that request mode to succeed.

  • 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.

The handler receives an Event, not an ErrorEvent: there is no message, no error and no status code on it, only target. Everything you can use to tell the causes apart lives outside the handler, in the developer tools or in a separate reporting stream. The matrix below lists the four causes that reach the same handler and the only signals that separate them.

Failure causes against their observable signals A four-row matrix listing a hash mismatch, a missing crossorigin attribute, an HTTP 404 and a network failure against the Chrome Network panel status, the console message and whether the error event fires, which it does in every case. Failure cause Network panel Console signature error fires? Hash mismatch (blocked:integrity) Failed to find a valid digest in integrity yes Missing crossorigin (blocked:integrity) requires the request to be CORS enabled yes HTTP 404 from CDN 404 Not Found Failed to load resource: 404 yes DNS or network fail (failed) net::ERR_NAME_NOT_RESOLVED yes Only the Network status string tells a hash mismatch apart from a plain 404.

Because the last column never varies, treat the handler as a recovery mechanism and never as a diagnosis. Record evt.target.src and let the server decide what happened: if the same URL is reported by thousands of clients within a minute it is a CDN or deployment problem, whereas a handful of reports from a single network path is a much stronger signal of interception.

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
Serving Local Fallback Bundles
Graceful Fallback Strategies Core SRI Fundamentals & Browse…