Deployment

Obfuscation and Subresource Integrity: getting the hash right

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.

Related reading