CORS for Static Assets and Fonts
You might think CORS is only about API calls. JavaScript fetching JSON from a server, that sort of thing. And then one day you deploy a beautiful new font from your CDN and it doesn't load. No error in the console that makes sense, the text just... falls back to Arial. Welcome to the world of CORS for static assets.
This chapter covers the cases where CORS shows up in places you didn't expect: fonts, images on canvas, scripts, and CDN-served resources. These are some of the most frustrating CORS issues to debug because the failure modes are silent, the symptoms are cosmetic, and the root causes are buried in specs that nobody reads for fun.
Why Fonts Are Special
The CSS Fonts specification contains a requirement that catches web developers off guard:
Font fetches that are cross-origin must use CORS. If the CORS check fails, the font is treated as a network error.
This isn't a suggestion. It isn't a browser quirk. It is a normative
requirement in the spec. Every browser implements it. When you load a font via
@font-face from a different origin, the browser performs a CORS check on the
font file — even though you're loading it via CSS, not JavaScript.
/* Your page is on https://myapp.example.com */
/* The font is on https://cdn.example.com */
@font-face {
font-family: "CustomFont";
src: url("https://cdn.example.com/fonts/custom.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
body {
font-family: "CustomFont", Arial, sans-serif;
}
For this to work, the CDN must respond with CORS headers when serving the font file:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.example.com
Content-Type: font/woff2
Content-Length: 45678
Without that Access-Control-Allow-Origin header, the browser will fetch the
font, receive the bytes, and then throw them away because the CORS check failed.
You can verify this with curl:
# Check if your CDN serves CORS headers for font files:
$ curl -sI "https://cdn.example.com/fonts/custom.woff2" \
-H "Origin: https://myapp.example.com" \
| grep -i access-control
# If this returns nothing, your fonts will not load cross-origin.
# You need:
# Access-Control-Allow-Origin: https://myapp.example.com
# or:
# Access-Control-Allow-Origin: *
Why Does the Spec Require This?
The rationale comes from the font foundry world. Font licenses often restrict
which domains can use a font. By requiring CORS, the spec ensures that a font
hosted on fonts.example.com can't be freely used by any website unless the
server explicitly allows it via CORS headers. Whether this actually provides
meaningful protection is debatable (anyone can download the font file directly),
but that's the reasoning, and it's baked into every browser.
The "FOUT Then Nothing" Problem
Here's the typical debugging experience:
- You add a custom font from your CDN.
- In development (same origin), it works perfectly.
- You deploy to production (app and CDN on different origins).
- The page loads. For a brief moment, you see the fallback font (Flash of Unstyled Text, or FOUT). Then... it just stays as the fallback. The custom font never loads.
- You open DevTools. The Network tab shows the font file was requested and returned 200 OK. The response has bytes. It looks fine.
- But then you look at the Console tab and see something like:
Access to font at 'https://cdn.example.com/fonts/custom.woff2' from origin
'https://myapp.example.com' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
The insidious thing is that this error is easy to miss. It doesn't crash anything. The page still renders. The text is still readable. It's just in the wrong font. In a code review or a quick glance at the deployed site, you might not even notice for days.
Debugging Font CORS Issues
In Chrome DevTools:
- Open the Network tab.
- Filter by Font (there's a filter button for resource types).
- Look at each font request. Click on it.
- Check the Response Headers section for
Access-Control-Allow-Origin. - If it's missing, that's your problem.
- Also check the Console tab for the explicit CORS error message.
In Firefox, the error message is similarly helpful:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the
remote resource at https://cdn.example.com/fonts/custom.woff2. (Reason:
CORS header 'Access-Control-Allow-Origin' missing).
Images on Canvas: The Tainting Problem
Here's another place CORS shows up unexpectedly. You can display a cross-origin
image on a page with a simple <img> tag — no CORS required:
<!-- This works fine. No CORS needed to display the image. -->
<img src="https://other-site.com/photo.jpg" alt="A photo">
But the moment you draw that image onto a <canvas> and try to read the pixel
data, CORS enters the picture:
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.src = "https://other-site.com/photo.jpg";
img.onload = () => {
ctx.drawImage(img, 0, 0);
// This throws a SecurityError:
try {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
} catch (e) {
console.error(e);
// SecurityError: The operation is insecure.
// The canvas has been "tainted" by the cross-origin image.
}
// This also fails:
try {
const dataUrl = canvas.toDataURL();
} catch (e) {
console.error(e);
// SecurityError: Tainted canvases may not be exported.
}
};
Drawing a cross-origin image onto a canvas taints the canvas. A tainted canvas can still be displayed, but you can't extract pixel data from it. This prevents a malicious page from using canvas as a way to read the contents of cross-origin images (which could be sensitive — think profile photos, medical images, CAPTCHAs).
Fixing It: The crossorigin Attribute
To load a cross-origin image without tainting the canvas, you need two things:
- The
crossoriginattribute on the<img>element. - The server must respond with appropriate CORS headers.
const img = new Image();
img.crossOrigin = "anonymous"; // This triggers a CORS request
img.src = "https://other-site.com/photo.jpg";
img.onload = () => {
ctx.drawImage(img, 0, 0);
// Now this works (if the server sent CORS headers):
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL();
};
The server must respond with:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.example.com
Content-Type: image/jpeg
Without the server's CORS headers, adding crossorigin actually makes things
worse: the image won't load at all, instead of loading but tainting the
canvas.
The crossorigin Attribute on HTML Elements
Several HTML elements support the crossorigin attribute:
| Element | Attribute syntax | Effect |
|---|---|---|
<img> | <img crossorigin="anonymous"> | CORS request for image data |
<script> | <script crossorigin="anonymous"> | Exposes full error details |
<link> | <link crossorigin="anonymous"> | CORS request for stylesheet/font |
<video> | <video crossorigin="anonymous"> | CORS request for video data |
<audio> | <audio crossorigin="anonymous"> | CORS request for audio data |
Two Modes: anonymous vs use-credentials
The crossorigin attribute accepts two values:
anonymous (or just the bare attribute crossorigin):
- Sends a CORS request without credentials (no cookies, no HTTP auth).
- The server can respond with
Access-Control-Allow-Origin: *. - This is what you almost always want for public static assets.
<img crossorigin="anonymous" src="https://cdn.example.com/image.png">
<script crossorigin="anonymous" src="https://cdn.example.com/app.js"></script>
<link crossorigin="anonymous" rel="stylesheet" href="https://cdn.example.com/styles.css">
use-credentials:
- Sends a CORS request with credentials (cookies, HTTP auth).
- The server must respond with the specific origin (not
*) andAccess-Control-Allow-Credentials: true. - Use this only when the server requires authentication to serve the resource.
<img crossorigin="use-credentials" src="https://cdn.example.com/private/photo.jpg">
Server response required:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.example.com
Access-Control-Allow-Credentials: true
Content-Type: image/jpeg
The Script Error Case
The crossorigin attribute on <script> deserves special attention. Without it,
if a cross-origin script throws an error, your window.onerror handler gets
almost no information:
window.onerror = function(message, source, lineno, colno, error) {
console.log(message); // "Script error."
console.log(source); // ""
console.log(lineno); // 0
console.log(colno); // 0
console.log(error); // null
};
"Script error." That's it. No stack trace, no source file, no line number. The browser deliberately hides this information for cross-origin scripts to prevent information leakage.
With the crossorigin attribute (and proper CORS headers from the server):
<script crossorigin="anonymous" src="https://cdn.example.com/app.js"></script>
window.onerror = function(message, source, lineno, colno, error) {
console.log(message); // "Uncaught TypeError: Cannot read property 'foo' of null"
console.log(source); // "https://cdn.example.com/app.js"
console.log(lineno); // 42
console.log(colno); // 15
console.log(error); // TypeError object with full stack trace
};
This is critical for error monitoring services like Sentry, Datadog, or
Bugsnag. If you're loading your JavaScript from a CDN and not using the
crossorigin attribute, your error reports will be full of useless "Script
error." entries. I have seen entire Sentry dashboards that were 90% "Script
error." because someone forgot this attribute.
CDN Configuration for Fonts and Static Assets
Let's get practical. Here's how to configure CORS for static assets on common CDN and hosting platforms.
Nginx
# In your server block or location block for static assets:
location ~* \.(woff2?|ttf|otf|eot)$ {
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET";
add_header Access-Control-Max-Age "86400";
# Also set proper content types
types {
font/woff2 woff2;
font/woff woff;
font/ttf ttf;
font/otf otf;
application/vnd.ms-fontobject eot;
}
}
# For all static assets (JS, CSS, images, fonts):
location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|otf|eot)$ {
add_header Access-Control-Allow-Origin "*";
add_header Vary "Origin";
}
Apache (.htaccess)
# Enable CORS for font files
<FilesMatch "\.(woff2?|ttf|otf|eot)$">
Header set Access-Control-Allow-Origin "*"
Header set Access-Control-Allow-Methods "GET"
Header set Access-Control-Max-Age "86400"
</FilesMatch>
# Or for all static assets:
<FilesMatch "\.(js|css|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|eot)$">
Header set Access-Control-Allow-Origin "*"
Header set Vary "Origin"
</FilesMatch>
AWS S3 Bucket CORS Configuration
S3 uses a JSON-based CORS configuration:
[
{
"AllowedOrigins": ["https://myapp.example.com"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": [],
"MaxAgeSeconds": 86400
}
]
Or if you want to allow all origins (typical for public assets):
[
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": [],
"MaxAgeSeconds": 86400
}
]
Apply it via the AWS CLI:
aws s3api put-bucket-cors --bucket my-assets-bucket --cors-configuration '{
"CORSRules": [
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 86400
}
]
}'
Or verify the current configuration:
$ aws s3api get-bucket-cors --bucket my-assets-bucket
{
"CORSRules": [
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 86400
}
]
}
AWS CloudFront with S3
This is where things get tricky. S3 might have the right CORS configuration, but CloudFront can strip or cache headers incorrectly. You need to:
-
Configure S3 CORS (as shown above).
-
Configure CloudFront to forward the
Originheader to S3:- In the CloudFront distribution's behavior settings, add
Originto the cache key (or use a cache policy that includes it). - Under "Origin request policy," use a policy that forwards the
Originheader.
- In the CloudFront distribution's behavior settings, add
-
Configure the CloudFront response headers policy to pass through CORS headers:
# Create a response headers policy that includes CORS headers:
aws cloudfront create-response-headers-policy --response-headers-policy-config '{
"Name": "CORS-Static-Assets",
"Comment": "CORS headers for static assets",
"CorsConfig": {
"AccessControlAllowOrigins": {
"Quantity": 1,
"Items": ["*"]
},
"AccessControlAllowHeaders": {
"Quantity": 1,
"Items": ["*"]
},
"AccessControlAllowMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"]
},
"AccessControlMaxAgeSec": 86400,
"OriginOverride": true
}
}'
The CDN Caching Problem (This One's Nasty)
This is one of the most common and most confusing CORS issues in production. It goes like this:
-
Browser A makes a same-origin request to your CDN for a font file. The request has no
Originheader (because it's same-origin). The CDN forwards to the origin server, which responds without CORS headers (because there was noOriginheader in the request, and many servers only add CORS headers when they see anOriginheader). -
The CDN caches this response — the one without CORS headers.
-
Browser B makes a cross-origin request to the same CDN for the same font file. The request includes an
Originheader. But the CDN serves the cached response from step 1 — without CORS headers. -
Browser B's CORS check fails. The font doesn't load.
The maddening part: this is intermittent. It depends on which request hits the CDN first. If a cross-origin request populates the cache first, same-origin requests work fine (they don't need CORS headers). But if a same-origin request populates the cache first, all subsequent cross-origin requests fail until the cache expires.
The Fix: Vary: Origin
The solution is the Vary response header:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.example.com
Content-Type: font/woff2
Vary: Origin
Vary: Origin tells the CDN (and any other cache): "The response to this URL
varies depending on the Origin request header. Cache different versions for
different Origin values."
With Vary: Origin:
- A request with no
Originheader gets cached separately. - A request with
Origin: https://myapp.example.comgets cached separately. - A request with
Origin: https://other-app.example.comgets cached separately.
Every server that serves resources cross-origin and sits behind a CDN should
include Vary: Origin in its responses. I cannot stress this enough. Omitting
it is one of the most common causes of intermittent, hard-to-reproduce CORS
failures in production.
# Verify that Vary: Origin is present:
$ curl -sI "https://cdn.example.com/fonts/custom.woff2" \
-H "Origin: https://myapp.example.com" \
| grep -i vary
Vary: Origin
When You Use Access-Control-Allow-Origin: *
If you always respond with Access-Control-Allow-Origin: * regardless of the
request's Origin header, you technically don't need Vary: Origin because the
response is the same for all origins. However, I still recommend including it.
It costs nothing and protects you if you later switch to origin-specific
responses.
Some CDN providers (like CloudFront) handle Vary: Origin automatically when you
configure CORS via their response headers policies. Others require you to
configure it explicitly. Test your setup:
# Request with no Origin header:
$ curl -sI "https://cdn.example.com/fonts/custom.woff2" \
| grep -iE "(access-control|vary)"
Access-Control-Allow-Origin: *
Vary: Origin
# Request with an Origin header:
$ curl -sI "https://cdn.example.com/fonts/custom.woff2" \
-H "Origin: https://myapp.example.com" \
| grep -iE "(access-control|vary)"
Access-Control-Allow-Origin: *
Vary: Origin
Both should include the CORS headers. If the first request (no Origin) doesn't
include them but the second does, you'll hit the caching problem described above.
Subresource Integrity (SRI) and CORS
Subresource Integrity lets you verify that a file loaded from a CDN hasn't been tampered with. You include a hash of the expected content:
<script
src="https://cdn.example.com/library.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w"
crossorigin="anonymous">
</script>
Notice that crossorigin="anonymous" is required when using SRI with
cross-origin resources. Here's why:
- SRI needs to read the resource's bytes to compute a hash and compare it to the
integrityattribute. - Reading the bytes of a cross-origin resource requires CORS.
- Without the
crossoriginattribute, the browser loads the script in "no-cors" mode, and the response is opaque — the bytes can't be hashed.
If you use integrity without crossorigin on a cross-origin script, the
browser will refuse to execute the script entirely. The error in Chrome looks
like:
Failed to find a valid digest in the 'integrity' attribute for resource
'https://cdn.example.com/library.js' with computed SHA-384 integrity
'...'. The resource has been blocked.
This also means the CDN serving the script must include CORS headers:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Content-Type: application/javascript
SRI and CORS: The Complete Chain
For SRI to work with cross-origin resources, you need all of these:
- The
integrityattribute with the correct hash. - The
crossorigin="anonymous"attribute on the element. - The server responding with
Access-Control-Allow-Origin(usually*for public CDN resources).
<!-- All three pieces: src, integrity, crossorigin -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7icsOifkntWB..."
crossorigin="anonymous">
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"
integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKr..."
crossorigin="anonymous">
</script>
What If the CDN Is Compromised?
SRI protects against CDN compromise: if an attacker modifies the file on the CDN, the hash won't match and the browser will refuse to load it. This is its primary purpose. But SRI only works if:
- CORS is properly configured (so the browser can read and hash the bytes).
- You've pinned the correct hash (generated from a known-good version).
- The browser supports SRI (all modern browsers do).
Generate SRI hashes yourself:
# Download the file and compute its hash:
$ curl -s https://cdn.example.com/library.js | \
openssl dgst -sha384 -binary | \
openssl base64 -A
oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w
# Use it in your HTML:
# integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w"
Or use the srihash.org service, which computes the hash and gives you the
complete HTML attribute.
Putting It All Together: A Checklist
When serving static assets cross-origin, here's what you need:
-
Font files: Server sends
Access-Control-Allow-Originheader. -
Images used on canvas:
<img crossorigin="anonymous">and server sends CORS headers. -
Scripts with error monitoring:
<script crossorigin="anonymous">and server sends CORS headers. -
Scripts with SRI:
<script crossorigin="anonymous" integrity="...">and server sends CORS headers. -
CDN caching: Server sends
Vary: Originto prevent the CDN from serving cached responses without CORS headers. -
CloudFront: Origin request policy forwards the
Originheader to the origin server.
Test everything with curl before and after deployment:
# The ultimate CORS check for static assets:
for file in \
"/fonts/custom.woff2" \
"/js/app.js" \
"/css/styles.css" \
"/images/logo.png"
do
echo "--- $file ---"
curl -sI "https://cdn.example.com${file}" \
-H "Origin: https://myapp.example.com" \
| grep -iE "(access-control|vary|content-type)"
echo
done
If any resource is missing Access-Control-Allow-Origin, fix it before your
users see broken fonts, blank canvases, or "Script error." in their error logs.
Those are the kinds of bugs that don't trigger alerts but slowly erode the
quality of your application while everyone wonders why the Sentry dashboard is
full of noise and the marketing site looks slightly off in certain browsers on
certain days depending on which edge node their CDN request landed on.
Not that I'm speaking from experience or anything.