Fix CDN Font CORS Errors: Nginx Access-Control-Allow-Origin

by Fahim

You offload your static assets to a CDN subdomain, reload the page, and get greeted by a wall of bright red console errors. Your custom fonts are completely blocked, replaced by jarring system fallbacks like Times New Roman. Browsers enforce strict Cross-Origin Resource Sharing (CORS) rules on font files, and if your origin Nginx server doesn’t serve the right headers, the browser simply throws them out.

Here is how to configure Nginx to deliver proper Access-Control-Allow-Origin headers for web fonts, dodge Nginx’s notorious header inheritance quirks, and clean up poisoned CDN edge caches.

Fix CDN Font CORS Errors: Nginx Access-Control-Allow-Origin
Fix CDN Font CORS Errors: Nginx Access-Control-Allow-Origin

Why Web Fonts Break on CDNs (The Same-Origin Trap)

Browsers treat font files loaded via CSS @font-face much more strictly than standard images or scripts. Under standard CORS rules, web fonts (.woff, .woff2, .ttf, .eot, .otf) are locked down by same-origin restrictions by default.

When your site loads from https://example.com and requests a font from https://cdn.example.com/fonts/inter.woff2, the browser flags it as a cross-origin hit. If the CDN’s response doesn’t explicitly return an Access-Control-Allow-Origin header, the browser refuses to render the binary.

The issue almost always traces back to your origin Nginx server. If Nginx serves the font without CORS headers, your CDN faithfully caches that broken, headerless response and serves it to users worldwide. When dialing in your edge delivery, pairing these headers with proper immutable cache-control and cache busting in Nginx ensures updated assets never get stuck.

Inspecting the Broken Response Headers with cURL

Before touching your Nginx configs, check what your origin is actually sending back when a cross-origin request arrives. You can reproduce the browser’s request directly using curl.

Fire off this request against your origin or CDN while passing an Origin header:

curl -I -H "Origin: https://example.com" https://cdn.example.com/fonts/inter.woff2

If CORS is broken, your output will look something like this:

HTTP/2 200 server: nginx/1.24.0
date: Mon, 24 Feb 2025 14:22:10 GMT
content-type: font/woff2
content-length: 45210
last-modified: Wed, 15 Jan 2025 09:12:00 GMT
etag: "67877e80-b09a"
cache-control: public, max-age=31536000
accept-ranges: bytes

Notice what’s missing: there is no Access-Control-Allow-Origin anywhere in that response. Without it, the browser blocks the font immediately.

Adding Access-Control-Allow-Origin in Nginx

To fix this for all standard font formats, match the file extensions inside a dedicated location block within your site configuration (usually at /etc/nginx/sites-available/example.com or /etc/nginx/conf.d/default.conf).

Open your site config in an editor:

sudo nano /etc/nginx/sites-available/example.com

Add this regex block inside your server { ... } definition:

location ~* .(?:woff|woff2|ttf|eot|otf)$ { add_header Access-Control-Allow-Origin "*" always; add_header Access-Control-Allow-Methods "GET, OPTIONS" always; add_header Access-Control-Allow-Headers "*" always; add_header Cache-Control "public, max-age=31536000, immutable"; access_log off; log_not_found off; try_files $uri =404;
}

The always flag is here. Without always, Nginx only sends the header on successful 2xx and 3xx responses. If an asset 404s or throws a 403, Nginx strips the header, making debugging in the browser a nightmare. You can check the Nginx ngx_http_headers_module documentation for more on how these flags behave.

Handling Dynamic Origins with Nginx Map

A wildcard Access-Control-Allow-Origin: * is fine for public open-source fonts. But if you’re running a multi-tenant app or need to prevent third parties from hotlinking paid, licensed fonts, a wildcard is either too permissive or won’t work with credentials.

You can’t supply a comma-separated list of domains in Access-Control-Allow-Origin according to the MDN Web Docs CORS guide. Instead, evaluate the incoming $http_origin header and reflect it back only if it matches your whitelist.

Drop this map directive inside the http { ... } block of /etc/nginx/nginx.conf:

map $http_origin $cors_origin { default ""; "~^https?://(www.)?example.com$" "$http_origin"; "~^https?://(www.)?staging.example.com$" "$http_origin"; "~^https?://app.example.com$" "$http_origin";
}

Then reference that dynamic variable inside your font location block:

location ~* .(?:woff|woff2|ttf|eot|otf)$ { if ($cors_origin != "") { add_header Access-Control-Allow-Origin $cors_origin always; add_header Vary "Origin" always; } add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404;
}

Adding add_header Vary "Origin" always; tells both browsers and CDN caches that the response changes based on who is asking, preventing cross-domain cache poisoning.

The add_header Inheritance Bug

This is the number one trap devs fall into with Nginx headers. If a child block (like a nested location or if) defines even a single add_header directive, it silently wipes out all add_header directives set in parent blocks.

Here is an example of what breaks:

server { server_name cdn-origin.example.com; # This parent header will be completely ignored below! add_header Access-Control-Allow-Origin "*" always; location ~* .(?:woff|woff2)$ { # Because this add_header exists, the parent one is dropped add_header Cache-Control "public, max-age=31536000"; }
}

To avoid this headache, either keep all your headers inside the specific location block, or break them out into an included snippet:

sudo nano /etc/nginx/snippets/cors-fonts.conf

Define your shared security and caching headers in that file:

add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range" always;
add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always;
add_header Cache-Control "public, max-age=31536000, immutable";

Then pull the snippet into your font blocks or server setups, like when deploying Laravel on a VPS with CDN.

Handling OPTIONS Preflight Requests

Standard CSS @font-face GET requests don’t typically trigger preflight requests, but custom JS font loaders (like Web Font Loader or canvas renderers) will fire an HTTP OPTIONS preflight before grabbing the binary.

If Nginx tries to proxy that OPTIONS hit to a backend app that doesn’t handle it, the font download fails. You can short-circuit this by returning a clean 204 No Content right at the web server layer:

location ~* .(?:woff|woff2|ttf|eot|otf)$ { add_header Access-Control-Allow-Origin "*" always; add_header Access-Control-Allow-Methods "GET, OPTIONS" always; add_header Access-Control-Allow-Headers "*" always; if ($request_method = 'OPTIONS') { add_header Access-Control-Allow-Origin "*" always; add_header Access-Control-Allow-Methods "GET, OPTIONS" always; add_header Access-Control-Allow-Headers "*" always; add_header Access-Control-Max-Age 1728000; add_header Content-Type "text/plain; charset=utf-8"; add_header Content-Length 0; return 204; } add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404;
}

Once you’ve made the changes, test your config syntax and reload Nginx:

sudo nginx -t && sudo systemctl reload nginx

Purging and Verifying the CDN Edge Cache

Reloading Nginx fixes your origin, but your CDN edge servers are likely still caching the old, headerless font files. You have to invalidate those cached assets so the edge pulls a fresh copy from origin.

  1. Head to your CDN dashboard and purge your font paths (e.g., /fonts/* or specific URLs).
  2. If you restrict traffic to origin nodes, double-check your rules using Nginx origin shield IP restrictions.
  3. To keep massive purge operations from hammering your origin, make sure you’re protected against CDN cache stampedes with request collapsing.

Test the CDN endpoint directly using cURL to verify the fix:

curl -I -H "Origin: https://example.com" https://cdn.example.com/fonts/inter.woff2

Look for the access-control-allow-origin line in the response headers:

HTTP/2 200 server: nginx/1.24.0
date: Mon, 24 Feb 2025 14:48:02 GMT
content-type: font/woff2
content-length: 45210
last-modified: Wed, 15 Jan 2025 09:12:00 GMT
etag: "67877e80-b09a"
access-control-allow-origin: *
access-control-allow-methods: GET, OPTIONS
cache-control: public, max-age=31536000, immutable
vary: Origin

Frequently Asked Questions

Why do images load fine from my CDN while fonts fail with CORS errors?

Browsers treat standard requests as simple media embeds that don’t execute or affect DOM parsing in the same way fonts do. The CSS font specification deliberately enforces CORS checks to protect licensed font binaries and prevent cross-origin font fingerprinting.

Can I just put Access-Control-Allow-Origin inside the http block?

You can, but the moment any child server or location block uses add_header, it completely drops all headers defined in the http block. Defining them directly in the font location block or using an include snippet is far more reliable.

Should I use a wildcard * or my specific domain for fonts?

For open-source fonts like Inter or Roboto that anyone can see, a wildcard (*) is standard and makes CDN caching dead simple. If you’re hosting expensive, proprietary licensed fonts, use an Nginx map to restrict origins and pair it with Vary: Origin.

Why does my CDN still report CORS errors after restarting Nginx?

CDNs cache the entire HTTP response payload, including the headers. If the CDN grabbed the font before you fixed Nginx, it will keep serving that headerless response until you explicitly purge the cache or the file’s TTL expires.

all_in_one_marketing_tool