SRI With crossorigin and CDN CORS Headers

The most common Subresource Integrity failure in production is not a bad digest — it is a correct digest attached to a resource the browser was never allowed to read, because the crossorigin attribute and the CDN’s CORS response headers do not agree.

The failure mode

You ship a build. The asset on the edge is byte-identical to the one in dist/. You have re-hashed it with openssl and the base64 digest matches the integrity attribute character for character. The browser still refuses to run the script, and the console message talks about a digest.

That combination — provably correct bytes, provably correct attribute, still blocked — has exactly one common cause: the response reached the browser in a form the integrity algorithm is not permitted to inspect. SRI does not operate on “whatever came back over the wire”. It operates on a response the fetch specification classifies as readable by the current document, and a cross-origin response only earns that classification through CORS.

Why integrity requires a CORS-readable response

When a browser loads a classic <script src> or <link rel="stylesheet"> without a crossorigin attribute, it issues the request in no-cors fetch mode. That mode is permissive on the network — the request goes out, the CDN answers, and the script executes — but the response the browser hands back to the page is opaque: status code masked, headers filtered down to a safelist, and body accessible only to the internal consumer that will parse it. Nothing else can look at those bytes, including the integrity checker.

Adding crossorigin="anonymous" switches the request into cors fetch mode. Now the browser demands an Access-Control-Allow-Origin response header that names the document’s origin (or *). If that header is present and acceptable, the response type becomes cors — the fetch spec calls this “CORS-same-origin” for integrity purposes — and the body becomes readable. Only then does the browser hash it and compare against integrity.

The rule the specification states, and the one worth memorising: a subresource with an integrity attribute must be CORS-same-origin, or the request fails. Same-origin resources satisfy that trivially and need no attribute. Cross-origin resources satisfy it only when both halves of the handshake are configured — the attribute in your HTML, and the header from your CDN.

Opaque versus CORS-readable subresource fetch Without a crossorigin attribute the browser issues a no-cors request, receives an opaque response, cannot read the body, and blocks the script. With crossorigin anonymous and an Access-Control-Allow-Origin header the response is CORS-same-origin, the digest is computed, and the script executes. Path A — integrity without crossorigin Browser fetch mode: no-cors CDN edge 200 OK no ACAO header Opaque body unreadable to the checker Blocked no digest computed Path B — crossorigin anonymous plus ACAO Browser fetch mode: cors no cookies sent CDN edge 200 OK ACAO: * CORS response body readable SHA-384 computed Executed digest matched The digest is computed over the decompressed body, but only once the response is CORS-readable. An opaque response has no readable body, so the check fails even on byte-identical files.
The same asset, the same digest, two outcomes: the CORS handshake decides whether the integrity checker ever sees the bytes.

Scenario matrix

Work out which row you are on before touching any config. The document origin below is https://www.example.test; the asset origin is https://cdn.example.test.

Scenario crossorigin attribute Response headers the CDN must emit Outcome
Same-origin asset, integrity present absent none required Digest checked, resource runs
Same-origin asset, integrity present anonymous none required Digest checked, resource runs (attribute is a no-op)
Cross-origin asset, integrity present absent any Blocked — opaque response, digest never evaluated
Cross-origin asset, integrity present anonymous Access-Control-Allow-Origin: * Digest checked, resource runs
Cross-origin asset, integrity present anonymous Access-Control-Allow-Origin: https://www.example.test + Vary: Origin Digest checked, resource runs
Cross-origin asset, integrity present anonymous no Access-Control-Allow-Origin Blocked — CORS error before the digest stage
Cross-origin asset, integrity present use-credentials Access-Control-Allow-Origin: https://www.example.test + Access-Control-Allow-Credentials: true Digest checked; cookies sent, so the edge must not cache the response publicly
Cross-origin asset, integrity present use-credentials Access-Control-Allow-Origin: * Blocked — wildcard is illegal with credentials
Cross-origin font in @font-face n/a (always cors mode) Access-Control-Allow-Origin required Font loads only with the header, SRI or not

Two rows deserve emphasis. crossorigin="use-credentials" with Access-Control-Allow-Origin: * is not a partial success — the wildcard is rejected outright whenever credentials mode is on, and the whole response is discarded. And crossorigin with no value (<script crossorigin>) is equivalent to anonymous; the empty string maps to the anonymous keyword, so both spellings are safe for public static assets.

What the CDN has to send

For public, unauthenticated, fingerprinted assets the correct header is almost always the flat wildcard:

Access-Control-Allow-Origin: *
Cache-Control: public, max-age=31536000, immutable
Cross-Origin-Resource-Policy: cross-origin

The wildcard is safe here because the assets carry no secrets, no per-user variation, and no cookie-derived content. Anyone can already fetch them by URL; allowing any origin to read the bytes gives away nothing. It is also the only value that keeps the edge cache to a single object per URL, which matters more than it first appears.

The alternative — echoing the request Origin back — is what you need when the asset is genuinely restricted to a known set of sites, or when crossorigin="use-credentials" is in play. It costs you cache efficiency, because a response whose body-affecting headers depend on the request must declare that dependency:

Access-Control-Allow-Origin: https://www.example.test
Vary: Origin
Cache-Control: public, max-age=31536000, immutable

Omitting Vary: Origin while echoing the origin is a genuine correctness bug, not a style preference: the edge will store the response it happened to fetch first and replay that exact Access-Control-Allow-Origin value to every other origin, so the second site to request the file gets a header naming the first site and its integrity check fails. Including Vary: Origin fixes correctness and fragments the cache — one stored object per distinct requesting origin, each with its own fill from origin and its own warm-up period. The wider consequences of Vary on fingerprinted URLs are covered in the Vary header pitfalls reference.

Cache fragmentation caused by Vary on Origin A wildcard Access-Control-Allow-Origin header stores one edge object per fingerprinted URL. Echoing the request origin requires Vary on Origin, which stores a separate edge object for every distinct requesting origin. Edge cache entries for one fingerprinted asset URL Allow-Origin: * no Vary on Origin needed main.a1b2c3d4.js one cached variant One object, full hit ratio Allow-Origin: echoed requires Vary on Origin variant: www.example variant: app.example variant: m.example variant: null origin Four objects, split hit ratio Vary on Origin multiplies stored objects by the number of requesting origins.
Wildcard CORS keeps one edge object per fingerprinted URL; echoing the origin forces Vary: Origin and one object per requesting site.

Provider configuration

Nginx

Serve the header unconditionally on the fingerprinted path and keep it out of the rest of the site. always is required so the header survives error responses, and Cross-Origin-Resource-Policy prevents the response being rejected by a cross-origin-isolated document:

map $request_method $cors_asset_methods {
    default "GET, HEAD, OPTIONS";
}

server {
    listen 443 ssl http2;
    server_name cdn.example.test;
    root /srv/assets;

    # Fingerprinted assets: 8 hex chars by default, 12-16 for large monorepos
    location ~* \.[0-9a-f]{8,16}\.(js|mjs|css|woff2|woff)$ {
        add_header Access-Control-Allow-Origin "*" always;
        add_header Access-Control-Allow-Methods $cors_asset_methods always;
        add_header Cross-Origin-Resource-Policy "cross-origin" always;
        add_header Cache-Control "public, max-age=31536000, immutable" always;
        add_header Timing-Allow-Origin "*" always;

        # Preflight is not triggered by <script>/<link>, but fonts behind a
        # custom header, or fetch() with an Authorization header, will send one.
        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Origin "*" always;
            add_header Access-Control-Max-Age 86400 always;
            add_header Content-Length 0;
            return 204;
        }

        try_files $uri =404;
    }
}

If you must restrict origins, replace the wildcard with a map from $http_origin to an allowed value and add Vary: Origin in the same block — never echo $http_origin straight through without the allowlist, and never echo it without the Vary.

Cloudflare

Cloudflare will not add CORS headers to proxied origin responses on its own; the header has to come from the origin or from a Transform Rule. A response-header Transform Rule scoped to the asset path is the least surprising option:

# Rules -> Transform Rules -> Modify Response Header
# Expression:
(http.request.uri.path matches "\.[0-9a-f]{8,16}\.(js|mjs|css|woff2)$")

# Actions:
Set static  Access-Control-Allow-Origin        = *
Set static  Cross-Origin-Resource-Policy       = cross-origin
Set static  Timing-Allow-Origin                = *

Pair it with a Cache Rule on the same expression that sets a one-year edge TTL and, critically, disables the content-altering features. Auto Minify, Rocket Loader, and Email Obfuscation all rewrite response bodies, and any of them turns a correct digest into a mismatch — a different failure from the CORS one, diagnosed in debugging SRI validation failures.

CloudFront

CloudFront handles this with a managed or custom response headers policy attached to the cache behaviour. The managed SimpleCORS policy emits Access-Control-Allow-Origin: *; a custom policy lets you add the immutable caching directive at the same time:

aws cloudfront create-response-headers-policy \
  --response-headers-policy-config '{
    "Name": "fingerprinted-assets-cors",
    "Comment": "Wildcard CORS plus immutable caching for hashed assets",
    "CorsConfig": {
      "AccessControlAllowOrigins": { "Quantity": 1, "Items": ["*"] },
      "AccessControlAllowHeaders": { "Quantity": 1, "Items": ["*"] },
      "AccessControlAllowMethods": { "Quantity": 2, "Items": ["GET", "HEAD"] },
      "AccessControlAllowCredentials": false,
      "OriginOverride": true
    },
    "CustomHeadersConfig": {
      "Quantity": 2,
      "Items": [
        { "Header": "Cache-Control", "Value": "public, max-age=31536000, immutable", "Override": true },
        { "Header": "Cross-Origin-Resource-Policy", "Value": "cross-origin", "Override": true }
      ]
    }
  }'

Set OriginOverride to true so the policy wins over whatever S3 returns, and make sure the S3 bucket CORS configuration is not simultaneously echoing an origin — two sources of Access-Control-Allow-Origin produce a duplicated header, which every browser treats as invalid. If you change the policy on a distribution that already has cached objects, the stored responses keep their old headers until they expire, so an invalidation is part of the rollout; see CloudFront invalidation for the mechanics and cost model.

Fonts need CORS with or without SRI

@font-face requests are made in cors mode unconditionally — the CSS Fonts specification requires it, and there is no attribute to opt out. A web font served without Access-Control-Allow-Origin from a different origin than the stylesheet simply never renders, and the console reports a font-loading failure rather than anything about integrity. Teams frequently discover this when they move fonts to a CDN hostname months before they adopt SRI, then wrongly blame SRI when they later add integrity to their scripts. Include woff2 and woff in whichever CORS rule you write, exactly as the Nginx location above does.

Telling the two errors apart

Both failures end with a blocked resource, but the console text differs, and reading it correctly saves an hour.

Digest mismatch versus CORS failure in the console Side-by-side comparison of the browser console messages produced by an integrity digest mismatch and by a missing CORS header, with the corresponding fix for each. Digest mismatch — wrong bytes CORS failure — unreadable bytes Chrome Failed to find a valid digest in the 'integrity' attribute for resource Firefox None of the sha384 hashes in the integrity attribute match the content Network panel: blocked:sri Chrome Access to script has been blocked by CORS policy: no Allow-Origin header Firefox Cross-Origin Request Blocked: the Same Origin Policy disallows reading Network panel: CORS error, 0 B body Fix the bytes or the attribute edge transform, stale hash, wrong algorithm Fix the CORS handshake missing header, wrong crossorigin value The digest is only evaluated after the response passes the CORS check.
A digest message names a hash; a CORS message names a policy. Only the first one means your bytes are wrong.

Safari is the awkward case. Its console reports both categories with variations on “Failed to load resource”, and older releases surfaced a missing CORS header on an integrity-bearing tag with wording that mentioned integrity. When a failure reproduces only in Safari, check the response headers in the Network inspector rather than trusting the message.

The tell that separates them without reading any text at all: a digest mismatch has a full response body in the Network panel — the bytes arrived, they were simply wrong. A CORS failure shows zero bytes readable and no response body preview, because the browser discarded the response before the page could touch it.

Verify from the command line

Reproduce the browser’s request exactly. The Origin header is what triggers the CDN’s CORS logic; without it, many configurations return no Access-Control-Allow-Origin at all and the check looks broken when it is fine:

# 1. Ask the way a cors-mode subresource fetch asks
curl -sS -D - -o /dev/null \
  -H "Origin: https://example.test" \
  "https://cdn.example.test/assets/main.a1b2c3d4.js" \
  | grep -iE "^(access-control-allow-origin|vary|cache-control|content-encoding):"

# Expected for a public asset:
#   access-control-allow-origin: *
#   cache-control: public, max-age=31536000, immutable

# 2. Confirm the header is not silently dropped on a HEAD from the edge
curl -H "Origin: https://example.test" -I \
  "https://cdn.example.test/assets/main.a1b2c3d4.js"

# 3. Prove the digest is right, so any remaining failure is CORS
printf "sha384-"; curl -sS --compressed \
  -H "Origin: https://example.test" \
  "https://cdn.example.test/assets/main.a1b2c3d4.js" \
  | openssl dgst -sha384 -binary | openssl base64 -A; echo

# 4. Compare against the attribute the HTML actually shipped
curl -sS "https://www.example.test/" | grep -o 'integrity="[^"]*"' | head -1

If step 3 and step 4 agree and the browser still blocks the script, step 1 is where the answer is. A missing header, a header echoed for a different origin, or a duplicated header are the three shapes this takes.

Run step 1 twice with two different Origin values when the CDN echoes rather than wildcards. Identical Access-Control-Allow-Origin responses to two different origins mean the edge cached the first answer and Vary: Origin is missing.

When to reconsider

crossorigin="anonymous" is the right default, but not universal:

  • The asset requires cookies or an Authorization header. Use use-credentials, drop the wildcard, echo a specific origin, add Access-Control-Allow-Credentials: true, and mark the response private so no shared cache stores it. You lose edge caching on that path; weigh whether the asset really needs to be authenticated.
  • The asset is same-origin and always will be. The attribute is inert, but it changes the fetch mode, which means a preflight-triggering configuration on your own server can start failing for no benefit. Adding it is still the safer habit if the asset might move to a CDN hostname later.
  • A third party controls the response headers. If you cannot get Access-Control-Allow-Origin out of a vendor’s host, you cannot use SRI on that resource. Mirror the file into your own bucket, hash it in CI, and serve it yourself — which also stops the vendor changing the bytes underneath you.
  • The origin is a private S3 bucket behind a signed URL. Signed query strings and Vary-driven fragmentation compound; prefer an unsigned, fingerprinted, immutable path for anything the browser loads as a subresource.