Static Asset Fingerprinting Fundamentals

Static asset fingerprinting enforces deterministic cache lifecycles by embedding cryptographic hashes directly into filenames. This technique eliminates version conflicts, guarantees automatic cache invalidation on content changes, and removes the need for CDN-wide purges when new builds ship.

Frontend engineers and DevOps teams deploy this strategy to align immutable caching directives with edge network behaviour. Correct implementation reduces origin load, accelerates time-to-first-byte across all geographic regions, and prevents stale asset delivery during rolling releases and blue-green deployments.

What Fingerprinting Solves

Traditional deployments overwrite files in place: main.js at v1.2 becomes main.js at v1.3. CDNs and browsers cached the original response. Without an explicit purge or a no-cache directive forcing revalidation, visitors receive the old file — sometimes for days — until TTL expiry. The application breaks when old JavaScript tries to import new CSS class names, when old HTML references new API endpoints, or when stale service workers serve a mixed-version bundle.

These failures are particularly damaging in single-page applications where JavaScript modules import each other dynamically. If app.js is cached from a previous build but router.js has been updated to export a renamed function, the import fails silently or throws at runtime. Users see a blank screen or a JavaScript error console, and the failure is invisible to your monitoring because the CDN returned a 200 — just for stale content.

Fingerprinting breaks this pattern. The SHA-256 content digest of every source byte is truncated to 8 hex characters and embedded in the filename: main.a1b2c3d4.js. When any byte changes, the digest changes, and the new URL is treated as a completely different resource. Old and new assets coexist on the CDN simultaneously, so users mid-session on the old version continue to get consistent responses while new visitors receive the updated build. The only document that needs purging is the HTML entry point, because it holds the references to all hashed filenames.

This separation between versioned HTML and immutable assets is the central architectural insight. Understanding content hashing versus semantic versioning helps teams decide when to embed hashes in filenames versus when a manual ?v=1.3 query parameter is sufficient — and the answer is almost always filename hashing for production traffic.

When Fingerprinting Is Not Enough

Fingerprinting guarantees that a changed file gets a new URL. It does not guarantee that the file was built correctly, that the manifest was updated atomically with the asset upload, or that the CDN will serve the right Cache-Control headers. Each of those requires explicit configuration. A fingerprinted build with Cache-Control: no-cache on the assets directory is functionally equivalent to no fingerprinting at all — the browser revalidates every asset on every page load, negating the performance benefit while adding URL complexity. The sections below cover each requirement in sequence.

The Fingerprint Lifecycle

The following diagram traces an asset from source file to cached browser response. Each stage has a distinct role: build tools compute hashes and write the manifest, the deploy step propagates files atomically, the CDN edge stores the immutable response, and the browser uses the manifest-resolved URL to retrieve the correct version.

Fingerprint lifecycle Six stages showing how a source file becomes a fingerprinted asset in the browser: build hashes the file, writes a manifest, deploys atomically, the CDN edge caches it immutably, and the browser fetches the hashed URL. Build compile bundle Hash SHA-256 truncate 8 Manifest logical → hashed URL Deploy atomic sync then HTML Edge / CDN immutable max-age=1yr Browser disk cache Purge HTML only assets never need purging SRI integrity="" browser verifies payload manifest.json emitted per build
The fingerprint lifecycle: source bytes are hashed and renamed at build time, recorded in a manifest, deployed atomically, cached immutably at the CDN edge, and retrieved by browsers. Only HTML entry points need purging on release.

Algorithm Selection

The hashing algorithm determines both collision safety and toolchain compatibility. Use the table below to select an algorithm and truncation length for your project.

Algorithm Collision Probability Output Length Truncation Default Production Use
MD5 Non-trivial at scale 32 hex 8 Avoid — cryptographically broken
SHA-1 Theoretical weaknesses 40 hex 8 Deprecated — strip from CI
SHA-256 Negligible (2¹²⁸ address space) 64 hex 8 Recommended
BLAKE3 Negligible 64 hex 8 Viable, less toolchain support

Production systems default to SHA-256. Truncating to 8 hex characters yields 4,294,967,296 unique combinations, which comfortably exceeds the asset count of enterprise-scale applications. Increase to 12–16 characters for monorepos with thousands of chunks where birthday-paradox collision probability becomes measurable. The trade-off analysis for truncation lengths is covered in detail in the safely truncating content hash length guide.

The MD5 vs SHA-256 for assets reference covers legacy compatibility scenarios, including reverse proxies that cannot handle 64-character filename segments and corporate firewalls that reject requests with unusual URL patterns.

Build Tool Integration

Bundlers must compute hashes during compilation, rename output files, and update all internal references automatically. Manual post-build renaming breaks module resolution, causes hydration mismatches in server-rendered frameworks, and invalidates source maps. Each major build tool exposes a token-based filename template for this purpose.

Webpack 5 Production Configuration

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  mode: 'production',
  entry: {
    app: './src/index.js',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash:8].js',
    chunkFilename: 'chunks/[name].[contenthash:8].js',
    assetModuleFilename: 'assets/[hash:8][ext][query]',
    clean: true,
  },
  optimization: {
    moduleIds: 'deterministic',
    chunkIds: 'deterministic',
    runtimeChunk: 'single',
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
        },
      },
    },
  },
  plugins: [
    new HtmlWebpackPlugin({ template: './src/index.html' }),
    new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash:8].css' }),
  ],
};

The contenthash token hashes only the content of that chunk, not the entire dependency graph. This means a change to Button.js does not rehash vendor.js, preserving long-lived cache entries for unchanged third-party code. The deterministic flags for moduleIds and chunkIds ensure module-to-integer assignments stay stable across builds when unrelated files are added or removed — without these, module IDs shift numerically and cause phantom hash changes across the whole bundle.

The webpack output hashing setup guide covers the full configuration surface including source maps, asset modules, and the common issue of missing asset hashes in Webpack 5.

Vite 5 Deterministic Asset Output

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    assetsDir: 'assets',
    sourcemap: false,
    rollupOptions: {
      output: {
        assetFileNames: 'assets/[name]-[hash:8][extname]',
        chunkFileNames: 'chunks/[name]-[hash:8].js',
        entryFileNames: 'entries/[name]-[hash:8].js',
        manualChunks: {
          vendor: ['react', 'react-dom'],
        },
      },
    },
  },
});

Vite derives its [hash] from Rollup’s outputFileNaming pipeline. The hash is content-based: only bytes that enter the chunk affect the digest, so separating vendor code into a manualChunks entry ensures React’s bundle hash changes only when React itself updates. The full configuration reference and edge cases around CSS code splitting are documented in the Vite asset pipeline configuration guide.

Rollup 4 Standalone Configuration

import { createHash } from 'crypto';
import { readFileSync } from 'fs';

export default {
  input: 'src/index.js',
  output: {
    dir: 'dist',
    format: 'es',
    assetFileNames: 'assets/[name]-[hash:8][extname]',
    chunkFileNames: 'chunks/[name]-[hash:8].js',
    entryFileNames: '[name]-[hash:8].js',
    sourcemap: false,
  },
};

Rollup 4 includes content hashing natively for ES module output without plugins. The [hash] placeholder is computed across the entire chunk dependency subgraph, meaning a shared utility module change propagates hash changes up to all importing chunks. This behaviour differs from Webpack’s contenthash — understand the distinction when migrating between tools.

esbuild and Astro

esbuild uses --entry-names=[dir]/[name]-[hash] and --chunk-names=chunks/[name]-[hash] for content hashing. Unlike Webpack and Rollup, esbuild computes hashes based on the full dependency tree of each entry point, not just the file’s own bytes. This produces more conservative cache invalidation — a change to a shared utility will rehash every entry that imports it — but eliminates the need for a runtime chunk manifest to reassemble split modules.

Astro handles fingerprinting automatically for assets imported through its module graph. Static files in the public/ directory are not fingerprinted; use src/assets/ instead and import them in components so Astro processes them through its content pipeline. Configuration details are in the Astro static asset optimization and fingerprinting guide.

Build Pipeline and CI Integration

Ensuring identical hashes across every CI runner requires locking three variables: the Node version, dependency resolution, and plugin configuration. One non-deterministic input anywhere in the graph — a timestamp embedded by a plugin, a randomly assigned module ID, an OS-dependent path separator, a locale-dependent sort order — invalidates the entire reproducibility guarantee.

The practical test for determinism is straightforward: run the build twice in the same CI job and compare the output hashes with diff <(ls -1 dist/assets/ | sort) <(ls -1 dist/assets/ | sort). If the filenames differ between two runs on identical source code, something in the pipeline is introducing entropy. Build twice and diff is cheaper to run than bisecting a live incident.

Enforcing deterministic build outputs means treating the build environment as a sealed input. The debugging phantom hash changes in CI guide provides a systematic methodology for tracking down non-deterministic plugins, unstable sort orders in dependency resolution, and environment-specific path mutations.

GitHub Actions with S3 and CloudFront

name: Deploy Fingerprinted Assets
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      BUCKET_NAME: ${{ secrets.S3_BUCKET }}
      CF_DIST_ID: ${{ secrets.CF_DISTRIBUTION_ID }}
      AWS_DEFAULT_REGION: us-east-1
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci --prefer-offline

      - name: Build with content hashes
        run: npm run build

      - name: Verify manifest exists
        run: |
          test -f dist/manifest.json || (echo "manifest.json missing — build failed" && exit 1)
          cat dist/manifest.json

      - name: Upload hashed assets (immutable)
        run: |
          aws s3 sync dist/assets/ s3://$BUCKET_NAME/assets/ \
            --delete \
            --cache-control "public, max-age=31536000, immutable" \
            --metadata-directive REPLACE

      - name: Upload HTML entry points (short TTL)
        run: |
          aws s3 sync dist/ s3://$BUCKET_NAME/ \
            --exclude "assets/*" \
            --cache-control "public, max-age=60, stale-while-revalidate=3600" \
            --metadata-directive REPLACE

      - name: Invalidate HTML on CloudFront
        run: |
          aws cloudfront create-invalidation \
            --distribution-id $CF_DIST_ID \
            --paths "/index.html" "/app.html" "/*.html"

This workflow uses two aws s3 sync commands deliberately: hashed assets in dist/assets/ receive max-age=31536000, immutable; HTML files at the document root receive a 60-second TTL with stale-while-revalidate. HTML is invalidated on CloudFront only, not the assets directory. This keeps asset cache hit ratios near 100% across CDN edge nodes while ensuring browsers pick up the new document within seconds of a release.

The CI/CD asset pipeline integration guide extends this pattern to multi-stage pipelines, artifact reuse across jobs, and strategies for promoting a build artifact from staging to production without rebuilding.

CDN and Edge Cache Behaviour

Cache-Control Header Strategy

Fingerprinted assets and HTML entry points require different caching strategies. Applying immutable to HTML causes users to miss deployments; applying short TTLs to hashed assets defeats the purpose of fingerprinting.

Resource Type Cache-Control Directive Rationale
Hashed JS/CSS/fonts/images public, max-age=31536000, immutable Content will never change at this URL
HTML entry points public, max-age=60, stale-while-revalidate=3600 New builds must propagate within minutes
Service worker JS no-cache SW must always be revalidated
API responses private, no-store Not applicable to static assets

The immutable directive signals to supporting browsers (Chrome, Firefox, Safari) that the cached response will never change even if the user manually reloads the page. This eliminates conditional GET requests that would otherwise reach the origin with If-None-Match or If-Modified-Since headers. CDN edge nodes honour the same directive.

Setting fingerprinting in HTTP headers correctly covers the ETag header interaction, the behaviour of the immutable flag across CDN vendors, and how to configure Nginx for immutable asset serving. The ETag vs immutable Cache-Control for assets guide explains when ETags add redundant round trips and when they remain necessary for legacy clients.

Cloudflare Cache Rules

For Cloudflare deployments, create a Cache Rule scoped to your assets path rather than relying on default caching:

Rule: Cache assets for one year
When: URI path matches regex ^/assets/.*\.[0-9a-f]{8,}\.(js|css|woff2|png|svg)$
Then: Cache Level = Cache Everything
      Edge Cache TTL = 1 year
      Browser Cache TTL = 1 year

Apply this via the Cloudflare dashboard or Terraform. Avoid the Bypass Cache setting on the origin Cache-Control header pass-through, which can cause the 31536000 value from S3 to be silently overridden. Full CDN cache rule configuration and purge workflows are covered in the CDN purge strategies reference, including tag-based purge for Cloudflare and Cloudflare cache-by-URL versus cache-by-tag tradeoffs.

Nginx Configuration for Immutable Assets

server {
    listen 443 ssl http2;
    server_name example.com;

    root /var/www/app/current;
    index index.html;

    # Immutable hashed assets — match exactly 8+ hex chars before the extension
    location ~* ^/assets/.*\.[0-9a-f]{8,}\.(js|css|woff2|woff|ttf|png|jpg|svg|ico)$ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        add_header X-Asset-Fingerprinted "true";
        try_files $uri =404;
        access_log off;
    }

    # HTML entry points — short TTL
    location ~* \.html$ {
        add_header Cache-Control "public, max-age=60, stale-while-revalidate=3600";
        try_files $uri /index.html;
    }

    # Service worker
    location = /sw.js {
        add_header Cache-Control "no-cache";
        try_files $uri =404;
    }
}

Verification Workflow

Post-deployment verification confirms that edge nodes store the correct response and return the expected headers. Run these checks as part of your deploy job or as a smoke test triggered after propagation.

Inspect Response Headers Directly

# Check the asset — expect immutable header
curl -sI https://cdn.example.com/assets/main.a1b2c3d4.js \
  | grep -E "cache-control|x-cache|cf-cache-status|age"

# Force an origin fetch to bypass CDN (Cloudflare: use Cloudflare-CDN-Cache-Control)
curl -sI https://cdn.example.com/assets/main.a1b2c3d4.js \
  -H "Cache-Control: no-cache" \
  | grep -i "cache-control"

# Confirm HTML has short TTL
curl -sI https://example.com/index.html \
  | grep "cache-control"

Expected output for the asset:

cache-control: public, max-age=31536000, immutable
cf-cache-status: HIT
age: 3600

Verify Manifest Completeness

# All entry points must appear in manifest.json
node -e "
const manifest = require('./dist/manifest.json');
const entries = ['src/index.js', 'src/admin.js'];
const missing = entries.filter(e => !manifest[e]);
if (missing.length) { console.error('Missing:', missing); process.exit(1); }
console.log('Manifest OK:', Object.keys(manifest).length, 'entries');
"

SHA-256 Cross-Check

# Confirm the deployed file matches the local build output
LOCAL_HASH=$(sha256sum dist/assets/main.a1b2c3d4.js | awk '{print $1}')
REMOTE_HASH=$(curl -sf https://cdn.example.com/assets/main.a1b2c3d4.js | sha256sum | awk '{print $1}')
if [ "$LOCAL_HASH" != "$REMOTE_HASH" ]; then
  echo "HASH MISMATCH: local=$LOCAL_HASH remote=$REMOTE_HASH"
  exit 1
fi
echo "Hash verified: $LOCAL_HASH"

Subresource Integrity

Subresource Integrity (SRI) adds a second layer of tamper protection. The build pipeline computes a base64-encoded SHA-384 digest of each output file and embeds it in the integrity attribute of every <script> and <link> tag. Browsers block execution if the fetched payload does not match the declared hash — protecting users from CDN-level content injection and supply chain attacks.

# Generate SRI hash for a built asset
openssl dgst -sha384 -binary dist/assets/main.a1b2c3d4.js \
  | openssl base64 -A

Inject the result into your HTML template:

<script
  src="/assets/main.a1b2c3d4.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
  crossorigin="anonymous"
></script>

<link
  rel="stylesheet"
  href="/assets/styles.c3d4e5f6.css"
  integrity="sha384-Li9vy3DqF8tnTXuiaAJuML3ky+er10rcgNR/VqsVpcw+ThHmYcwiB1pbOxEbzJr7"
  crossorigin="anonymous"
/>

The crossorigin="anonymous" attribute is required when serving assets from a different origin (CDN subdomain). Without it, the browser sends no CORS credentials, and the integrity check is skipped silently in some browsers.

Build-time SRI generation and the debugging workflow for ERR_SRI_SIGNATURE_MISMATCH errors are covered in the generating SRI hashes in your build pipeline guide and the debugging SRI validation failures guide.

Cache Key Architecture

CDN cache keys map request attributes to stored responses. A poorly designed cache key fragments the cache pool: two requests for main.a1b2c3d4.js that differ only in a Cookie header end up as separate cache entries, halving the effective hit ratio. A well-designed key normalises irrelevant dimensions out of the key.

The cache key architecture guide covers normalisation rules for query parameters, headers, and geographic variants. The implementing cache keys with query parameters vs filenames guide provides the decision matrix for when query strings cause CDN fragmentation and when they are safe.

Fingerprinted filenames make cache key design simpler: because the hash is in the path, the CDN never needs to key on headers or query parameters to distinguish asset versions. Cookie, Accept-Encoding, and geographic headers can be stripped from the cache key for hashed assets without risk of serving stale content. This also means rolling back cache keys after a bad deploy is straightforward — the old hashed URL is simply left in place on the CDN while the HTML entry point is updated to reference the rollback filename.

Troubleshooting Common Failures

Phantom Hash Changes on Every Build

Symptom: Running the build twice on the same source code produces different output filenames.

Root causes and fixes:

  1. Non-deterministic plugin — a Babel plugin or PostCSS transform embeds a build timestamp or random UUID. Identify it with DEBUG=vite:build npm run build 2>&1 | grep -i "timestamp\|random\|uuid". Disable or replace the offending plugin.

  2. Unstable module ordering — Webpack without moduleIds: 'deterministic' assigns numeric IDs in insertion order, which varies with file-system enumeration order. Add the flag or upgrade to Webpack 5.

  3. Floating dependency versions — npm install without a lockfile can resolve different patch versions across runners. Use npm ci in CI, which installs strictly from package-lock.json.

  4. Inline source maps in production — source maps embed file paths that include absolute filesystem paths, which differ between developers’ machines and CI agents. Set sourcemap: false in production builds or use hidden source maps that are not embedded in the bundle.

The why deterministic builds matter for asset fingerprinting guide explains the reproducibility contract in depth.

Cache Misses After Correct Deployment

Symptom: Assets receive max-age=31536000, immutable headers but still show MISS at the CDN edge for minutes or hours.

Cold cache is expected behaviour. CDN edge nodes populate the cache on the first request from each geographic PoP. Every distinct PoP must receive at least one request before it caches the asset. There is no way to pre-warm all PoPs simultaneously using standard cache semantics — instead, use CDN-specific cache warming APIs or accept that cold misses after a deploy are a transient, not a misconfiguration.

A persistent MISS (hours after deployment) usually means the origin is returning a no-cache or private directive that overrides the S3 or Nginx configuration. Verify with a direct origin fetch using curl -sI https://origin.example.com/assets/main.a1b2c3d4.js.

Broken Module References in the Browser

Symptom: Console shows Failed to load module script or 404 for a chunk file that exists in the build output.

The HTML entry point contains references to hashed filenames that were generated during the build. If the HTML was deployed separately from the assets — or if the deploy was non-atomic and the old HTML was still live while new assets arrived — browsers may request the new filename before it is available, or request an old filename that was deleted by --delete in aws s3 sync.

Fix: upload all assets before uploading or invalidating HTML. In S3 pipelines, the sync of dist/assets/ must complete fully before the sync of dist/*.html. Verify sequence in your CI logs.

CDN Serving Stale HTML After Invalidation

Symptom: Users see stale content even after a successful CloudFront invalidation.

CloudFront invalidation removes files from all 450+ edge PoPs, but it takes 30–60 seconds to propagate globally. During that window, some edges serve the old HTML referencing old asset filenames — which remain valid and cached, so the page loads correctly. This is by design. If stale HTML persists beyond 120 seconds post-invalidation, check that the invalidation was submitted to the correct distribution ID and that the paths include all HTML files, not just /index.html.

Common Failure Modes Reference

Issue Root Cause Resolution
Hash regenerates every build Non-deterministic plugin or floating deps npm ci, moduleIds: 'deterministic', sourcemap: false
CDN returns stale asset immutable header missing or overridden Check origin headers, verify CDN rule precedence
404 for newly deployed chunk Non-atomic deploy: HTML updated before assets Upload assets first, HTML last
SRI mismatch in browser Asset transformed by CDN (e.g. minification, gzip) Disable on-the-fly transforms; compute SRI after transformation
Reverse proxy strips hash from URL Proxy regex too restrictive Extend regex to match [0-9a-f]{8,} before extension

Pre-Deploy Checklist

Run this checklist before every release to production to catch configuration drift before it reaches users.

  • npm ci used in CI (not npm install) — lockfile is the source of truth
  • moduleIds and chunkIds set to deterministic (Webpack) or equivalent
  • sourcemap: false in production build config, or source maps use hidden mode
  • dist/manifest.json exists and maps all entry points to hashed filenames
  • Cache-Control: public, max-age=31536000, immutable on all /assets/** responses
  • Cache-Control: public, max-age=60, stale-while-revalidate=3600 on HTML entry points
  • Cache-Control: no-cache on /sw.js if a service worker is present
  • integrity attributes present on all <script> and <link rel="stylesheet"> tags
  • crossorigin="anonymous" present on all cross-origin asset tags
  • cf-cache-status: HIT or x-cache: Hit for assets

Frequently Asked Questions

Should I purge the entire CDN cache after deploying fingerprinted assets?

No. Only purge the HTML entry points. Fingerprinted assets are immutable — the URL itself encodes the content version, so the cached response is permanently correct by definition. Purging all assets forces every CDN edge to re-fetch from origin simultaneously, causing origin load spikes and elevated TTFB for minutes. The CDN purge strategies reference documents the correct per-CDN purge commands for HTML-only invalidation.

How does fingerprinting interact with HTTP/2 server push or Early Hints?

Positively. Unique URLs allow the server to push or hint multiple assets simultaneously without the browser rejecting them as duplicate-cache entries. HTTP/2 multiplexes all requests over a single connection, so there is no overhead from the long hash-bearing filenames. With 103 Early Hints, the server can emit the hashed asset URLs before the HTML fully renders, reducing waterfall depth by one round trip.

Can I use query string parameters instead of filename hashing?

Not reliably. Many CDNs, reverse proxies, and corporate firewalls normalise cache keys by stripping query strings entirely, treating main.js?v=a1b2c3d4 the same as main.js. Others preserve the query string but fragment the cache pool — every unique ?v= value becomes a separate cache entry, and a cache purge must target each individually. Filename mutation is unambiguous: the full path is the cache key on every compliant HTTP intermediary. The implementing cache keys with query parameters vs filenames guide demonstrates cache fragmentation with real CDN key examples.

What happens to old hashed assets after multiple deployments?

They remain on the CDN until they expire naturally (1 year TTL) or are explicitly purged. This is intentional. Users mid-session on an older build can continue to load old assets from CDN cache even after a new build has shipped. The CDN storage cost for 10–20 generations of hashed assets is negligible. If storage is a concern, schedule a weekly job to purge assets older than 30 days, but only after confirming no active sessions reference those filenames.

How do I handle fingerprinting in a monorepo with shared packages?

Set chunkFileNames to include a package-scope prefix: packages/[name]/[hash:12].js. Use 12-character hashes at monorepo scale. Extract shared code into explicit manualChunks entries or Webpack’s cacheGroups so that a change in one package does not rehash the shared library. Track which packages changed using nx affected or Turborepo’s task graph, and only rebuild affected packages in CI to keep hash churn manageable.

Why does my SRI hash mismatch even though the file content is identical?

The most common cause is CDN-level transformation applied after the file is uploaded. Some CDNs apply on-the-fly minification, Brotli re-encoding, or proprietary header injection that modifies the response body. The browser computes the SRI hash against the decoded body, so if the CDN re-encodes the response differently from how the SRI was computed, the check fails. Compute SRI hashes after any build-pipeline transformations and disable CDN-level body transforms on asset paths. The debugging SRI validation failures guide covers the full diagnostic sequence.