Subresource Integrity Validation for Fingerprinted Assets

Fingerprinted filenames protect against stale caches, but they say nothing about whether the bytes a browser receives match the bytes you built — Subresource Integrity closes that gap by embedding a cryptographic hash directly in the HTML tag that loads each asset.

How SRI Relates to Content Hashing

A fingerprinted URL and an SRI integrity attribute are two projections of the same fact: the exact byte content of a built file. The fingerprint answers “which version is this?” — it is a short, URL-safe identifier baked into the filename so that a new build produces a new URL and the old URL stays immutable in caches. The integrity attribute answers a stricter question: “are these the exact bytes I signed at build time?” — it is a full-length cryptographic digest the browser recomputes and compares before it runs a single line of the script.

The distinction matters because the two values are not interchangeable. The fingerprint in main.a1b2c3d4.js is typically an 8-hex-character truncation of a content hash — enough entropy to make accidental collisions vanishingly unlikely within one project, and the right length for most builds (push to 12–16 characters only for monorepos emitting thousands of chunks). That truncated value is useless as a security control: an attacker who can rewrite the file on a compromised CDN can trivially produce different content whose 8-character prefix still appears in a URL you do not re-verify. The SRI digest cannot be forged that way, because the browser hashes the response body it actually received and rejects anything that does not match the full digest recorded in your HTML.

Because both values flow from the same source bytes, a build that emits content-hashed filenames is already producing the input SRI needs — the hash is computed once and projected two ways. This is why SRI integrates so cleanly into a fingerprinting pipeline: the moment a file’s content changes, its fingerprint changes, its URL changes, and its integrity attribute must be regenerated in lockstep. The content hashing vs semantic versioning choice determines how automatic that lockstep is. Content-hashed builds keep filename and integrity synchronized for free; semantic-version suffixes (main.v2.3.1.js) decouple the filename from the bytes, so a hotfix that changes content without bumping the version will silently produce a stale integrity attribute unless your pipeline regenerates the digest on every build. The same coupling underpins cache-key architecture: the URL is the cache key, the integrity attribute is the trust key, and both must rotate together.

When to Use SRI

SRI is the right tool when any of the following conditions hold:

  • Third-party CDN delivery — assets are served from an origin you do not fully control (shared CDN, vendor-hosted scripts, partner-injected stylesheets).
  • Multi-origin first-party delivery — your build pipeline writes assets to a CDN bucket and your HTML origin is separate; a compromised CDN credential could silently swap bytes.
  • Compliance requirements — PCI-DSS and similar frameworks now mandate script integrity checking on payment pages.
  • Defence in depth — even for self-hosted assets, SRI ensures that CDN-side transforms (auto-minification, Brotli recompression, edge-side includes) have not altered the payload.

SRI is the wrong tool when:

  • The asset URL changes on every request (server-side rendering with dynamic query strings) — the browser cannot cache a single integrity value.
  • You rely on CDN byte-for-byte transforms that you have not accounted for (see the edge-case section below).
  • Assets are loaded by service workers using the importScripts API — SRI is not enforced in that context.

The content hashing vs semantic versioning decision feeds directly into SRI: content-hashed filenames and SRI integrity attributes are generated from the same byte stream, so they are naturally kept in sync. Semantic version suffixes require a separate integrity-generation step.

Prerequisites

Requirement Minimum version / setting
Webpack 5.0.0 with webpack-subresource-integrity ≥ 5.1
Vite 5.0.0 with vite-plugin-sri3 ≥ 1.0 or manual rollup hook
Rollup 4.0.0 — use the generateBundle hook to compute hashes
Node.js (build scripts) 18 LTS (built-in crypto.createHash, fs/promises)
OpenSSL CLI 1.1.1 or 3.x — for manual hash verification
Browser targets All evergreen browsers support SRI; IE 11 does not
CDN Must serve assets with Access-Control-Allow-Origin header for cross-origin SRI

The crossorigin attribute on <script> and <link rel="stylesheet"> tags is mandatory when the asset is fetched from a different origin than the document. Without it, the browser performs an opaque fetch and cannot pass the response bytes through the integrity check, causing a silent failure that looks identical to a hash mismatch.

SRI Config Reference

Attribute / option Type Default Effect
integrity string Space-separated list of <algorithm>-<base64hash> values; browser accepts if any one matches
crossorigin "anonymous" | "use-credentials" absent "anonymous" sends no cookies; required for cross-origin SRI checks
Hash algorithm sha256 | sha384 | sha512 sha384 is the W3C recommendation; sha512 is preferred for long-lived assets
Multiple hashes space-separated in integrity Browser accepts the resource if any listed hash matches; used for algorithm agility during migration
webpack-subresource-integrity hashFuncNames string[] ["sha256"] Algorithms the plugin hashes with; becomes the integrity value
webpack-subresource-integrity enabled "always" | "auto" | "never" "auto" "auto" enables only in production mode

Step-by-Step Implementation

1. Generate the hash for a single file (manual baseline)

Before touching your build tool, understand the hash format:

# SHA-384 base64 digest — this is what goes in the integrity attribute
openssl dgst -sha384 -binary dist/assets/main.a1b2c3d4.js \
  | openssl base64 -A
# Output (example): 47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU96xtjz3FAPZ3KZHA12iK

The full integrity value is the algorithm prefix concatenated with a hyphen and the base64 output:

sha384-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU96xtjz3FAPZ3KZHA12iK

2. Inject SRI into HTML manually

For small sites or server-rendered templates, inject directly:

<!-- For a script loaded from a CDN origin -->
<script
  src="https://cdn.example.com/assets/main.a1b2c3d4.js"
  integrity="sha384-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU96xtjz3FAPZ3KZHA12iK"
  crossorigin="anonymous"
  defer
></script>

<!-- For a stylesheet -->
<link
  rel="stylesheet"
  href="https://cdn.example.com/assets/styles.b3c4d5e6.css"
  integrity="sha384-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
  crossorigin="anonymous"
/>

The crossorigin="anonymous" attribute is required on both <script> and <link> when the resource origin differs from the document origin. Omitting it causes the browser to perform an opaque request, which cannot be integrity-checked.

3. Automate with Webpack (webpack-subresource-integrity)

The webpack-subresource-integrity plugin reads Webpack’s chunk graph, computes integrity values for every emitted chunk, and writes them into stats.json / the HTML output:

npm install --save-dev webpack-subresource-integrity html-webpack-plugin
// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { SubresourceIntegrityPlugin } = require('webpack-subresource-integrity');

module.exports = {
  mode: 'production',
  output: {
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js',
    // crossOriginLoading is required so dynamically imported chunks also carry CORS headers
    crossOriginLoading: 'anonymous',
  },
  plugins: [
    new HtmlWebpackPlugin({ template: 'src/index.html' }),
    new SubresourceIntegrityPlugin({
      hashFuncNames: ['sha384'],
      enabled: 'always',
    }),
  ],
};

After the build, dist/index.html will contain entries like:

<script
  src="main.3fa8b21c.js"
  integrity="sha384-..."
  crossorigin="anonymous"
></script>

Dynamic imports (import()) also receive integrity attributes because the plugin patches Webpack’s runtime chunk-loading code.

4. Automate with Vite

Vite 5 does not ship a built-in SRI plugin, but the vite-plugin-sri3 package wraps the Rollup generateBundle hook:

npm install --save-dev vite-plugin-sri3
// vite.config.js
import { defineConfig } from 'vite';
import sri from 'vite-plugin-sri3';

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        assetFileNames: 'assets/[name]-[hash:8][extname]',
        chunkFileNames: 'chunks/[name]-[hash:8].js',
        entryFileNames: 'entry-[name]-[hash:8].js',
      },
    },
  },
  plugins: [
    sri({
      algorithms: ['sha384'],
    }),
  ],
});

The plugin rewrites dist/index.html post-bundle, adding integrity and crossorigin="anonymous" to every <script src> and <link rel="stylesheet"> it finds.

5. Write a manifest-driven Node.js injection script

When your HTML template is rendered server-side (Next.js, Astro, Express), you often need to read the build manifest and emit integrity attributes yourself:

// scripts/inject-sri.mjs
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

const DIST = new URL('../dist/', import.meta.url).pathname;
const MANIFEST = join(DIST, 'manifest.json');
const HTML_OUT = join(DIST, 'index.html');

async function computeSri(filePath, algorithm = 'sha384') {
  const bytes = await readFile(filePath);
  const digest = createHash(algorithm).update(bytes).digest('base64');
  return `${algorithm}-${digest}`;
}

async function main() {
  const manifest = JSON.parse(await readFile(MANIFEST, 'utf8'));
  let html = await readFile(HTML_OUT, 'utf8');

  for (const [_key, entry] of Object.entries(manifest)) {
    const assetFile = entry.file;
    const assetPath = join(DIST, assetFile);
    const integrity = await computeSri(assetPath);

    // Replace src/href references with an integrity-bearing version
    const srcPattern = new RegExp(
      `(src|href)=["'](/?${assetFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})["']`,
      'g'
    );
    html = html.replace(
      srcPattern,
      `$1="$2" integrity="${integrity}" crossorigin="anonymous"`
    );
  }

  await writeFile(HTML_OUT, html, 'utf8');
  console.log(`SRI injected into ${HTML_OUT}`);
}

main().catch((err) => { console.error(err); process.exit(1); });

Run this script after your build step:

node scripts/inject-sri.mjs

For a Vite project the manifest is at dist/.vite/manifest.json and contains { "src/main.ts": { "file": "assets/main-a1b2c3d4.js", ... } }. For Webpack output hashing, the equivalent is stats.json keyed by chunk name.

SRI verification flow The browser fetches the resource, computes its hash, compares that hash to the integrity attribute value, then either executes the resource or blocks it and fires a violation report. HTML Parser reads integrity= "sha384-…" Network Fetch GET + CORS check crossorigin=anon Hash Response SHA-384 of response bytes Compare Hash computed vs attribute value match? YES NO Execute Resource script runs / styles applied Block Resource network error + console violation integrity attribute is checked AFTER the full response body is received — not during streaming multiple algorithms in integrity="" → browser accepts if any one matches
SRI verification flow: the browser computes a hash over the fetched response body and compares it against the integrity attribute before executing or applying the resource.

Verification Shell Commands

After deploying, confirm that the served bytes match the integrity attribute you injected:

# 1. Download the deployed asset and compute its SHA-384
curl -s --compressed "https://cdn.example.com/assets/main.a1b2c3d4.js" \
  | openssl dgst -sha384 -binary | openssl base64 -A

# 2. Check the CORS header is present (required for cross-origin SRI)
curl -I -H "Origin: https://www.example.com" \
  "https://cdn.example.com/assets/main.a1b2c3d4.js" \
  | grep -i "access-control-allow-origin"

# 3. Confirm the integrity attribute in the deployed HTML
curl -s "https://www.example.com/" \
  | grep -o 'integrity="[^"]*"'

# 4. Compare local build output hash against deployed hash
LOCAL_HASH=$(openssl dgst -sha384 -binary dist/assets/main.a1b2c3d4.js | openssl base64 -A)
REMOTE_HASH=$(curl -s --compressed "https://cdn.example.com/assets/main.a1b2c3d4.js" \
  | openssl dgst -sha384 -binary | openssl base64 -A)
[ "$LOCAL_HASH" = "$REMOTE_HASH" ] && echo "MATCH" || echo "MISMATCH — SRI will fail"

Note the --compressed flag: if the CDN serves Brotli or gzip, curl --compressed decompresses before hashing, which is what the browser’s integrity check operates on. However, see the CDN interaction section below for an important nuance.

CDN and Edge Behavior

SRI lives at the intersection of two systems you must reason about together: the immutable caching contract you set on fingerprinted assets, and the byte-exactness contract SRI demands. They reinforce each other when configured correctly and fight each other when they are not.

Caching headers are unaffected by SRI

The integrity check is a browser-side comparison; it never travels to the origin or the edge. A fingerprinted asset should still be served with the strongest possible caching directive:

# Nginx — immutable fingerprinted assets, untouched by SRI
location ~* \.[0-9a-f]{8,16}\.(js|css|woff2)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Access-Control-Allow-Origin "https://www.example.com";
    add_header Cross-Origin-Resource-Policy "cross-origin";
    types { }
    default_type application/octet-stream;
}
# Cloudflare Cache Rule equivalent
# Match: (http.request.uri.path matches "\.[0-9a-f]{8,16}\.(js|css|woff2)$")
# Edge TTL: 1 year
# Browser TTL: respect existing headers
# IMPORTANT: disable "Auto Minify" / "Rocket Loader" / "Mirage" on this path

Because the URL changes on every content change, the immutable directive is safe: a browser that has cached main.a1b2c3d4.js will never re-request it, and the integrity attribute it stored alongside that URL remains valid for the life of that exact byte stream. The headers and the integrity attribute share a single source of truth — the file content — so they can never drift apart for a given URL.

The CORS header is load-bearing for cross-origin SRI

When the asset and the document live on different origins (the common case once you front assets with a CDN hostname), the browser’s integrity check requires a CORS-readable response. The Access-Control-Allow-Origin header above is what makes the response body readable to the integrity algorithm. Without it, the response is opaque and the integrity check fails even though the bytes are correct — the browser simply cannot see the body to hash it. This is the single most common cause of “valid asset, failing SRI” incidents, and it is why every cross-origin example on this page pairs crossorigin="anonymous" in the HTML with Access-Control-Allow-Origin at the edge.

Edge content transforms are the enemy of SRI

Any feature that rewrites the response body at the edge will break SRI: HTML/JS/CSS auto-minification, comment stripping, script-injection features (analytics beacons, consent banners injected into JS), image-to-WebP conversion for assets referenced via integrity, and “optimize delivery” toggles that re-emit the file. Transport compression (gzip/Brotli) is fine — the browser hashes the decompressed body — but anything that changes the decompressed bytes is fatal. The rule is simple: for any path covered by SRI, the edge must be a transparent byte pipe. Disable Cloudflare Auto Minify and Rocket Loader, disable CloudFront’s automatic compression in favor of pre-compressed origin objects, and confirm your origin and edge agree byte-for-byte using the verification commands above. When SRI breaks in production despite a correct build, an edge transform is the first suspect — the full diagnosis workflow lives in debugging SRI validation failures.

Edge Cases and Known Issues

CDN byte-for-byte transforms break SRI

Most CDN platforms apply automatic optimizations: Cloudflare’s “Auto Minify” feature strips whitespace from JavaScript and CSS; some platforms re-gzip content with different compression parameters. These transforms change the response bytes and therefore the hash. The browser computes a hash over the decompressed response body, so transport-layer compression (gzip/Brotli headers) is transparent to SRI. However, content transforms — minification, comment stripping, charset normalization — change the decompressed body and break the hash.

Resolution:

  • Disable “Auto Minify” and similar CDN content transforms for paths covered by SRI.
  • On Cloudflare: Page Rules or Transform Rules → set “Disable Performance” for /assets/*.
  • On CloudFront: disable the “Compress Objects Automatically” option on the behavior and handle compression in your build pipeline.
  • Verify with curl --compressed locally vs remotely and diff the outputs before deploying.

Missing crossorigin attribute causes silent failure

Without crossorigin, the browser issues an opaque request. An opaque response has no readable body for integrity checking, so the browser reports a failure even if the bytes are identical. This is the most common SRI bug. Always add crossorigin="anonymous" when the asset origin differs from the document origin. For same-origin assets (both document and asset served from www.example.com), crossorigin is not required, but adding it is harmless.

Dynamic chunk loading requires runtime patching

For code-split applications, the initial HTML only references the entry chunk. Dynamic imports load additional chunks at runtime. Without runtime patching, these chunks are fetched without integrity attributes. The webpack-subresource-integrity plugin handles this by modifying Webpack’s __webpack_require__.l runtime function to include integrity values from the stats manifest. Rollup-based tools (Vite) need explicit runtime handling; vite-plugin-sri3 injects a small import-map-like structure for this purpose.

Service workers and importScripts

SRI is not enforced on importScripts() calls within service workers, regardless of the integrity attribute on the worker <script> tag itself. If you load scripts inside a service worker, validate them via a fetch event handler with manual hash comparison using the Web Crypto API (crypto.subtle.digest).

Algorithm agility: specifying multiple hashes

The W3C spec allows space-separated multiple hash values in the integrity attribute. The browser accepts the resource if any value matches:

<script
  src="/assets/main.a1b2c3d4.js"
  integrity="sha256-abc123... sha384-def456..."
  crossorigin="anonymous"
></script>

This enables rolling algorithm migrations: deploy the new algorithm alongside the old one, verify browsers are not rejecting, then remove the weaker algorithm in the next release cycle.

Preload hints also support integrity attributes:

<link rel="preload" as="script" href="/assets/main.a1b2c3d4.js"
      integrity="sha384-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU96xtjz3FAPZ3KZHA12iK"
      crossorigin="anonymous"/>

The preload fetch is integrity-checked. When the same URL is later used in a <script src> with a matching integrity attribute, the browser reuses the preloaded response from the cache. If the two integrity values differ (e.g., a stale preload hint), the browser fetches the resource again and applies the <script> tag’s integrity check.

Performance Impact

SRI hash computation in the browser occurs synchronously over the response body before the resource is parsed or executed. Measured overhead on modern devices:

Asset size SHA-256 overhead SHA-384 overhead SHA-512 overhead
50 KB < 0.5 ms < 0.6 ms < 0.8 ms
500 KB < 2 ms < 3 ms < 4 ms
5 MB < 12 ms < 16 ms < 22 ms

SHA-384 is the minimum recommended by the W3C for new deployments. SHA-256 is acceptable for build cache-busting fingerprints (where you control both sides), but SHA-256 has been practically broken for collision resistance — not for SRI, but using SHA-256 everywhere removes the option to upgrade SRI separately from fingerprinting. The overhead is negligible on desktop; on low-end mobile it remains under 20 ms even for large bundles.

Build-side hash computation adds roughly 1–2 ms per file using OpenSSL on modern CI hardware. For a project with 200 output chunks, this is a 200–400 ms total addition — well within acceptable build overhead. See deterministic build outputs for techniques to keep build times stable when adding SRI to a large pipeline.

Frequently Asked Questions

Does SRI apply to assets loaded by service workers?

SRI is not enforced on resources fetched via importScripts() or fetch() inside a service worker. The integrity attribute on the <script> tag that registers the worker is checked, but once the worker is running it operates outside SRI enforcement. For in-worker fetch integrity, use crypto.subtle.digest manually.

Can I use the same hash for both the CDN fingerprint and the SRI attribute?

No. The CDN fingerprint (e.g., 8 hex characters in the filename) is a truncated, URL-safe identifier derived from the content hash. The SRI hash is a full-length base64-encoded cryptographic digest of the entire file. They derive from the same source bytes but serve different purposes — truncation for filenames, full digest for security. Using the truncated hash as an integrity value will cause a browser rejection because the format is wrong.

What happens if an attacker modifies an asset on the CDN?

The browser computes a hash over the tampered bytes, compares it against the integrity attribute in the HTML (which the attacker did not control, assuming TLS is intact on the HTML origin), finds a mismatch, and blocks execution. The browser also fires a securitypolicyviolation event that can be forwarded to your monitoring endpoint via a Content Security Policy report-uri or report-to directive.

Should I use SHA-256, SHA-384, or SHA-512?

SHA-384 is the practical default: it has 384-bit security, is natively accelerated on ARMv8 and x86 via SHA extension instructions, and is the algorithm specified in the W3C SRI recommendation. SHA-512 provides no meaningful security advantage over SHA-384 for file integrity checking and produces longer attribute values. SHA-256 is sufficient for cache-busting fingerprints (see MD5 vs SHA-256 for assets) but for SRI, prefer SHA-384 or higher to future-proof the deployment.