Using the crossorigin Attribute on script and img
Failure symptom:
Access to script at 'https://cdn.example.dev/vendor/app.4f21c8.js' from origin
'https://app.example.dev' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
Or, from the other direction, the error your monitoring dashboard has been full of for months:
Uncaught Script error.
at <anonymous>:0:0
Both come from the same attribute. The first appears when you add crossorigin and the asset host is not ready for it. The second appears when you do not add it at all.
Root Cause
crossorigin is an enumerated content attribute on script, img, link, video, audio and a few others. It selects the request mode and the credentials mode the browser will use to fetch that subresource. Omit it, and the element performs a no-CORS fetch: the resource loads and runs, but the page is not permitted to read its contents — which is why an exception thrown inside such a script is censored down to the string "Script error." with no filename and no line number, and why a cross-origin stylesheet’s cssRules throws. Add it, and the browser sends an Origin header and requires a matching Access-Control-Allow-Origin in the response, or the load fails outright.
That is the trade to understand before touching production markup: the attribute does not “enable CORS” on an element that was working. It converts a permissive, unreadable load into a strict, readable one, and a server that never had to answer for itself suddenly does. This page sits under Opaque Responses & no-cors Mode, which covers the unreadable half of that pair in detail.
Prerequisite State
- An application at
https://app.example.devloads bundles, sprites and fonts fromhttps://cdn.example.dev. - You can set response headers on the CDN or on the origin behind it.
- A client-side error reporter is installed and currently receiving
"Script error."entries with no detail. - Subresource Integrity hashes, if you use them, are generated from the exact bytes the CDN serves.
Step-by-Step Fix
Step 1 — Choose the value from the cookie question, not from habit
There are only two states. anonymous — written as a bare crossorigin, an empty value, or the word itself — sends an Origin header and no cookies. use-credentials sends the user’s cookies with the subresource request and requires a far stricter response. Pick anonymous unless the asset is genuinely session-scoped, because use-credentials also drags the request into the wildcard prohibition described in Credential Sharing & Security Boundaries in CORS.
<!-- Public bundle on a CDN: anonymous is correct -->
<script src="https://cdn.example.dev/vendor/app.4f21c8.js" crossorigin></script>
<!-- Per-user asset behind a session cookie: use-credentials, and a much stricter grant -->
<img src="https://cdn.example.dev/u/avatar/8812.png" crossorigin="use-credentials" alt="Avatar">
Two elements do not take the choice. A link rel="preload" as="font" is always fetched in CORS mode whether or not you write the attribute, because font loading is CORS-only by specification — but the attribute still selects the credentials mode, and a preload whose credentials mode differs from the eventual @font-face fetch produces a second network request and a console warning about an unused preload. Write crossorigin on the preload so the two match. A link rel="modulepreload" behaves the same way, since module scripts are CORS-only too.
Step 2 — Match the grant to the value
The two states demand different response headers. Getting this pair wrong is the single most common reason an element that used to load stops loading.
For a public CDN path, the grant is a one-liner:
location /vendor/ {
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
For the credentialed path, the wildcard is illegal and an allowlist is mandatory:
map $http_origin $avatar_cors_origin {
default "";
"https://app.example.dev" $http_origin;
"https://beta.example.dev" $http_origin;
}
location /u/avatar/ {
add_header Vary Origin always;
if ($avatar_cors_origin) {
add_header Access-Control-Allow-Origin $avatar_cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
}
}
The reflection pattern here is the safe form of dynamic matching described in Dynamic Origin Validation Patterns: compare against a fixed set first, echo only the exact value that matched.
Step 3 — Unlock real error messages
This is the highest-value reason to add the attribute to a script element. Without it, the browser censors every uncaught exception from that file before handing the event to window.onerror or an error listener, because the message and stack could leak the contents of a resource the page may not read.
<script src="https://cdn.example.dev/vendor/app.4f21c8.js" crossorigin></script>
window.addEventListener('error', (event) => {
// Without crossorigin: message === "Script error.", filename === "", lineno === 0
// With crossorigin + a matching grant: the real message, file, line and stack
reportToBackend({
message: event.message,
file: event.filename,
line: event.lineno,
stack: event.error && event.error.stack,
});
});
Nothing else changes for the user, and a reporting pipeline that was producing one useless bucket starts producing actionable ones.
The same censorship applies to stylesheets and to performance data, and both are fixed by the same attribute. A cross-origin stylesheet loaded without crossorigin throws a SecurityError from sheet.cssRules, which breaks critical-CSS tooling and any runtime that inspects its own rules. A cross-origin subresource without a grant also reports zeroes for the detailed timing fields in the Resource Timing API — domainLookupStart, connectStart, responseStart and the rest collapse to 0 — so a performance dashboard cannot tell a slow DNS lookup from a slow origin. Adding Timing-Allow-Origin alongside Access-Control-Allow-Origin restores those fields, and the attribute restores the rest.
If your error reporter is the only consumer of this change, roll it out on one bundle first. A script element that suddenly fails to load takes the whole application with it, whereas a censored error message merely makes debugging harder — so the ordering in step 2 matters more here than anywhere else on the page.
Step 4 — Pair integrity with crossorigin
Subresource Integrity requires the browser to read the response bytes in order to hash them, so an integrity attribute on a cross-origin subresource is only honoured when the fetch is a CORS-mode fetch. Without crossorigin the browser refuses to load the resource at all rather than silently skipping the check.
<script src="https://cdn.example.dev/vendor/app.4f21c8.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin></script>
Generate the digest from the exact bytes the CDN returns, after any minification and compression negotiation:
curl -s https://cdn.example.dev/vendor/app.4f21c8.js \
| openssl dgst -sha384 -binary \
| openssl base64 -A
Step 5 — Set the property before src in scripted loads
For elements you build in JavaScript, the attribute must be in place before the URL is assigned. Assigning src starts the fetch immediately, and a later crossOrigin assignment cannot change a request that is already in flight.
const script = document.createElement('script');
script.crossOrigin = 'anonymous'; // first
script.integrity = 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC';
script.src = 'https://cdn.example.dev/vendor/app.4f21c8.js'; // last
document.head.append(script);
Verification
curl the asset with an Origin header to confirm the grant matches the attribute you chose:
curl -sI https://cdn.example.dev/vendor/app.4f21c8.js \
-H 'Origin: https://app.example.dev' | grep -i 'access-control\|vary'
Expected for anonymous: access-control-allow-origin: *. Expected for use-credentials: the exact origin, plus access-control-allow-credentials: true and vary: Origin.
DevTools check: with the Network panel open, select the script row and confirm the request headers contain Origin. Then trigger a deliberate error inside the bundle and confirm the console reports a real message and file rather than "Script error.". For images, confirm a canvas readback succeeds, as described in Fixing Tainted Canvas Cross-Origin Image Errors.
Security Boundary Note
Never combine crossorigin="use-credentials" with a reflected-but-unvalidated origin on the asset host. Echoing whatever arrives in the Origin header while also sending Access-Control-Allow-Credentials: true lets any page on the internet load a signed-in user’s private avatar, receipt image, or generated document and read its pixels. Compare against a fixed allowlist, echo only the exact matched value, and always send Vary: Origin so an intermediary cache cannot hand one user’s grant to another visitor.
Common Mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
Adding crossorigin before the CDN sends a grant |
A previously working script or image now fails to load entirely, which is a visible outage rather than a silent limitation | Deploy Access-Control-Allow-Origin first, verify with curl, then ship the attribute |
Using use-credentials with Access-Control-Allow-Origin: * |
The browser rejects the response and the subresource never loads | Echo the exact validated origin and add Access-Control-Allow-Credentials: true |
Writing integrity without crossorigin on a cross-origin script |
The browser blocks the load rather than skipping the check, so the page silently loses a bundle | Always pair the two attributes on a cross-origin subresource |
Assigning script.src before script.crossOrigin |
The fetch is already running in no-CORS mode, so error messages stay censored | Set crossOrigin and integrity first, then src |
FAQ
What is the difference between crossorigin and crossorigin=“anonymous”?
There is none. The attribute is an enumerated attribute whose empty string and the value anonymous both map to the anonymous state, and any unrecognised value maps there too. Writing crossorigin, crossorigin="" and crossorigin="anonymous" produce byte-identical requests. Only use-credentials selects the other state, which is why a typo such as crossorigin="true" quietly gives you the anonymous behaviour.
Why does my script with integrity fail to load after I add crossorigin?
Adding crossorigin switches the request to CORS mode, so the CDN must now answer with Access-Control-Allow-Origin naming your page origin. If it does not, the load fails before the digest is ever computed and the console reports a CORS block rather than an integrity mismatch. Confirm the grant with curl before suspecting the hash, then regenerate the hash from the bytes the CDN actually serves.
*Does crossorigin=“use-credentials” work with Access-Control-Allow-Origin: ?
No. A credentialed request requires the response to name the exact requesting origin and to carry Access-Control-Allow-Credentials: true. A wildcard is rejected outright, so the subresource fails to load. Echo the validated origin instead and add Vary: Origin so caches keep the per-origin answers apart.