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.
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.
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.
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
Authorizationheader. Useuse-credentials, drop the wildcard, echo a specific origin, addAccess-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-Originout 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.
Related
- Subresource Integrity validation — the parent reference for how browsers verify integrity attributes
- Debugging SRI validation failures — isolating byte-level causes once CORS is ruled out
- Vary header pitfalls with fingerprinted assets — what
Vary: Origindoes to edge hit ratio - CloudFront invalidation — rolling out a changed response headers policy to already-cached objects