Deployment
Published
Subresource Integrity checks that the bytes a browser fetched are the bytes you expected. Obfuscation changes the bytes. Put those two facts together and the whole topic reduces to a single question about ordering — but it is a question that fails loudly and totally when you get it wrong, because a failed integrity check does not degrade anything. It refuses to execute the file at all.
The failure, in one sentence
If your pipeline computes the integrity attribute from dist/app.js and then deploys dist-protected/app.js, every visitor gets a blocked script and a blank page.
The console message names the problem clearly enough — the computed hash does not match any of the values in the integrity attribute — but the reason it is so easy to ship is that nothing earlier in the pipeline complains. The build succeeds, the hash is a valid hash, the file is a valid file, and the two were simply generated from different bytes. It also passes any test that loads the unprotected build, which is most of them.
The fix: hash last, always
Protection is the last transform that touches your output, so integrity generation has to come after it. Concretely:
npm run build # 1. bundle + minify
npx jso-protector --config jso.config.json \ # 2. protect
--input dist --output dist-protected
node scripts/emit-sri.js dist-protected # 3. hash + rewrite HTML
Computing the hash itself is a one-liner — SHA-384 of the file, base64 encoded, prefixed with the algorithm:
openssl dgst -sha384 -binary dist-protected/app.js \
| openssl base64 -A
# => use as integrity="sha384-<output>"
If you use a framework plugin that injects integrity attributes during the build, it runs at step 1 and is therefore in the wrong place. Either disable it and generate attributes in step 3, or move protection into the build so it happens before the plugin. What you cannot do is leave a step-1 generator and a step-2 protector both switched on.
Polymorphic output makes every hash new
Here is the part specific to protected builds. Obfuscation output is polymorphic by default: identifier names, string-table order and the generated decoder all come from fresh entropy on every run, so rebuilding an unchanged commit produces a different file and therefore a different hash.
Most of the time this is fine — you regenerate the attributes in the same pipeline run that produced the file, so they always agree. It stops being fine when something in your process assumes a stable hash:
- An integrity value committed into a template or a CMS field, updated by hand at release time.
- A CSP that pins inline scripts with
'sha256-…' source expressions, maintained separately from the build.
- A partner or customer who has allow-listed your widget’s hash on their own page.
- A change-review process that asks why the artifact hash moved when the diff is empty.
For those cases, protect with a fixed seed. The same input, options and seed produce byte-identical output, so the hash is stable across rebuilds of the same release — see reproducible builds. A seed is not a security control, and it does cost you the per-build variation, so the usual compromise is one seed per version tag: stable within 4.2.0, completely different in 4.3.0.
What SRI actually protects you from
It is worth being precise, because SRI is often described as if it verified trustworthiness rather than identity.
SRI answers exactly one question: are these the bytes the page author expected? That is genuinely valuable when the script is hosted somewhere you do not control — a CDN, a vendor, a tag manager — because it converts a silent substitution into a hard failure. It is the difference between a compromised CDN serving a skimmer to your checkout and a compromised CDN serving nothing at all.
What it does not do:
- It does not vouch for the code. A hash pins a specific version of a script that may always have been malicious.
- It does not cover what the script loads next. A pinned loader that fetches a second file with
fetch or an injected tag puts that second file entirely outside the check.
- It does not apply to dynamically imported chunks by default. A hash on an entry script tag says nothing about the lazy chunks that entry script pulls in — bundler-specific support exists, but you have to enable it, and it interacts with code splitting.
- It does nothing about the user’s own browser. An extension modifying the page after load is downstream of every integrity check.
Why this shows up in compliance work
If you take card payments in a browser, PCI DSS v4 asks you to authorise every script on a payment page and to detect unauthorised change to it — requirements 6.4.3 and 11.6.1. An integrity attribute is one of the accepted ways to demonstrate the first half, and a reasonable input to the second, which is why SRI turns up in assessment conversations far more often than in ordinary front-end work. The mapping between those requirements and concrete controls is in PCI DSS and JavaScript, with the page-level detail in payment page protection.
Note that obfuscation and SRI answer different halves of that conversation and neither substitutes for the other. Obfuscation raises the cost of understanding a script you shipped deliberately. SRI detects a script that changed without you. An assessor asking about 11.6.1 wants the second one, and a protected bundle with no integrity story does not answer them — nor does an integrity attribute on a script whose logic is readable to anyone who opens it.
A checklist worth running once
- Integrity attributes are generated after protection, in the same pipeline run that produces the deployed file.
- No build plugin is also injecting integrity attributes earlier in the pipeline.
crossorigin is set alongside integrity on cross-origin scripts — without it the check cannot run and the script is blocked.
- Your staging smoke test loads the protected artifact, not the pre-protection build. This single change catches the whole class of error.
- If any hash is stored outside the build, you protect with a seed and record it with the release.
- Anything else that pins bytes — a service worker precache manifest, an asset manifest, a CSP hash — is regenerated from the protected output too. More on this in deployment hygiene.
The short version
SRI and obfuscation compose cleanly; the only rule is that the hash must be computed from the artifact you actually deploy. Generate integrity attributes as the last step, after protection. If anything downstream needs a hash that survives a rebuild, use a seed and treat it as release metadata. And keep the two controls in their own lanes: integrity tells you a file changed, obfuscation makes the file expensive to understand, and a payment page needs both.
Frequently asked questions
Does obfuscation break Subresource Integrity?
No, they compose cleanly, and the only rule is about ordering. An integrity attribute pins the bytes of a specific file, and protection changes those bytes, so the hash has to be computed from the artifact you actually deploy. If your pipeline hashes the pre-protection build and ships the protected one, every visitor gets a blocked script, because a failed integrity check does not degrade the page, it refuses to execute the file at all.
Where in my pipeline should integrity hashes be generated?
Last, after protection has run. Protection is the final transform that touches your output, so integrity generation belongs in the step that follows it and reads the deployed directory. The common failure is a framework plugin that injects integrity attributes during the bundle step, because that runs before protection and is therefore in the wrong place. Either disable the plugin and generate attributes afterwards, or move protection earlier so it happens before the plugin, but do not leave both switched on.
Why does my integrity hash change when I rebuild the same commit?
Because protection output is polymorphic by default. Identifier names, string-table order and the generated decoder all come from fresh entropy on every run, so rebuilding an unchanged commit produces a different file and therefore a different hash. Most of the time this is harmless, since you regenerate the attributes in the same run that produced the file. It becomes a problem when something assumes a stable hash, such as an integrity value kept in a template, a CSP that pins hashes separately, or a partner who has allow-listed your widget.
Should I use a fixed seed to keep the hash stable?
Use one where something outside the build depends on the hash surviving a rebuild. The same input, options and seed produce byte-identical output, so the hash stays stable across rebuilds of the same release. A seed is not a security control and it costs you the per-build variation, so the usual compromise is one seed per version tag: stable within a release, completely different in the next one. Record the seed with the release so the build can be reproduced later.
Does SRI protect me from a malicious script?
It answers a narrower question than people expect: are these the bytes the page author expected? That is genuinely valuable for a script hosted somewhere you do not control, because it turns a silent substitution into a hard failure. It does not vouch for the code, since a hash happily pins a script that was always malicious. It does not cover what that script loads next, it does not apply to dynamically imported chunks unless you enable that support, and it does nothing about an extension modifying the page after load.
Do I need both SRI and obfuscation for a payment page?
They answer different halves of the question and neither substitutes for the other. Protection raises the cost of understanding a script you shipped deliberately. Integrity detects a script that changed without you. An assessor asking about detecting unauthorised change to payment page scripts wants the second one, so a protected bundle with no integrity story does not answer them, and an integrity attribute on a script whose logic is readable to anyone who opens it does not answer the first.
Related reading