You point static assets to a CDN subdomain, reload the page, and your clean typography instantly collapses into unstyled system serif. Open dev tools, and there it is: Access to font at 'https://cdn.example.com/fonts/inter.woff2' has been blocked by CORS policy. I’ve hit this headache more times than I’d like to admit. Here’s how to configure Nginx properly, sidestep Nginx’s silent header inheritance trap, and purge your CDN edge so your fonts actually show up.

The Silent Asset Killer: Font CORS Console Errors
You set up cdn.example.com or switched WordPress to pull assets from an edge proxy. Your stylesheets and images load cleanly, but your icon pack is completely blank and your brand fonts won’t render. The console spells out the damage:
Access to font at 'https://cdn.example.com/wp-content/themes/mytheme/assets/fonts/inter.woff2' from origin 'https://example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.Why do fonts break when your JPEGs and SVGs load without issue? Blame the W3C CSS Fonts specification. Browsers are required to fetch webfonts using strict Cross-Origin Resource Sharing (CORS). Even though your HTML file lives on example.com, the actual font file is requested from cdn.example.com. Because those origins differ, modern browsers refuse to evaluate the binary unless the CDN’s origin server explicitly grants permission via response headers.
If you’re already tweaking cache rules like I covered in my walkthrough on WordPress browser cache headers and CDN edge caching on Nginx, missing CORS headers will kill font delivery right at the finish line.
Inspecting the Response with cURL
Don’t touch your config files yet. First, see what your origin server is actually kicking back. Emulate a cross-origin hit from your main domain directly against your origin:
curl -I -H "Origin: https://example.com" https://origin.example.com/wp-content/themes/mytheme/assets/fonts/inter.woff2When I ran this against my broken origin, here’s what came back:
HTTP/2 200
server: nginx/1.24.0
date: Wed, 18 Oct 2023 14:22:10 GMT
content-type: font/woff2
content-length: 18452
last-modified: Mon, 02 Oct 2023 09:12:00 GMT
etag: "651a84c0-4814"
accept-ranges: bytes
cache-control: max-age=31536000, publicNotice what isn’t there: access-control-allow-origin is completely absent. Because the origin never returned it, my CDN happily cached the headerless 200 response and replicated it across every edge PoP. Every visitor then pulled that cached response, triggering browser-level CORS blocks across the board.
Configuring Nginx to Send Font CORS Headers
To fix this, we need a targeted location block in Nginx matching our font extensions: woff, woff2, ttf, otf, and eot. Open your site’s virtual host file (usually in /etc/nginx/sites-available/ or /etc/nginx/conf.d/).
Drop this block inside your primary server { ... } directive:
# Match all primary webfont extensions
location ~* .(?:woff2?|ttf|otf|eot)$ { 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; expires 365d;
}A few details here that save you headaches down the road:
- Regex pattern:
(?:woff2?|ttf|otf|eot)covers modern WOFF2 down to legacy OpenType/TrueType files. - The
alwaysflag: Do not omit this. Withoutalways, Nginx strips custom headers on non-200 responses—including304 Not Modified. That causes bizarre bugs where a font loads on initial visit but breaks on reload. - Wildcard origin: Since webfonts are static design files, a wildcard
*is standard and supported across modern browsers. - Immutable caching: Tells both the CDN edge and the browser that these hashed binaries will never change in-place.
If you build with pre-compressed assets, pair this configuration with our guide to serve pre-compressed Brotli and Gzip assets on Nginx so you don’t waste CPU cycles re-compressing fonts on every request.
Handling Origin Whitelisting vs Wildcard
A wildcard * works for almost every static asset setup. But what if security audits forbid wildcards on your CDN, or you’re licensing commercial fonts restricted to specific subdomains? In those cases, a static * won’t fly.
Instead of hardcoding, use Nginx’s map directive to match the incoming Origin header dynamically. Add this inside your http { ... } context in /etc/nginx/nginx.conf:
http { # Define allowed origins for fonts map $http_origin $cors_origin { default ""; "~^https?://(www.)?example.com(:[0-9]+)?$" "$http_origin"; "~^https?://(staging.)?example.com(:[0-9]+)?$" "$http_origin"; "~^https?://localhost(:[0-9]+)?$" "$http_origin"; }
}Then update your font location block to serve that mapped variable:
location ~* .(?:woff2?|ttf|otf|eot)$ { if ($cors_origin != "") { add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Methods "GET, OPTIONS" always; add_header Vary "Origin" always; } add_header Cache-Control "public, max-age=31536000, immutable"; access_log off; expires 365d;
}Pay close attention to add_header Vary "Origin" always;. When you serve dynamic CORS headers instead of a wildcard, your CDN edge needs to know the cached response is tied directly to the incoming Origin. Forget the Vary header, and your edge might serve an origin header granted to staging.example.com to an end user on example.com, causing sporadic CORS rejections.
The Gotcha: Nginx add_header Inheritance Bug
This is where I’ve seen developers waste hours debugging. In Nginx, the ngx_http_headers_module has an annoying quirk: if a child block defines any add_header directive, it completely stops inheriting all add_header directives from parent blocks.
Say you set global security headers inside your server block:
server { server_name example.com; # Global headers defined at the server level add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; location ~* .(?:woff2?|ttf)$ { # Adding this single header wipes out X-Content-Type-Options # and X-Frame-Options inside this location block! add_header Access-Control-Allow-Origin "*" always; }
}The second you add add_header Access-Control-Allow-Origin "*" inside your font location block, Nginx quietly drops X-Content-Type-Options and every other parent header for all font requests.
To avoid repeating yourself across multiple location blocks, split shared headers into a snippet file.
Create /etc/nginx/snippets/cors-headers.conf:
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Range, Accept, Origin" always;
add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;
add_header Timing-Allow-Origin "*" always;Then include it directly where you need it:
location ~* .(?:woff2?|ttf|otf|eot)$ { include /etc/nginx/snippets/cors-headers.conf; add_header Cache-Control "public, max-age=31536000, immutable"; access_log off;
}Handling Preflight OPTIONS Requests
Standard CSS @font-face loads use a plain GET request. But if you load fonts via JavaScript—using the CSS Font Loading API or custom fetch() calls with custom headers—the browser dispatches an HTTP OPTIONS preflight check first.
If Nginx doesn’t handle the OPTIONS method for font paths, it returns a 405 Method Not Allowed, and the browser aborts the request entirely.
Handle preflights cleanly inside your font block like this:
location ~* .(?:woff2?|ttf|otf|eot)$ { include /etc/nginx/snippets/cors-headers.conf; 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 86400 always; add_header Content-Length 0; add_header Content-Type "text/plain; charset=UTF-8"; return 204; } add_header Cache-Control "public, max-age=31536000, immutable";
}The 204 No Content tells the browser the preflight passed without transferring a dummy body, and Access-Control-Max-Age 86400 caches that approval in the client’s browser for 24 hours.
Reloading Nginx and Purging the CDN Edge
Never reload Nginx without testing the config first. A missing semicolon will take down your entire proxy.
Run a syntax check:
sudo nginx -tOnce you see syntax is ok, reload the daemon:
sudo systemctl reload nginxTest the origin directly to make sure the header is actually present:
curl -I -H "Origin: https://example.com" https://origin.example.com/fonts/inter.woff2You should see the CORS header right there in the response:
HTTP/2 200
server: nginx/1.24.0
content-type: font/woff2
access-control-allow-origin: *
access-control-allow-methods: GET, OPTIONS
cache-control: public, max-age=31536000, immutableHere’s the step most people forget: your CDN edge nodes are still holding the old headerless response. Until you purge that cache, visitors will keep hitting CORS failures. Log into your CDN or trigger a purge via CLI:
# Example cache purge pattern for fonts
/wp-content/themes/*/assets/fonts/*
/fonts/*
*.woff2If you’re dealing with mixed dynamic endpoints on WordPress while caching static assets, check my notes on how to bypass CDN edge cache for admin-ajax.php in WordPress.
Verifying End-to-End CORS Execution
Now that Nginx is reloaded and the edge is wiped clean, test the live CDN URL with an explicit Origin header attached:
curl -s -D - -o /dev/null -H "Origin: https://example.com" https://cdn.example.com/fonts/inter.woff2Look for these two headers in the output:
access-control-allow-origin: *
cache-control: public, max-age=31536000, immutableIf those are present alongside an edge cache hit (like x-cache: HIT), you’re set. If you run into odd IP restriction or logging issues while debugging edge traffic, check out our guide on how to restore real visitor IPs in Nginx behind a CDN with ngx_http_realip_module so your access logs reflect the actual visitor rather than edge IPs.
Frequently Asked Questions
Why do images load fine from my CDN without CORS headers, but fonts fail?
Standard tags don’t enforce CORS restrictions unless you’re rendering them to an HTML5 canvas element with crossorigin="anonymous". Webfonts are held to strict cross-origin policies by the CSS specification, so browsers block font binaries outright if the origin isn’t explicitly permitted.
Can I just put “add_header Access-Control-Allow-Origin *;” inside the http block?
You can, but it breaks as soon as any child server or location block uses add_header. Because Nginx overrides parent headers instead of merging them, putting it in http leaves you vulnerable to silent drops. Keep it inside a dedicated font location block instead.
Why did the CORS error persist even after adding the Nginx rule?
Almost always a stale edge cache. Your CDN cached the original 200 response that lacked the header. Until you purge the path or the TTL expires, the CDN will keep serving that broken response. Purge the font path on your CDN and hard-refresh your browser.
Does adding CORS headers to fonts create a security vulnerability?
No. Fonts are static visual assets. Adding Access-Control-Allow-Origin "*" doesn’t expose cookies, session tokens, or private user data. It simply tells the browser it has permission to render the glyphs.
Next Steps for Edge Performance
With CORS sorted and your CDN caching fonts cleanly, preload your primary typeface in the document . Adding gets the browser fetching your font binary before external CSS stylesheets even finish parsing.

