The problem
Loading a script from a CDN takes one line, but it means trusting that host to serve the exact same file, unchanged, to all of your users for as long as the tag stays on your page. A <script src="https://cdn.example.com/lib.js"> tag tells the browser to download whatever is at that URL right now and execute it with full access to your page. The browser doesn’t compare the file against the one you tested, and it doesn’t check any signature. It just runs whatever file it gets back.
The pattern
Subresource Integrity (SRI) lets the browser check each file against a cryptographic hash before using it. You add an integrity attribute to the <script> or <link> tag, and its value is a base64-encoded hash of the file you expect. Before the browser runs the script or applies the stylesheet, it hashes the bytes it actually downloaded and compares them with your value. If they match, the resource loads as normal. If they don’t, the browser refuses the whole file and reports an error, and a single changed byte is enough for that to happen.
Problem
Sometimes that trust turns out to be misplaced. A CDN can get breached, an insider can push a malicious build, an attacker can take over the package or account that the CDN mirrors, or a hijacked DNS record can send the request to a server the attacker controls. HTTPS doesn’t help with any of these, because it only protects the file on its way from the server to the browser. If the server itself is sending the wrong file, HTTPS delivers the wrong file safely and without changes. Any one of these can turn a dependency you trusted into hostile code that runs on every page that loads it. Supply-chain attackers go after popular CDNs because breaking into one can affect thousands of sites and millions of users at once.
A tampered script can do almost anything on the page. It can read passwords straight out of form fields, send session tokens to the attacker, inject a cryptocurrency miner, or quietly redirect users to a copy of your site built for phishing. Your own scripts run on the same page as the attacker’s code, and they can’t protect themselves from it. Your server-side checks don’t help either, because the attacker’s requests come from a real, logged-in user’s browser and look like normal traffic. You also can’t detect the swapped file from your side, because it runs in the user’s browser, and by the time someone notices, the data has already been stolen.
Solution
The hash starts with the name of its algorithm, such as sha384- or sha512-. SHA-384 and SHA-512 are the usual choices, though SHA-256 is also allowed. SRI depends on CORS, because the browser has to read the whole body of a cross-origin response to verify it. That’s why the tag needs crossorigin="anonymous", or use-credentials, so the browser makes a CORS request and the CDN has to serve the file with the right CORS headers. Without that attribute, the integrity check can’t run and the resource is blocked.
SRI only checks the tags that have an integrity attribute, so a tag you forget to add one to loads without any check. You may come across a Content Security Policy directive, require-sri-for, that was meant to fix this, but it was dropped from the spec and browsers don’t support it. It’s been replaced by the Integrity-Policy response header. Sending Integrity-Policy: blocked-destinations=(script) tells the browser to block any script that arrives without integrity metadata. Chrome has supported the header since version 138, and Firefox has added support since then. It isn’t Baseline yet, though, and enforcement for stylesheets is still experimental, so older browsers will still load a tag that doesn’t have a hash. An Integrity-Policy-Report-Only header reports violations without blocking anything, which is a useful way to find the tags without a hash before you start enforcing the policy.
Generating a hash is straightforward, and most CDNs now publish integrity values in their documentation next to each version number. For your own bundled assets, it makes sense to generate the hashes as part of the build. A build plugin can compute the hash of each output file and add the attribute automatically, so the hash always matches the file. SRI pins one exact file, though, so it only works with versioned URLs whose content never changes, such as .../react@18.2.0/.... If you use a “latest” or auto-updating URL, the file can change underneath your hash and break the page. I’d always pin a specific version, and treat each hash update as a chance to review the new release properly.
Example
The examples below show the attribute added to a tag manually, how to generate the hash, how to automate it in the build, and a fallback for when a resource is blocked.
Adding integrity to a CDN script
Both <script> and <link rel="stylesheet"> support the integrity and crossorigin attributes. The hash identifies the exact file, and the crossorigin attribute lets the browser read the response so it can check the hash.
<!-- External script, pinned to an exact version -->
<script
src="https://cdn.example.com/react@18.2.0/react.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"
></script>
<!-- External stylesheet -->
<link
rel="stylesheet"
href="https://cdn.example.com/bootstrap@5.3.0/bootstrap.min.css"
integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM"
crossorigin="anonymous"
/>
If even one character changes in either file, the browser blocks the resource and logs an integrity error to the console.
Generating the hash
The hash has to come from the exact file you plan to ship. These commands compute a SHA-384 hash as raw binary and then base64-encode it.
# Hash a local file
cat react.min.js | openssl dgst -sha384 -binary | openssl base64 -A
# -> oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC
# Hash a file straight from the CDN
curl -s https://cdn.example.com/react@18.2.0/react.min.js \
| openssl dgst -sha384 -binary | openssl base64 -A
Adding sha384- to the front of the output gives you the attribute value, integrity="sha384-oqVuAfXRKap7...". If you only need one or two values, the SRI Hash Generator website does the same thing in your browser.
Automating SRI in the build
Keeping the hashes for your own files up to date manually doesn’t work well, because a new build usually changes the bytes. A build plugin can compute the hash of each file the build outputs and add the attribute to the generated HTML automatically. With Vite, vite-plugin-sri-gen handles this, and in a plain Rollup build, rollup-plugin-sri does the same job.
// vite.config.js
import { defineConfig } from 'vite';
import sri from 'vite-plugin-sri-gen';
export default defineConfig({
plugins: [
sri({ algorithm: 'sha384' }),
],
});
With the plugin in place, each <script> and <link> tag that the build outputs gets a correct integrity attribute. The plugin regenerates the attribute on each build, so the hash always matches the file. In a webpack project, you can use webpack-subresource-integrity instead.
Providing a fallback
A resource that SRI blocks just doesn’t load, and if your page depends on that library, the page can break. You can guard against this by detecting the failure and loading a self-hosted copy. For scripts, a short inline script can check whether the library’s global variable exists, and load a local copy if it doesn’t.
<script
src="https://cdn.example.com/jquery@3.7.1/jquery.min.js"
integrity="sha384-1H217gwSVyLSIfaLxHbE7dRb3v4mYCKbpQvzx0cegeju1MVsGrX5xXxAvs/HgeFs"
crossorigin="anonymous"
></script>
<script>
// If integrity failed (or the CDN is down), window.jQuery is undefined.
window.jQuery || document.write(
'<script src="/vendor/jquery-3.7.1.min.js"><\/script>'
);
</script>
For stylesheets, an onerror handler on the <link> can switch the href to a local copy. The handler should be inline, or at least attached before the stylesheet fails to load, because a listener that a script adds later might miss an error that has already happened.
<link
rel="stylesheet"
href="https://cdn.example.com/bootstrap@5.3.0/bootstrap.min.css"
integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM"
crossorigin="anonymous"
onerror="this.onerror = null; this.href = '/vendor/bootstrap-5.3.0.min.css';"
/>
The local copy should be the same version as the pinned CDN file, so the page behaves the same whichever copy ends up loading.
Benefits
- The browser checks each external script and stylesheet against its hash before using it, so the page only uses the exact files you approved.
- If a CDN is compromised, the browser blocks the changed files instead of running them on your page.
- Supply-chain attacks that rely on quietly changing a popular library stop working, because the changed file no longer matches your hash.
- It only takes one attribute per tag, with no runtime library and no extra request, and the hash check adds very little work for the browser.
- Because a hash only matches one file, you have to pin an exact version, so an automatic CDN update can’t change how your page behaves without you knowing.
- In browsers that support the
Integrity-Policyheader, you can require SRI for every script on the page, so a tag without a hash is blocked instead of slipping through. - You can combine it with self-hosted fallbacks, so if a resource is blocked or unavailable, the page loads a local copy and keeps working.
- A build plugin can keep the hashes for your own assets correct without anyone updating them manually.
Tradeoffs
- Any legitimate change to the file, such as a routine CDN update or a change in the minifier’s output, stops the file loading until you regenerate the hash.
- You can’t use it with “latest” URLs or any other URL whose file changes, because the file stops matching the hash as soon as it’s updated.
- Each external resource needs a hash, and someone has to update it whenever the version changes, which adds real maintenance work.
- Verification needs CORS, so you can’t use SRI with a CDN that doesn’t send the right CORS headers.
- The browser either accepts a whole file or blocks it, and if it blocks a resource your page depends on, the page breaks unless you’ve built a fallback.
- It doesn’t suit files that change often, such as analytics or ad scripts that the vendor updates frequently, because you can’t keep the hashes up to date.
- Updating a library becomes a step that someone has to take and check, which is safer than using whatever the CDN serves, but slower.
- If a hash is wrong or out of date, the resource just doesn’t load and the user isn’t told why, so you need monitoring to catch the problem in production.
- SRI only checks that the file hasn’t changed, so it can’t help if the CDN goes offline, and it doesn’t stop the CDN from learning about your users from their requests.
Summary
Subresource Integrity pins an external script or stylesheet to a cryptographic hash, so the browser refuses to run anything that doesn’t match the exact file you approved. It protects you from compromised CDNs and tampered packages. In return, you have to regenerate the hash whenever a file legitimately changes, which means pinning exact versions and generating the hashes for your own assets in the build. An Integrity-Policy header can make the check mandatory in browsers that support it, and self-hosted fallbacks keep the page working when a resource is blocked.