1. The Morning Polyfill.io Turned Hostile
In late June 2024, a lot of ecommerce sites started redirecting mobile visitors to a sports betting site. Not because they'd been breached. Because a JavaScript file they had loaded, unchanged, for years had started serving something different.
Polyfill.io was a genuinely useful service: you dropped in one script tag, and it detected the visitor's browser and served exactly the polyfills that browser needed. Millions of pages used it. In February 2024 the domain changed hands, and by June the code being served had been modified to inject malicious redirects — selectively, so it fired on mobile devices, avoided admin sessions, and lay dormant when it thought it was being watched. Estimates of affected sites ran from a hundred thousand upward. Google began disabling ads for merchants whose pages loaded it. Cloudflare and Fastly stood up clean mirrors. Namecheap eventually pulled the domain.
Here is what makes it the perfect illustration for this article. Every affected site's own infrastructure was fine. Their servers weren't compromised, their databases weren't touched, their deploy pipelines were clean. They had simply written <script src="https://cdn.polyfill.io/v3/polyfill.min.js"> and trusted that the bytes coming back tomorrow would resemble the bytes that came back yesterday.
Subresource Integrity is the browser feature that turns that trust into a check. It is about fifteen lines of work to adopt, it has been supported everywhere for a decade, and the reason it is not universal is that adopting it properly collides with how modern third-party scripts actually want to be delivered. That collision is most of what this article is about.
2. How the Attack Actually Works
To decide how much of this to do, it helps to know what you're defending against. Card skimming on the web — Magecart, formjacking, whatever your vendor calls it — has a consistent shape, and third-party scripts are the delivery mechanism in most of it.
The attacker's goal is card data at the moment of entry. Not from your database, where it isn't stored if you're doing things correctly, and not in transit, where TLS protects it. In the browser, in the DOM, between the customer's keystroke and the payment processor. At that instant the data is plaintext and any script on the page can read it.
Getting a script onto the page is the hard part, and there are three routes. Compromise the merchant directly, which is expensive and noisy. Compromise a widely-used third-party script, which hits thousands of merchants for the same effort. Or acquire the third party outright — buy the domain, buy the company, wait for the acquisition to be forgotten — which is what happened with Polyfill.io and is the cheapest of the three.
The payload itself is usually small and deliberately dull. A listener on the checkout form's submit event, or a periodic sweep of input values, serialising fields that look like card numbers and posting them to a collection endpoint. The good ones are careful: they check the URL contains "checkout" before activating, they skip requests when DevTools is open, they base64 the exfiltration so it doesn't look like card data in a network log, and they sometimes send only a fraction of transactions to stay under detection thresholds.
What makes this hard to catch is that nothing visibly breaks. The order completes. The customer gets a confirmation. Revenue is unaffected. Merchants typically learn about it from their acquiring bank weeks later, once a common point of purchase analysis links a cluster of fraudulent cards back to their store. By then the exposure window is measured in months.
That's the honest case for pinning scripts and inventorying what runs on your payment page: not that a hash is clever, but that this particular class of attack is silent, and change-detection is the only signal you get. Every other control on your site tells you when something is wrong. This one tells you when something is different, which is the only warning available.
3. The Mechanism, Precisely
You add a cryptographic hash of the file you expect, and the browser refuses to execute anything that doesn't match:
<script
src="https://cdn.example.com/checkout-widget-4.2.1.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>
The browser downloads the resource, hashes the bytes it received, compares that against the value you declared, and either runs the file or discards it entirely. A mismatch is treated as a network error: the script does not execute, an error event fires, and the console logs a failure. There is no partial execution and no "run it anyway" prompt.
The attribute value has three parts: the algorithm prefix (sha256, sha384, or sha512), a hyphen, and the base64-encoded digest. Use sha384 unless you have a specific reason not to — it's the common default, comfortably strong, and shorter than sha512. sha256 is fine too; the practical difference is nil. Anything weaker is not in the specification.
SRI applies to <script> and <link rel="stylesheet">, and to preloads of those types. It does not apply to images, video, iframes, or fonts. That surprises people, and it's worth understanding why: the threat model is code execution and style injection, both of which can fully compromise a page. A tampered image is a defacement problem, not an account-takeover problem.
You may supply multiple hashes separated by whitespace, and the browser accepts the resource if any of them matches:
<script src="/app.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC
sha384-Rp/nBnZBJTRQK5oJdvOMWY2rSCQBwGYVjRGMS9RvfhzTZ6PW0nMhFhLnJ6Ni7Qk2"
crossorigin="anonymous"></script>
That's not redundancy for its own sake — it's the mechanism that makes zero-downtime rotation possible, which I'll come back to when we get to rotation. It is one of the most useful and least-known parts of the feature.
4. What SRI Does Not Do
More SRI deployments fail from misunderstanding the guarantee than from getting the syntax wrong. Be clear about the boundaries.
It does not make a malicious script safe. SRI verifies that the file is the one you pinned. If the file you pinned was already hostile, SRI faithfully guarantees you'll keep running the hostile version. It is a change-detection mechanism, not a code review.
It does not protect what the script does at runtime. A pinned, verified script can still call document.createElement('script') and load anything it likes, unpinned. Most third-party tags do exactly this — the initial file is a loader, and the payload arrives later. Pinning the loader tells you nothing about the payload. This is the single biggest gap in real deployments, and Content Security Policy is what closes it.
It does not cover inline scripts. There's nothing to fetch, so there's nothing to verify. CSP hashes and nonces handle inline code.
It does not survive a compromise of your own HTML. If an attacker can edit your pages, they can change the integrity attribute as easily as the src. SRI defends the link between your page and a third party. It does not defend your page.
It is not a substitute for HTTPS. On plain HTTP, an attacker on the network can strip the attribute before the browser ever sees it. SRI assumes the document arrived intact.
What it genuinely gives you is narrow and valuable: certainty that a specific external file has not changed since you approved it. In a supply chain where the most common attack is exactly "a file you already trusted quietly changed," that is worth having.
5. Generating Hashes Without Getting Them Wrong
The hash is over the raw response body bytes. Not the file on disk before minification, not the decompressed content as you see it in DevTools — the bytes the server sends, after any build step and before transport compression is applied.
From the command line, hash whatever you'll actually be serving:
# From a local build artifact
openssl dgst -sha384 -binary dist/checkout-widget.js | openssl base64 -A
# 4EhaLRJ2VVvVhWjK3n8QpQY6dLoM0mvWJ+7C9tRzQqB3XkFhPq0nZ1sYcHwLtDgN
# From the URL you will actually reference — the more reliable option
curl -sS https://cdn.example.com/widget-4.2.1.js \
| openssl dgst -sha384 -binary \
| openssl base64 -A
Prefer the second. Hashing your local build assumes your CDN serves it untouched, and CDNs do not always oblige — some inject headers into JavaScript, rewrite URLs, or apply their own minification tier. Hash what the browser will receive.
A wrapper worth keeping around, because you'll do this often:
#!/usr/bin/env bash
# sri.sh — print a ready-to-paste integrity attribute for a URL
set -euo pipefail
url="${1:?usage: sri.sh <url> [algo]}"
algo="${2:-sha384}"
hash=$(curl -fsSL "$url" | openssl dgst "-${algo}" -binary | openssl base64 -A)
echo "integrity=\"${algo}-${hash}\" crossorigin=\"anonymous\""
In Node, if you'd rather keep it in the toolchain:
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
export async function sriFor(path, algo = 'sha384') {
const bytes = await readFile(path);
const digest = createHash(algo).update(bytes).digest('base64');
return `${algo}-${digest}`;
}
Two failure modes to know about. Trailing whitespace: if your build appends a newline that the CDN strips, or vice versa, the hash changes and everything breaks. Hash the served bytes and this disappears. And transport compression: gzip and Brotli are applied after hashing and removed before verification, so they don't affect the digest — but a CDN that stores a pre-compressed variant built from slightly different source bytes absolutely will. If a hash mismatches only on some edge locations, this is why.
6. The crossorigin Attribute, Which Is Where Everyone Gets Stuck
You will hit this within an hour of starting, so let's be direct about it. For any cross-origin resource, SRI requires a CORS check. Without it the browser cannot read the response body well enough to verify it, and it refuses the resource outright.
Two things must both be true:
- Your tag carries
crossorigin="anonymous". - The server hosting the file sends
Access-Control-Allow-Origincovering your origin.
If the second isn't true, you cannot use SRI on that resource. Full stop. No workaround exists on your side, because the whole point is that the remote server must opt into being read.
# Does this CDN allow SRI?
curl -sI -H "Origin: https://shop.example.com" https://cdn.vendor.net/tag.js \
| grep -i access-control-allow-origin
# access-control-allow-origin: * <- good, SRI is possible
# (nothing) <- SRI is not possible; talk to the vendor
Same-origin resources don't need CORS and don't need the attribute, though adding it is harmless. Most people add it everywhere for consistency and to avoid the class of bug where a file moves to a CDN and the attribute is forgotten.
The error message when this goes wrong is unhelpfully vague in most browsers — some variation of "failed to load because it violates the following Content Security Policy" or a generic integrity failure — so remember the shape of the problem: no CORS header, no SRI. When a vendor tells you their CDN "supports SRI" because they publish hashes, check the header yourself. I've had that conversation more than once, and the header was missing.
7. What This Means for a Payment Page
If you take card payments, this stopped being an engineering nicety and became a compliance requirement. PCI DSS v4.0 introduced two requirements that were future-dated and became mandatory on 31 March 2025.
Requirement 6.4.3 says that all scripts loaded and executed in the consumer's browser on a payment page must be managed: you authorise each one, you justify why it's necessary, and you maintain an inventory. Not a list you wrote once. A maintained inventory.
Requirement 11.6.1 says you must deploy a mechanism that detects unauthorised modification to the HTTP headers and the script content of payment pages, and alerts on it, evaluated at least weekly.
SRI is one of the named techniques for satisfying the script-integrity half of this. It is not the only one, and on its own it is not sufficient — but a QSA who asks how you'd detect a Polyfill-style substitution on your checkout page wants a concrete answer, and "every third-party script on that page is pinned by hash, and a failure raises an alert" is a very good one.
The uncomfortable part of these requirements is the inventory. Most checkout pages I audit load somewhere between six and twenty scripts, and nobody can name an owner for all of them. The exercise of listing them, finding an owner, and asking "what breaks if we remove this?" routinely deletes a third of them. That's a performance win and a compliance win from the same afternoon of work.
Building the inventory
Run this in the console on your live checkout, and again on the order confirmation page:
// Every script the page has loaded, third-party first, with SRI status
const here = location.origin;
const rows = [...document.querySelectorAll('script[src]')].map(s => ({
src: s.src,
thirdParty: !s.src.startsWith(here),
integrity: s.integrity || '—',
crossorigin: s.crossOrigin || '—',
async: s.async,
defer: s.defer
}));
console.table(rows.sort((a, b) => b.thirdParty - a.thirdParty));
// And the ones injected after load, which the markup will not show you
const seen = new Set(rows.map(r => r.src));
new PerformanceObserver(list => {
for (const e of list.getEntries()) {
if (e.initiatorType === 'script' && !seen.has(e.name)) {
seen.add(e.name);
console.warn('dynamically injected:', e.name);
}
}
}).observe({ type: 'resource', buffered: true });
That second block is the one that produces the surprises. The scripts in your HTML are the ones you know about. The scripts those scripts load are the actual attack surface, and they rarely appear in anyone's documentation.
8. The Conversation With Your Vendors
A good part of this work is not engineering. It's asking suppliers for things, and knowing what to ask for.
Four questions, for any vendor whose script runs on a page that handles customer data:
- Do you offer a versioned, immutable URL? If yes, use it and pin it. If no, ask why not — the answer tells you how they think about change management.
- Does your CDN send
Access-Control-Allow-Origin? Without it, SRI is impossible regardless of anything else. This is a five-second check you can do yourself before the call. - How will you notify us of changes, and how far in advance? "We push to all customers simultaneously" is an honest answer and a risk you then get to price.
- Does your script load further scripts at runtime, and from where? Many do. The answer belongs in your PCI inventory, and vendors are often vague because nobody has asked them before.
Where you have contractual leverage — renewal, a new deal, an enterprise tier — these belong in the agreement rather than in an email thread. Advance notice of changes to browser-executed code, and a commitment to versioned URLs, are reasonable asks that mature vendors already meet.
Where you have no leverage, which is most of the time, you get to decide whether the integration is worth the exposure. Sometimes it plainly is: Stripe's rolling URL is a deliberate, documented design choice by a company whose entire business depends on not shipping malicious JavaScript, and pinning it is neither possible nor sensible. Sometimes it plainly isn't: a heatmap tool that nobody has opened in six months does not justify unreviewed code on a card entry form.
The useful mental exercise is to ask what a compromise of each vendor would mean for you, and then to notice that you are trusting them at exactly that level whether or not you've thought about it.
9. The Versioning Problem
Here is the fundamental tension, and it's worth stating plainly rather than pretending SRI is free.
SRI pins a file. Many third-party vendors deliver a URL that intentionally changes — widget.js with no version, updated whenever they ship. That's how they push security patches to every customer at once, and from their perspective it's a feature. Pin it and their next release breaks your site, silently and everywhere, because a hash mismatch means the script simply doesn't run.
There is no clever trick that resolves this. There are four honest options.
Ask for a versioned URL. Most serious vendors have one; it's often undocumented. https://cdn.vendor.net/v4.2.1/widget.js alongside the rolling latest. This is the right answer when you can get it: you pin the version, you get told about updates, you test and then bump. Ask the vendor's support team directly rather than trusting the docs.
Self-host. Download the file, review it, serve it from your own origin, pin it. You now control the update cadence entirely. The cost is that you own security patching — if the vendor fixes a vulnerability, you have to notice and pull it. This is what a lot of teams did with Polyfill.io after the fact, and it's a genuinely defensible posture for anything on a payment page.
Don't pin that one, and compensate. Accept the rolling URL, and cover it with CSP and runtime monitoring instead. This is the pragmatic answer for tag managers, and I'll come to it when we get to tag managers.
Remove it. Ask what breaks. Do it on the checkout page especially. This option is chosen far less often than it should be.
What you should not do is pin a rolling URL and hope. The failure is silent from the visitor's perspective — a widget just isn't there — and you will find out from a drop in conversions rather than from an alert.
10. Tag Managers, and Being Honest About Them
Google Tag Manager is where this argument goes to die, so let's have it properly.
GTM's whole purpose is that marketing can add and change tags without a deploy. Every tag it fires is code you did not review, arriving from a URL you did not pin, injected at runtime. Pinning the GTM loader script itself is possible and nearly pointless — the loader isn't the risk, the containers it pulls are.
The honest position: you cannot make GTM SRI-safe, and on a payment page it is a genuine PCI DSS 6.4.3 problem. Anyone telling you otherwise is selling something.
What actually works, in order of how much I'd push for it:
Remove GTM from the payment page. Not from the site — from the page where card data is entered. Fire conversion tracking from the confirmation page or, better, server-side. This is the recommendation I lead with, and it lands more often than you'd expect once the compliance requirement is on the table, because the alternative is a QSA finding.
Server-side tagging. Move the container to a server endpoint you control. The browser talks to your domain; the fan-out to vendors happens server-side where you can see and log it. This solves a meaningful chunk of the problem and improves page performance as a side effect.
Lock down the container. If GTM stays, then: two-person approval on publish, custom HTML tags disabled entirely, an allowlist of permitted tag types, and a real review of who has publish rights. In most organisations that last one is a longer list than anybody realises.
Constrain it with CSP. A strict script-src allowlist limits where injected tags can load from, even if you can't verify their contents. Combined with connect-src, it also limits where a compromised tag can exfiltrate to, which is often the more important control.
SRI and tag managers are philosophically opposed: one asserts that code must be known in advance, the other exists so it doesn't have to be. Pick which property you need on which page.
11. Rotation Without Downtime
The multiple-hash feature from the section on how the attack works is what makes updates safe. The pattern is a three-step deploy with no window where the site is broken.
Say you're moving from 4.2.1 to 4.3.0. Deploy one: add the new hash alongside the old, still pointing at the old URL. Both hashes are now accepted; nothing has changed behaviourally.
<script src="https://cdn.example.com/widget-4.2.1.js"
integrity="sha384-OLD_HASH_HERE sha384-NEW_HASH_HERE"
crossorigin="anonymous"></script>
Deploy two: switch the src to the new file. The new hash is already trusted, so it loads immediately, and if you need to roll back the old hash is still there.
<script src="https://cdn.example.com/widget-4.3.0.js"
integrity="sha384-OLD_HASH_HERE sha384-NEW_HASH_HERE"
crossorigin="anonymous"></script>
Deploy three, once you're confident: drop the old hash. Leaving both indefinitely isn't dangerous — the old URL is no longer referenced — but it's clutter that will confuse whoever reads this next year.
This matters most with cached HTML. If your pages sit behind a CDN with a long TTL, some visitors are running last week's HTML for a while after you deploy. A single-hash swap breaks the widget for exactly those visitors. The overlap window makes that impossible.
12. Wiring It Into the Build
Hand-computing hashes is fine for a handful of third-party tags. For your own bundles it has to be automatic, or it will drift within two sprints.
Vite
// vite.config.js
import { defineConfig } from 'vite';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import path from 'node:path';
function sri() {
return {
name: 'sri',
enforce: 'post',
apply: 'build',
transformIndexHtml: {
order: 'post',
handler(html, ctx) {
return html.replace(
/<(script|link)([^>]*?)(src|href)="\/([^"]+)"([^>]*)>/g,
(match, tag, pre, attr, file, post) => {
if (match.includes('integrity=')) return match;
const abs = path.join(ctx.bundle ? 'dist' : 'dist', file);
let bytes;
try {
bytes = readFileSync(abs);
} catch {
return match; // not a build artifact we control
}
const digest = createHash('sha384').update(bytes).digest('base64');
return `<${tag}${pre}${attr}="/${file}"${post} integrity="sha384-${digest}" crossorigin="anonymous">`;
}
);
}
}
};
}
export default defineConfig({ plugins: [sri()] });
Community plugins exist for both Vite and webpack and are worth using instead of hand-rolling; I've included the above mainly so the mechanism is legible rather than magic. Whichever you use, verify the output HTML in CI — a plugin that silently stops matching after a config change is a common and quiet failure.
Magento 2
Magento's asset pipeline doesn't emit integrity attributes, and its bundling and minification settings change the served bytes, so hashes must be generated after static content deployment. The workable approach is a post-deploy step that walks pub/static, computes digests, and writes them into a map the layout can read:
<?php
declare(strict_types=1);
namespace Modracx\Security\Model;
use Magento\Framework\Filesystem\Driver\File;
class IntegrityMap
{
private array $map;
public function __construct(
private File $file,
private string $mapPath = BP . '/var/sri/map.json'
) {
}
public function forPath(string $staticPath): ?string
{
$this->map ??= $this->file->isExists($this->mapPath)
? json_decode($this->file->fileGetContents($this->mapPath), true) ?: []
: [];
return $this->map[$staticPath] ?? null;
}
public static function digest(string $absolutePath): string
{
return 'sha384-' . base64_encode(hash_file('sha384', $absolutePath, true));
}
}
Run the map generation as the last step of your deploy, after setup:static-content:deploy, and fail the deploy if a referenced file is missing from the map. A partially-applied SRI deployment is worse than none, because it creates confidence without coverage.
Shopify
Theme assets served from Shopify's CDN don't come with CORS headers you can rely on, so SRI on your own theme files is generally not available. Your leverage on Shopify is elsewhere: audit installed apps, use the checkout extensibility model rather than injecting scripts into checkout (script tags on checkout are gone for Plus merchants anyway), and lean on Shopify's own platform controls. This is one of the cases where the platform has taken the problem off your desk by taking the capability away.
13. When Verification Fails
A hash mismatch is silent to the visitor. The script doesn't run; nothing tells them why. So you need to hear about it.
The error event fires on the element:
<script
src="https://cdn.example.com/widget-4.3.0.js"
integrity="sha384-NEW_HASH_HERE"
crossorigin="anonymous"
onerror="window.__sriFailure && window.__sriFailure(this.src)"></script>
// Defined early, before any pinned script
window.__sriFailure = function (src) {
// Keep this dependency-free — the thing that failed may be your SDK
navigator.sendBeacon('/_telemetry/sri', JSON.stringify({
src,
page: location.pathname,
ts: Date.now(),
ua: navigator.userAgent
}));
};
// Catch the ones you did not wire up individually
window.addEventListener('error', (e) => {
const el = e.target;
if (el instanceof HTMLScriptElement && el.integrity) {
window.__sriFailure(el.src);
}
}, true);
Note the true on the listener — resource errors don't bubble, so you need the capture phase. This is a two-character mistake that makes the whole handler silently useless, and I have shipped it.
On whether to fall back: for a decorative widget, degrade quietly and log. For anything on the critical path — a payment field, an address validator — I'd rather show the customer an honest error than let them fill in a form that cannot submit. And under no circumstances should the fallback be "load it again without integrity." That converts a working security control into a bypass, and I have found exactly that pattern in production code, added by someone who was trying to fix an outage at 2am and was not thinking about threat models.
14. Where CSP Picks Up the Slack
SRI verifies files you named. CSP constrains everything, including what your verified scripts do next. They cover different halves of the problem and you want both — and both sit inside the wider set covered in the security headers guide.
Content-Security-Policy:
default-src 'self';
script-src 'self' https://js.stripe.com https://cdn.example.com;
connect-src 'self' https://api.stripe.com https://metrics.example.com;
style-src 'self' 'unsafe-inline';
frame-src https://js.stripe.com https://hooks.stripe.com;
base-uri 'self';
form-action 'self';
object-src 'none';
report-uri /_csp-report
The two directives that do the most work against a supply-chain compromise are the ones people skip. connect-src limits where any script can send data — a compromised tag that can't reach the attacker's collection endpoint has stolen nothing. And form-action stops an injected form posting card details somewhere else, which is precisely the Magecart pattern.
There was once a require-sri-for directive that would have let you mandate integrity attributes across a page. It was removed and no current browser implements it. Don't reach for it; enforce the requirement in your build and your review process instead.
15. A Payment Page, Start to Finish
A footwear retailer, Magento 2.4.7, Stripe for payments, preparing for their first PCI assessment under v4.0. Their checkout loaded nine external scripts. Here's how it went.
Week one, inventory. The console snippet from the inventory section found nine scripts in markup and four more injected at runtime — thirteen total. Owners could be identified for eight. Of the remaining five: one was an A/B testing tool cancelled eighteen months earlier whose snippet was never removed, two were duplicate analytics from a migration, one was a heatmap tool nobody had approved, and one was a chat widget loaded by the heatmap tool.
Week two, deletion. Removing the five unowned scripts took a two-line theme change and improved checkout LCP by roughly 400ms on throttled mobile. Nobody noticed anything missing. This remains the highest-value hour of the whole engagement.
Week three, pinning what could be pinned. Of the eight remaining, Stripe's js.stripe.com/v3 is explicitly a rolling URL that Stripe requires you to load directly and does not support pinning — that's a deliberate design decision on their side, documented, and the correct response is to accept it and cover it with CSP. Two vendors provided versioned URLs when asked. Three were self-hosted after review. Two were the GTM loader and its container, which went to the tag-manager conversation.
Week four, GTM. They removed GTM from the payment step and fired conversion tracking from the confirmation page instead. Marketing pushed back for about a day, until someone drew the diagram showing that a GTM user with publish rights could inject arbitrary code into the card entry form. The conversation ended quickly after that.
Week five, monitoring. The error handler from the failure section, plus a nightly job that re-fetches every pinned URL, recomputes the digest, and alerts on drift. That job has fired twice since — both times a CDN configuration change on the vendor's side, both times benign, both times worth knowing about.
What the assessment found. The inventory and the change-detection mechanism satisfied 6.4.3 and 11.6.1. The finding they did get was unrelated: an admin session timeout set too long. The script work was the part that went smoothly, largely because they'd done the deletion first and had fewer things to defend.
16. Inheriting a Site That Has None of This
Most of the time you're not starting clean. You've taken over a storefront with years of accumulated tags and no documentation. A workable order of operations:
First day: look, don't touch. Run the inventory snippets against production checkout and the confirmation page. Save the output with a date on it. You now have a baseline, which is worth having even if you change nothing for a month.
First week: find owners. Take the list to marketing, to whoever manages the tag container, and to whoever was here longest. For each script you want a name and a sentence about what it does. Anything with neither goes on the deletion list. Expect a third of them to be there.
Second week: delete. Remove the unowned scripts, one deploy, with a note in the ticket about how to put them back. Watch conversion and error rates for a few days. In my experience nothing happens, and the page gets faster. If something does break, you've just discovered an undocumented dependency, which is also valuable.
Third and fourth weeks: pin what you can. Versioned URLs where vendors offer them, self-hosting where review is feasible, CSP coverage for the rest. Add the error handler before you add the first hash, so a mistake is loud rather than silent.
Second month: automate. Build-time hash generation for your own bundles, the CI check for unpinned external scripts, the nightly drift job. Without this the work you just did starts decaying immediately.
Ongoing: make it someone's job. A quarterly re-inventory that takes twenty minutes and belongs to a named person. Every control described in this article survives exactly as long as somebody owns it.
17. Keeping It Honest Over Time
SRI decays in a specific way: someone adds a script without a hash, and nothing complains. Coverage erodes silently until it means nothing. Three defences.
Fail the build on unpinned third-party scripts. A grep over your templates for <script src="http without a neighbouring integrity= catches most of it. Allow exceptions by explicit annotation, so the exception is a decision someone wrote down rather than an oversight:
#!/usr/bin/env bash
# Fails if a template loads an external script without integrity or a waiver
set -uo pipefail
bad=0
while IFS= read -r hit; do
case "$hit" in
*sri-waiver*) continue ;;
*integrity=*) continue ;;
esac
echo "unpinned external script: $hit"
bad=1
done < <(grep -rn '<script[^>]*src="https\?://' app/design/ --include='*.phtml')
exit $bad
Re-verify from the outside on a schedule. A nightly fetch-and-compare over every pinned URL. This catches vendor-side changes before a customer does, and it is the mechanism you point at when someone asks how requirement 11.6.1 is met.
Re-run the runtime inventory quarterly. The dynamic-injection observer from the inventory section, run against production checkout, compared against the last known list. New entries mean either an approved change or an unapproved one, and you want to know which.
18. Support, and the Failure Modes You Will Meet
Browser support is complete and has been for years — every browser in your analytics honours integrity. There is no progressive-enhancement story to worry about and no polyfill to add, which is a pleasing irony given the opening of this article. A browser too old to understand the attribute simply ignores it and loads the script, so you are never worse off than before.
The failure modes are all operational rather than compatibility-related, and they cluster into four:
The hash was computed from the wrong bytes. Most common by a distance. Someone hashed the source file rather than the built artifact, or the local build rather than what the CDN serves. Fix by always hashing the URL.
CORS is missing. Second most common. The symptom looks like an integrity failure but the cause is the absent header. Check with curl before debugging anything else.
The vendor changed the file. Working as designed — this is the control doing its job. The problem is that without monitoring you find out from a support ticket rather than an alert.
An intermediary rewrote the response. Corporate proxies that inject scripts, some ISP-level injection on plain HTTP, occasionally an over-eager CDN optimisation feature. This is rare and maddening, and it usually presents as failures from one network or one country only.
When something fails, check in that order. It will be the first two roughly nine times in ten.
19. Questions That Come Up
"Does SRI slow the page down?" Not measurably. The hash is computed over bytes already in memory; it's microseconds. The real performance consideration is indirect — crossorigin="anonymous" can mean a separate cache entry from a non-CORS request for the same URL, which occasionally causes a double-fetch during a migration. Once everything uses the same attribute, it settles.
"Can I use SRI on images or fonts?" No. Scripts and stylesheets only. For fonts, the equivalent protection is serving them from your own origin.
"What about ES modules and import maps?" integrity works on <script type="module"> for the entry point, but statically-imported dependencies fetched by the module graph have historically not been covered. Integrity support in import maps is the mechanism that addresses this and browser support is still uneven — check current support before relying on it, and don't assume a pinned entry module means a pinned dependency tree.
"A vendor says their CDN supports SRI but it fails." Check Access-Control-Allow-Origin yourself with the curl in the crossorigin section. Second most likely cause: they serve different bytes to different regions, so your hash matches in one place and not another. Fetch from a couple of geographies before concluding it's your fault.
"Should I pin my own first-party bundles?" The security value is low — same origin, same trust boundary. The operational value is real: it catches a corrupted deploy or a CDN serving a stale artifact. Cheap if your build does it automatically, not worth hand-maintaining.
"Our CSP already allowlists that domain. Isn't that enough?" The allowlist says the file may come from that host. SRI says the file must be the one you approved. Polyfill.io would have passed every CSP allowlist on every affected site, because the domain was allowlisted and the domain was exactly where the malicious code came from.
20. The Part That Actually Matters
Adding integrity attributes is an afternoon. It's genuinely worth doing, and if you stop reading here and go pin the third-party scripts on your checkout, you'll have improved things.
But the lesson of Polyfill.io wasn't that those sites lacked a hash. It was that a hundred thousand teams had a third-party dependency on their critical path that nobody owned, nobody reviewed, and nobody had thought about since the day it was added. The domain changed hands in February. The attack ran in June. In between, there were four months during which anyone looking at their own script inventory would have had a chance to notice.
So do the inventory first. Find out what's actually on your checkout page and who owns each item. Delete what nobody can justify — you'll delete more than you expect, and the page will get faster. Then pin what can be pinned, cover the rest with CSP, and set something up that tells you when any of it changes.
The hash is the easy part. Knowing what you're running is the whole job.