How CORS and crossorigin Affect SRI

Permalink to "How CORS and crossorigin Affect SRI"

Part of Browser Enforcement & Security Boundaries, this page explains why a correct hash still fails on a cross-origin asset when crossorigin is missing, and exactly which response headers the asset host has to return to make the check succeed.

Quick Reference

Permalink to "Quick Reference"
Attribute form Request mode Credentials mode Server must return Cross-origin SRI
attribute absent no-cors include nothing fails — response is opaque
crossorigin (bare) cors same-origin Access-Control-Allow-Origin passes
crossorigin="" cors same-origin Access-Control-Allow-Origin passes
crossorigin="anonymous" cors same-origin Access-Control-Allow-Origin: * or the exact origin passes
crossorigin="use-credentials" cors include exact origin + Access-Control-Allow-Credentials: true passes
any invalid value cors same-origin same as anonymous passes

Digest algorithm is unaffected by any of this — SHA-384 remains the default recommendation. Preflight requests are never involved: a subresource GET with no author headers is a simple request. Timing-Allow-Origin is optional and controls Resource Timing detail, not integrity enforcement.

The mental model

Permalink to "The mental model"

The crossorigin attribute is not an SRI feature. It is the HTML CORS settings attribute, and its only job is to pick the fetch mode the browser uses for the subresource. Leave it off and a classic script or stylesheet is fetched in no-cors mode. That mode was designed for the legacy web, where a page could embed an image or a script from anywhere without the remote host’s consent, and the safety valve is that the page gets to use the bytes but never to read them. Fetch calls the result an opaque response: status 0, no readable headers, and a body that is walled off from the page’s origin.

Subresource Integrity is defined as an operation over exactly those bytes. The Fetch Standard performs the integrity comparison inside main fetch, after the response body is available, by digesting the body and matching it against the parsed integrity metadata. An opaque response has no body the algorithm may touch, so the comparison cannot be performed. The specification does not fall back to “allow it anyway” — a resource carrying integrity metadata that the browser cannot verify is treated as a failure and blocked. That is the whole bug in one sentence: the check does not fail because the hash is wrong, it fails because the browser was never allowed to look.

Adding crossorigin="anonymous" flips the request into cors mode. Now the browser sends an Origin header, the asset host is asked for consent through Access-Control-Allow-Origin, and if consent is granted the response is a basic-filtered CORS response with a readable body. Only then does the digest computation have something to work on.

Fetch mode decides whether SRI can run The upper pipeline shows a tag with integrity but no crossorigin producing a no-cors request, an opaque response with an unreadable body, and a blocked resource. The lower pipeline shows the same tag with crossorigin set producing a cors request, an accepted allow-origin header, readable bytes, and an executing script. integrity present crossorigin absent request mode no-cors opaque response body unreadable hash cannot be computed blocked integrity present crossorigin set request mode cors allow header ok bytes readable hash computed and matched executes

There are two distinct failure messages hiding behind “my SRI broke”, and telling them apart saves hours. When the attribute is missing entirely, Chromium blocks the load before CORS is even in play and logs:

Subresource Integrity: The resource 'https://cdn.example.com/lib-1.4.2.min.js' has an
integrity attribute, but the resource requires the request to be CORS enabled to check
the integrity, and it is not. The resource has been blocked because the integrity cannot
be enforced.

When the attribute is present but the host does not consent, you get an ordinary CORS rejection instead, and no integrity message at all:

Access to script at 'https://cdn.example.com/lib-1.4.2.min.js' from origin
'https://app.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin'
header is present on the requested resource.

Firefox and Safari word these differently but draw the same line: the first is a client-side markup problem, the second is a server-side header problem. Neither is a digest problem, which is why they look nothing like the mismatch output covered in the hash-mismatch guide.

Canonical example: a verified cross-origin script

Permalink to "Canonical example: a verified cross-origin script"

The markup side is one attribute. Put crossorigin="anonymous" on every tag that carries integrity and points at another origin, and keep the two attributes together forever — a template that can emit one without the other will eventually ship a page that blocks its own bundle.

<!-- app.example.com loading a pinned asset from cdn.example.com -->
<script src="https://cdn.example.com/lib-1.4.2.min.js"
        integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
        crossorigin="anonymous"></script>

<link rel="stylesheet"
      href="https://cdn.example.com/ui-3.0.1.css"
      integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIiCX+8Bd6mLoBk8UEd"
      crossorigin="anonymous">

The server side is three headers, only the first of which is mandatory. This is the minimum an asset host must return for the fetch above to produce readable bytes:

location /lib- {
    add_header Access-Control-Allow-Origin  "*"                      always;
    add_header Timing-Allow-Origin          "*"                      always;
    add_header Cache-Control                "public, max-age=31536000, immutable" always;
}

Access-Control-Allow-Origin grants read access and is what turns the opaque response into a readable one. Timing-Allow-Origin grants a second, unrelated permission: without it the Resource Timing entry for the asset reports zeroed transferSize, encodedBodySize and phase timings, so your real user monitoring cannot tell a cache hit from a cold fetch. It has no bearing on whether the integrity check passes, but you want it on any asset you intend to measure. The long Cache-Control is the usual companion for a hash-pinned URL.

CORS-enabled subresource fetch and integrity check The browser sends a GET with an Origin header to the CDN, the CDN answers with allow-origin, vary and timing-allow-origin headers, the readable body comes back, and the browser then hashes the bytes and compares the digest with the integrity metadata before executing. app.example.com page cdn.example.com GET /lib.js Origin: https://app.example.com response headers required Access-Control-Allow-Origin Vary: Origin (if echoed) Timing-Allow-Origin (optional) 200 OK, body readable to the page browser hashes the response bytes digest matches metadata script executes

Note where the digest is computed: entirely on the client, after the bytes arrive. CORS never validates content, and the allow header is not a trust signal — it is only permission to read. The trust comes from the pinned digest, which is why the two mechanisms have to be configured together rather than treated as alternatives. That division of labour is the same one described in Mapping CDN Origins to SRI Policies, where each origin gets a rule for what it is allowed to serve and how it must be pinned.

Variants

Permalink to "Variants"

Three situations change the calculus: credentialed assets, same-origin assets, and resource types whose fetch mode is fixed by the platform rather than by your markup.

crossorigin values compared A four-column matrix comparing an omitted crossorigin attribute, the anonymous value and the use-credentials value across request mode, whether cookies are sent, the response header the server must set, and whether cross-origin integrity checking works. crossorigin attribute request mode cookies sent response header the server must set cross-origin SRI works omitted no-cors yes none required no anonymous cors no allow-origin: * or origin yes use-credentials cors yes allow-origin: exact origin plus allow-credentials yes

Credentialed assets

Permalink to "Credentialed assets"

use-credentials is the right value only when the asset host needs the request’s cookies, TLS client certificate or HTTP authentication to decide what to serve — a licensed widget behind a session, or an internal bundle host that authorises by cookie. It is a strictly narrower configuration: the wildcard allow header is rejected outright for credentialed requests, so the host must echo the exact requesting origin, add Access-Control-Allow-Credentials: true, and set Vary: Origin so caches key on it. Get any of those wrong and the fetch fails CORS. Because these tags are usually injected at runtime rather than hardcoded, the property-based form is the one you will actually write:

// Load a session-gated bundle with a pinned digest.
const el = document.createElement('script');
el.src = 'https://assets.internal.example.com/reporting-2.1.0.js';
el.integrity = 'sha384-Zb1Y8u3TjfF2lF3M3JqzZ2S0BhqDp7v3g5eXKQ0S1yWXk1H4tqVfQ7q0M8ZzC1nA';
el.crossOrigin = 'use-credentials';   // sends cookies; needs an echoed allow-origin
el.onerror = () => reportBlockedAsset(el.src);
document.head.appendChild(el);

If you do not need cookies, do not reach for this value. A credentialed fetch cannot share a cache entry with an anonymous one, and it drags the origin-echo and Vary machinery into a path that a wildcard would have served for free.

Same-origin subresources

Permalink to "Same-origin subresources"

A same-origin response is never opaque, so the digest can be computed with no attribute at all. <script src="/static/app.a91f3c.js" integrity="sha384-..." crossorigin="anonymous"></script> and the same tag without the attribute both verify correctly. The attribute is harmless in the narrow sense that no CORS check is applied to a same-origin URL and the request stays a preflight-free simple GET, but it is not a no-op: it switches the credentials mode from include to same-origin, and browsers partition HTTP cache entries by credentials mode. A file requested once with the attribute and once without can be downloaded twice. The same mismatch is what produces Chromium’s warning that a preload “is found, but is not used because the request credentials mode does not match” — keep crossorigin identical on the <link rel="preload"> and on the tag that consumes it, or the preload is wasted. There is one genuine argument for adding it to first-party URLs anyway: if the path later starts redirecting to a CDN, the CORS-mode request keeps working while a no-cors one would silently start failing its integrity check.

Resource types with a fixed fetch mode

Permalink to "Resource types with a fixed fetch mode"

Some fetches are CORS-mode whether you ask or not. Module scripts are always fetched in cors mode per the HTML specification, so a cross-origin <script type="module"> with an integrity attribute verifies even without the markup attribute — though writing it is still worthwhile for the credentials mode and for readers, as covered in SRI for ES Module Imports. Web fonts loaded through @font-face are also always CORS-mode, which is why cross-origin fonts need an allow header even when nothing about them mentions integrity, and why the stylesheet that declares them is the thing you can actually pin — see Adding Integrity to Google Fonts and CSS for how that plays out with a hosted font provider.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • Omitting crossorigin is the single most common cross-origin SRI bug, and it looks like a hash bug. A tag that carries integrity but no crossorigin produces a no-cors request, an opaque response, and a blocked resource — the digest is never even computed. Teams respond by regenerating hashes, which changes nothing. Treat “integrity without crossorigin on a cross-origin URL” as a lint rule in your template layer, not as a review convention.

  • Access-Control-Allow-Origin: * and an echoed origin are not interchangeable. The wildcard is simple and cache-friendly and needs no Vary. Echoing the request’s Origin makes the response origin-specific, so it must be paired with Vary: Origin — otherwise a shared cache or a CDN edge will serve the allow header minted for one site to a different site, and that site’s fetch fails CORS at random depending on which copy it hits. If you echo, echo from an allowlist; reflecting arbitrary origins turns a static asset host into a confused deputy.

  • Duplicate or malformed allow headers fail closed. Two Access-Control-Allow-Origin headers on one response, or a single header containing a comma-separated list, are both invalid — browsers reject them rather than picking one. This happens when an origin server sets the header and a proxy adds it again. Check for it with curl -D - before assuming the origin config is wrong.

  • Edge middleware can strip or rewrite the headers you tested. A worker, image optimiser or HTML rewriter sitting in front of the origin may drop Access-Control-Allow-Origin on some routes or cache variants, which turns a working asset into a CORS failure only in production. Header handling under edge transforms is covered in SRI with Cloudflare and Fastly Edge Transforms.

  • A missing Timing-Allow-Origin is not a failure, it is a blind spot. The asset loads and verifies normally, but performance.getEntriesByType('resource') reports transferSize: 0 and collapsed phase timings for it, so you cannot tell a cache hit from a cold fetch when you are investigating why a verified bundle got slow. Add it to any third-party asset you monitor.

Verification Steps

Permalink to "Verification Steps"

1. Confirm the host consents to your origin

Permalink to "1. Confirm the host consents to your origin"
curl -sS -o /dev/null -D - \
  -H "Origin: https://app.example.com" \
  https://cdn.example.com/lib-1.4.2.min.js | grep -i -E 'access-control|timing-allow|^vary'

Expected output for a wildcard-configured host:

access-control-allow-origin: *
timing-allow-origin: *

If the host echoes instead, you should see access-control-allow-origin: https://app.example.com accompanied by vary: origin. An echoed origin with no Vary line is a bug — fix it before it produces intermittent failures.

2. Prove the response body is actually readable

Permalink to "2. Prove the response body is actually readable"

Run this in the page’s own console, from the real page origin:

const r = await fetch('https://cdn.example.com/lib-1.4.2.min.js', { mode: 'cors' });
console.log(r.type, r.status, (await r.text()).length);

Expected output is cors 200 <byte length>. A thrown TypeError: Failed to fetch means the allow header is absent or malformed; opaque 0 0 means the request was not made in CORS mode at all.

3. Recompute the digest from the same bytes

Permalink to "3. Recompute the digest from the same bytes"
curl -sS https://cdn.example.com/lib-1.4.2.min.js \
  | openssl dgst -sha384 -binary \
  | openssl base64 -A

Paste the result after sha384- and compare it with the integrity value in your markup. If they match and the page still blocks the script, the problem is the fetch mode or the headers, not the hash — which is exactly the split that Debugging SRI Hash Mismatch Errors walks through in detail.

4. Watch the failure arrive in telemetry

Permalink to "4. Watch the failure arrive in telemetry"

An SRI failure fires a securitypolicyviolation event and, with a reporting endpoint configured, produces a report you can alert on. Set that up as described in Configuring Content Security Policy with SRI, then deliberately remove the crossorigin attribute in a staging build and confirm a report lands. A pipeline that cannot see this class of breakage will discover it from users instead.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Why does my hash work locally but fail from a CDN?

Locally the asset is same-origin, so the response body is readable and the digest is computed normally. From a CDN the same tag becomes a cross-origin fetch, and without crossorigin the browser uses no-cors mode and receives an opaque response whose bytes it may not read. The hash is correct; the fetch mode is wrong.

Is Access-Control-Allow-Origin: * good enough?

Yes for crossorigin="anonymous", which is what almost every public CDN asset should use. The wildcard is rejected only when the request carries credentials, so it fails for crossorigin="use-credentials". A wildcard also needs no Vary: Origin, which makes it far friendlier to shared caches than an echoed origin.

Does adding crossorigin trigger a CORS preflight?

No. A subresource fetch is a plain GET with no author-supplied headers, which makes it a simple request under the Fetch Standard. The browser sends the Origin header on the real request and never issues an OPTIONS preflight, so there is no extra round trip to pay for. Credentialed fetches do not preflight either.

Do same-origin scripts need crossorigin to use integrity?

No. A same-origin response is not opaque, so the browser can already read the bytes and run the digest. Adding the attribute is harmless in the sense that no CORS check is applied to a same-origin URL, but it does change the request’s credentials mode, which partitions the HTTP cache entry and can cause a duplicate download.

What breaks if the CDN strips the allow header at the edge?

The fetch becomes a CORS failure rather than a hash mismatch. The browser logs a CORS policy error naming the missing Access-Control-Allow-Origin header, the script never executes, and no integrity error appears at all. Check response headers before you start recomputing digests, because the digest was never the problem.

Permalink to "Related"

Related Articles

Configuring Content Security Policy with SRI
Debugging SRI Hash Mismatch Errors
Browser Enforcement & Security Boundaries Core SRI Fundamentals & Browse…