Alerting on SRI Failures from CSP Reports

Permalink to "Alerting on SRI Failures from CSP Reports"

Part of Security Reporting & Violation Telemetry, this page shows how to turn a silent integrity rejection in one user’s browser into a signal that reaches an on-call engineer, without flooding the endpoint the first time a deploy ships a stale hash.

Quick Reference

Permalink to "Quick Reference"
Signal Where it comes from Fires on a pure digest mismatch? Support
Console error Browser devtools only Yes All browsers, not readable by your JS
error event on the element window.addEventListener('error', fn, true) Yes All browsers; capture phase required
securitypolicyviolation event document event listener No — only on a CSP block Chrome, Firefox, Safari
report-uri POST application/csp-report body No — only on a CSP block Widely supported, deprecated directive
Reporting-Endpoints + report-to application/reports+json body No — only on a CSP block Chromium; batched, delayed delivery
require-sri-for Historical CSP directive Removed from browsers; do not rely on it

The load-bearing row is the second one. Everything else is corroboration.

The mental model

Permalink to "The mental model"

When the browser finishes fetching a resource that carries an integrity attribute, it computes the digest of the response body and compares it against the strongest algorithm present in the attribute. On a mismatch the resource is discarded: the script never executes, the stylesheet never applies, and the element fires an error event. That is the whole of the integrity mechanism, and it is entirely separate from Content Security Policy. A CSP violation report is generated when a directive rejects a request — a URL missing from script-src, a missing nonce, an inline handler. If your policy already allows the CDN origin and only the digest fails, no report is produced anywhere. The name of this page is the question most teams arrive with, and the honest answer is that CSP reports are a second source, not the primary one.

The historical directive that would have closed that gap, require-sri-for, shipped briefly in Chromium and was withdrawn; it is not part of the current CSP specification and no major browser enforces it today. Its practical status is covered in Combining require-sri-for with CSP. Plan your telemetry as though the directive does not exist, because for alerting purposes it does not.

That leaves the error event as the reliable first-party signal. It has one property that trips people up: resource load errors are dispatched at the element and do not bubble, so a handler registered the usual way on window never runs. Registering it with the capture phase — the true third argument to addEventListener — puts your listener on the path the event takes down the tree to its target, which is the only way to observe every failing <script> and <link> from one place.

What a digest mismatch actually produces A SHA-384 digest mismatch produces a console error that JavaScript cannot read, an error event on the element that can be reported, and a CSP violation report only when a policy directive also blocked the request; the last two reach the collector endpoint. SHA-384 digest mismatch console error message devtools only, unreadable by your JS error event on the element capture phase, always fires CSP violation report only if a directive blocked it collector /_report/sri

Canonical example: capture, deduplicate, report

Permalink to "Canonical example: capture, deduplicate, report"

The monitored tag looks like any other hashed asset. Nothing about the markup changes; the reporting is bolted on separately so that a single listener covers every integrity-guarded element on the page, including ones injected later.

<html lang="en" data-release="2026-08-05.3" data-pop="{{ edge_pop }}">
<head>
  <script src="https://cdn.example.com/app.7f3c.js"
          integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
          crossorigin="anonymous"></script>
</head>

The data-release attribute is what makes the alert actionable — without a release identifier on every report you cannot tell a rollback candidate from a background rate. The data-pop attribute is filled in by whatever renders the HTML at the edge; it records which point of presence served the document, which is the closest thing the page has to knowing which edge served the asset.

The listener belongs inline in the <head>, before any other script, so it is installed before the first hashed asset can fail. If your policy uses nonces, give this block the same per-request nonce as every other inline script.

(function () {
  var root = document.documentElement;
  var RELEASE = root.dataset.release || 'unknown';
  var POP = root.dataset.pop || null;
  var ENDPOINT = '/_report/sri';
  var seen = new Set();

  window.addEventListener('error', function (event) {
    var el = event.target;
    // Script exceptions target window; resource errors target the element.
    if (!el || el === window || !el.tagName) return;
    if (el.tagName !== 'SCRIPT' && el.tagName !== 'LINK') return;
    if (!el.integrity) return;

    var url = el.src || el.href;
    var key = RELEASE + '|' + url + '|' + el.integrity;
    if (seen.has(key)) return;          // per-page dedupe
    seen.add(key);
    try {                                // per-session dedupe across navigations
      if (sessionStorage.getItem('sri:' + key)) return;
      sessionStorage.setItem('sri:' + key, '1');
    } catch (e) { /* storage blocked; in-memory dedupe still applies */ }

    var timing = performance.getEntriesByName(url, 'resource')[0];
    var body = JSON.stringify({
      type: 'sri-failure',
      resource: url,
      element: el.tagName.toLowerCase(),
      expected: el.integrity,
      crossorigin: el.crossOrigin,
      release: RELEASE,
      pop: POP,
      page: location.pathname,
      fetched: Boolean(timing),
      transferSize: timing ? timing.transferSize : null,
      ua: navigator.userAgent,
      ts: Date.now()
    });

    var blob = new Blob([body], { type: 'application/json' });
    if (!navigator.sendBeacon || !navigator.sendBeacon(ENDPOINT, blob)) {
      fetch(ENDPOINT, { method: 'POST', body: body, keepalive: true,
                        headers: { 'Content-Type': 'application/json' } });
    }
  }, true); // capture phase: resource errors do not bubble
})();

Three fields in that payload do real work. expected is the digest the page asked for, which lets the collector re-fetch the URL and decide whether the served bytes or the recorded hash is the wrong one. fetched distinguishes a response that arrived and was rejected from one that never arrived at all — a resource timing entry exists in the first case and not the second. release is the grouping key every alert rule below is built on.

The receiving endpoint normalises three body shapes into one event, stamps the edge it was received on, and applies a coarse per-asset cap so a bad deploy costs you a bounded number of writes rather than one per user.

// Cloudflare Worker: POST /_report/sri
const CAP_PER_MINUTE = 50;

export default {
  async fetch(request, env) {
    if (request.method !== 'POST') return new Response(null, { status: 405 });

    const raw = await request.text();
    if (raw.length > 16384) return new Response(null, { status: 413 });

    const type = (request.headers.get('content-type') || '').split(';')[0].trim();
    let events;
    try {
      const parsed = JSON.parse(raw);
      if (type === 'application/csp-report') {
        events = [fromCsp(parsed['csp-report'])];
      } else if (type === 'application/reports+json') {
        events = parsed
          .filter((r) => r.type === 'csp-violation')
          .map((r) => fromReportingApi(r.body));
      } else {
        events = [parsed];
      }
    } catch (e) {
      return new Response(null, { status: 400 });
    }

    for (const e of events) {
      const bucket = `rl:${e.release}:${e.resource}:${Math.floor(Date.now() / 60000)}`;
      const n = Number(await env.SRI_KV.get(bucket)) || 0;
      if (n >= CAP_PER_MINUTE) return new Response(null, { status: 429 });
      await env.SRI_KV.put(bucket, String(n + 1), { expirationTtl: 120 });

      await env.SRI_EVENTS.writeDataPoint({
        blobs: [e.resource, e.release, request.cf.colo,
                request.headers.get('cf-ipcountry') || '??', e.source],
        doubles: [1],
        indexes: [e.resource]
      });
    }
    return new Response(null, { status: 204 });
  }
};

function fromCsp(r) {
  return { source: 'csp-report', resource: r['blocked-uri'],
           directive: r['effective-directive'] || r['violated-directive'],
           page: r['document-uri'], release: 'unknown' };
}

function fromReportingApi(b) {
  return { source: 'reporting-api', resource: b.blockedURL,
           directive: b.effectiveDirective, page: b.documentURL, release: 'unknown' };
}

request.cf.colo is the airport code of the Cloudflare edge that terminated the beacon, and cf-ipcountry is the client country. Together they give you the geographic dimension the alert rules need. The KV counter is eventually consistent, so treat CAP_PER_MINUTE as a soft ceiling; if you need an exact bound, move the counter into a Durable Object keyed by asset.

From error event to page The browser posts a deduplicated beacon to the collector, which applies a rate limit and increments a counter labelled by asset, release and POP; the alert evaluator checks thresholds every five minutes and pages on-call with a runbook link. page (browser) collector endpoint alert evaluator POST /_report/sri (beacon) dedupe + rate limit count by asset, release, POP evaluate every 5m page on-call with runbook link

Variants

Permalink to "Variants"

Add the securitypolicyviolation event as an in-page source

Permalink to "Add the securitypolicyviolation event as an in-page source"

If the asset URL is also rejected by a directive, the document fires securitypolicyviolation synchronously — no endpoint round trip, no batching delay. Reuse the same beacon so both signals land in one table:

document.addEventListener('securitypolicyviolation', function (e) {
  navigator.sendBeacon('/_report/sri', new Blob([JSON.stringify({
    type: 'csp-violation',
    resource: e.blockedURI,
    directive: e.effectiveDirective,
    disposition: e.disposition,   // "enforce" or "report"
    release: document.documentElement.dataset.release,
    page: location.pathname
  })], { type: 'application/json' }));
});

e.disposition tells you whether the policy actually blocked the resource or merely observed it, which matters while a policy is still in report-only mode.

Collect the header-driven report stream too

Permalink to "Collect the header-driven report stream too"

Declare both the legacy and modern delivery mechanisms; coverage differs by browser and neither is universal.

add_header Reporting-Endpoints 'csp-endpoint="https://example.com/_report/sri"' always;
add_header Content-Security-Policy "script-src 'self' https://cdn.example.com; report-to csp-endpoint; report-uri /_report/sri" always;

The report-uri directive is deprecated but still the only path several browsers take, so keep both until that changes. Endpoint plumbing, batching behaviour and body shapes are covered in Collecting CSP Violation Reports with the Reporting API.

Sample repeats rather than dropping first occurrences

Permalink to "Sample repeats rather than dropping first occurrences"

On a very high traffic page even a deduplicated beacon can be too much. Never sample the first report for a given asset and release — that is the one that starts the incident. Sample only the confirmations, and multiply them back up at query time:

var firstForKey = !seen.has(key);
if (!firstForKey && Math.random() > 0.05) return;   // keep 5% of repeats

Record the sample rate on the event so the alert rule can scale counts correctly instead of silently under-reporting.

Alert thresholds that separate a bad deploy from tampering

Permalink to "Alert thresholds that separate a bad deploy from tampering"

Two very different incidents produce the same event type, and the difference is entirely in the shape of the distribution. A bad deploy is loud and uniform: one asset, one release, failing for essentially every session that loads it, in every region, starting within a minute or two of the rollout. Tampering in transit is quiet and lopsided: the same asset that was healthy an hour ago starts failing for a fraction of users, concentrated in one edge POP, one ASN or one country, with no deploy anywhere near the start time. A threshold tuned only on absolute volume will page loudly for the first and miss the second entirely.

Reading the shape of the failure A matrix comparing four signals — share of sessions, geographic spread, release correlation and assets affected — between a bad deploy and a targeted or regional tamper. signal bad deploy targeted or regional tamper share of sessions nearly all that load it a small subset geographic spread every POP and region one POP, ASN or country release correlation starts at the rollout no deploy; was healthy assets affected whatever the build emitted one asset, often third-party

Two rules cover both shapes. The first is a volume alert on an asset and release pair; the second requires that most of a smaller number of failures land on a single POP. Both assume your collector exports a counter named sri_failure_total with asset, release and pop labels.

# alerts/sri.yml — thresholds are starting points, tune to your traffic
groups:
  - name: sri-integrity
    interval: 30s
    rules:
      - alert: SRIFailureSpike
        expr: sum by (asset, release) (increase(sri_failure_total[5m])) > 50
        for: 2m
        labels:
          severity: page
        annotations:
          summary: "SRI failures on {{ $labels.asset }} ({{ $labels.release }})"
          runbook: "https://runbooks.example.com/sri-failure#spike"

      - alert: SRIFailureLocalised
        expr: |
          (
            sum by (asset, pop) (increase(sri_failure_total[15m])) > 5
          )
          and on (asset, pop)
          (
              sum by (asset, pop) (increase(sri_failure_total[15m]))
            / on (asset) group_left()
              sum by (asset) (increase(sri_failure_total[15m]))
            > 0.8
          )
          and on (asset)
          (
            sum by (asset) (increase(sri_failure_total[15m])) < 50
          )
        for: 10m
        labels:
          severity: page
        annotations:
          summary: "SRI failures concentrated at {{ $labels.pop }} for {{ $labels.asset }}"
          runbook: "https://runbooks.example.com/sri-failure#localised"

SRIFailureLocalised deliberately fires at a low absolute count and a long for window. It is the rule that catches a compromised edge or an injecting middlebox, and both are rare enough that a ten-minute confirmation window costs little.

Runbook

Permalink to "Runbook"
  1. Read the alert labels. SRIFailureSpike on the release you just shipped is a deploy problem; anything else is an integrity problem until proven otherwise.
  2. Fetch the asset yourself and hash it: curl -sS <url> | openssl dgst -sha384 -binary | openssl base64 -A. Compare against the expected value on the reports.
  3. If your copy matches expected, the bytes differ somewhere between origin and those users. Do not purge the cache and do not roll back — that destroys the only evidence. Capture a copy from the affected region first.
  4. If your copy does not match expected, the HTML and the asset disagree. Reconcile them against the build output using Verifying Deployed Assets Against a Hash Manifest, then roll forward or back.
  5. Record the resolution against the asset URL. Repeat offenders — usually unversioned third-party tags — belong on a self-hosting plan, not on a pager.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • A missing crossorigin attribute produces the same alert as tampering. A cross-origin resource fetched without crossorigin="anonymous" yields an opaque response, and an opaque response can never satisfy an integrity check, so the browser blocks it every time for every user. This is the single most common cause of a full-volume SRI alert on a freshly hashed third-party tag. Check the attribute before you suspect anything else — the crossorigin field in the payload above exists precisely so the triage takes ten seconds.

  • CSP reports arrive late and incomplete. Reporting API delivery is batched and can lag by a minute or more, and browsers strip blocked-uri down to the origin when a cross-origin redirect was involved. Never build a paging threshold on report latency you do not control; page off the first-party beacon and use the report stream for corroboration and forensics.

  • Edge transforms are a legitimate source of mismatches. Any CDN feature that rewrites a response body — minification, script rewriting, image or HTML optimisation — invalidates the digest computed at build time. If failures track a specific POP the day after someone enabled an optimisation feature, that is configuration, not attack; the interaction is covered in SRI with Cloudflare and Fastly Edge Transforms.

  • The error handler will see events that have nothing to do with integrity. Uncaught exceptions, failed images and blocked trackers all reach a capture-phase error listener on window. The el.integrity guard is what keeps the endpoint from becoming a general error firehose, and it also means an asset that lost its integrity attribute in a template change silently stops being monitored.

  • Deduplication can hide a slow burn. Session-scoped deduplication is right for volume control but wrong for measuring prevalence: ten thousand affected users produce ten thousand events, while one user reloading two hundred times produces one. Keep the unique-session count as the alerting metric and the raw count only as a secondary line on the dashboard.

Verification Steps

Permalink to "Verification Steps"

1. Force a real mismatch locally

Permalink to "1. Force a real mismatch locally"

Serve a file, hash it, then corrupt one character of the hash in the markup:

printf 'console.log("ok")' > app.js
openssl dgst -sha384 -binary app.js | openssl base64 -A

Load the page with the corrupted value and check the console. Chromium prints:

Failed to find a valid digest in the 'integrity' attribute for resource
'http://localhost:8080/app.js' with computed SHA-384 integrity '...'.
The resource has been blocked.

2. Confirm the endpoint accepts a beacon

Permalink to "2. Confirm the endpoint accepts a beacon"
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://example.com/_report/sri \
  -H 'Content-Type: application/json' \
  --data '{"type":"sri-failure","resource":"https://cdn.example.com/app.7f3c.js","expected":"sha384-AAAA","release":"2026-08-05.3"}'

Expected output:

204

3. Confirm the rate limit engages

Permalink to "3. Confirm the rate limit engages"
for i in $(seq 1 60); do
  curl -s -o /dev/null -w '%{http_code} ' -X POST https://example.com/_report/sri \
    -H 'Content-Type: application/json' \
    --data '{"type":"sri-failure","resource":"https://cdn.example.com/app.7f3c.js","expected":"sha384-AAAA","release":"2026-08-05.3"}'
done; echo

Expected output — the first fifty accepted, the tail rejected:

204 204 204 ... 204 429 429 429 429 429 429 429 429 429 429

4. Validate the alert rules before shipping them

Permalink to "4. Validate the alert rules before shipping them"
promtool check rules alerts/sri.yml

Expected output:

Checking alerts/sri.yml
  SUCCESS: 2 rules found

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Does an SRI failure on its own produce a CSP violation report?

No. Integrity checking and Content Security Policy are separate mechanisms. If the resource URL is allowed by your policy and only the digest fails, the browser blocks the resource, logs a console error and fires an error event on the element, but sends no violation report. You only get a CSP report when a directive such as script-src also rejected the request, so treat the report stream as a supplement to your own beacon.

Why does the error listener have to run in the capture phase?

Resource load errors fire on the element itself and do not bubble up to window, so a listener added with the default bubbling phase never sees them. Passing true as the third argument to addEventListener registers the handler for the capture phase, which visits window on the way down to the target. The same handler still receives ordinary script exceptions, so filter on event.target before reporting.

How do I tell a digest mismatch apart from a plain 404 in the report?

The error event does not say why the load failed, so add corroborating fields. A PerformanceResourceTiming entry for the URL means the fetch itself completed, which points at integrity or CORS rather than a missing file. The definitive check happens server side: have the collector re-fetch the URL, compute its SHA-384 digest and compare it with the expected value the client reported.

Should an SRI failure page someone immediately?

A spike on a single asset across every user and edge location is almost always a bad deploy or a mistimed cache purge, and paging is justified because the fix is a rollback. A handful of failures confined to one region or edge POP deserves a page too, but a different runbook: it may be tampering in transit, and rolling back destroys the evidence. Isolated one-off failures should only raise a ticket.

Can the browser tell me which CDN edge served the blocked file?

Not directly. A page cannot read response headers from a subresource the browser refused to execute, and cross-origin responses expose no headers unless Access-Control-Expose-Headers lists them. Two practical substitutes exist: stamp the edge location that served the HTML document into a data attribute at render time, or record the POP your collector received the beacon on, which is usually the same edge.

Permalink to "Related"

Related Articles

Collecting CSP Violation Reports with the Reporting API
Security Reporting & Violation Telemetry Runtime Policy Enforcement & T…