CORS and Cache-Control Headers for CDN Static Assets

by Fahim

Your site loads fine on the root domain, but your custom web fonts break on a subdomain and your scripts keep serving month-old cached builds after a release. The console spits out CORS errors, and users are seeing broken layouts. You’re dealing with missing CORS headers and misconfigured Cache-Control directives between your origin server and your CDN edge nodes.

We need to configure your origin web server (Nginx or Apache) to send proper CORS headers, dial in Cache-Control for solid edge caching, and keep the CDN from poisoning its own cache across different origins.

Terminal window showing cURL HTTP response headers with CORS and Cache-Control status
Terminal window showing cURL HTTP response headers with CORS and Cache-Control status

Why CDN Static Assets Break with CORS

Browsers block cross-origin requests for fonts (WOFF2, TTF), WebAssembly modules, and fetch/XHR calls by default under the Same-Origin Policy. When your static assets live on cdn.example.com while your app runs on app.example.com, the browser refuses to render the font or execute the code unless it sees an explicit Access-Control-Allow-Origin header in the response.

Things get messier once a CDN sits in the middle. If a scraper or direct user requests a font without an Origin header, your origin server might omit the CORS headers. If the CDN caches that bare response, every subsequent cross-origin visitor gets the cached version without CORS headers. The browser immediately throws a No 'Access-Control-Allow-Origin' header is present on the requested resource error.

To fix this properly, you need two things on your origin:

  • Explicit CORS headers on static asset extensions (fonts, SVGs, JSON, scripts).
  • A Vary: Origin header so your CDN stores separate cached copies for different origins.

Step 1: Test Your Current CDN Headers with cURL

Before touching your server configs, check what your CDN and origin are actually returning. Fire this cURL request in your terminal to simulate a cross-origin hit:

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

Check the response headers. If things are broken, you’ll see no access-control-allow-origin header or a missing vary: origin. Here’s what a working response looks like:

HTTP/2 200
content-type: font/woff2
content-length: 98432
cache-control: public, max-age=31536000, immutable
access-control-allow-origin: *
access-control-allow-methods: GET, OPTIONS
vary: Origin, Accept-Encoding
x-cache: HIT

If those headers are missing, we need to fix the origin. If you just set up your CDN and aren’t sure routing is clean, verify how you connect your domain to your CDN before diving into server configs.

Step 2: Configure CORS and Cache-Control in Nginx

If your origin runs Nginx, open your site config (usually /etc/nginx/sites-available/example.com or /etc/nginx/conf.d/default.conf).

We’ll match static asset extensions specifically so we don’t accidentally expose dynamic APIs, and we’ll apply aggressive 1-year caching for hashed assets.

# For web fonts, icons, and static assets
location ~* .(woff|woff2|ttf|eot|otf|svg|json)$ { # Allow any origin to read static assets add_header Access-Control-Allow-Origin "*" always; add_header Access-Control-Allow-Methods "GET, OPTIONS" always; add_header Access-Control-Allow-Headers "*" always; # Ensure CDNs cache separate variants per origin add_header Vary "Origin, Accept-Encoding" always; # Aggressive cache for immutable static assets (1 year) add_header Cache-Control "public, max-age=31536000, immutable" always; # Handle preflight requests quickly if ($request_method = 'OPTIONS') { add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Max-Age 86400; add_header Content-Type text/plain; add_header Content-Length 0; return 204; } access_log off; expires max;
}

Don’t skip the always flag on those add_header directives. Without always, Nginx silently strips your custom headers on 4xx and 5xx responses. Test your syntax and reload:

sudo nginx -t
sudo systemctl reload nginx

Step 3: Configure CORS and Cache-Control in Apache (.htaccess)

If you’re on Apache or LiteSpeed, add these directives to your root .htaccess file. Make sure both mod_headers and mod_setenvif are enabled.

 # Match font and asset file types  # Allow cross-origin asset access Header set Access-Control-Allow-Origin "*" Header set Access-Control-Allow-Methods "GET, OPTIONS" Header set Access-Control-Allow-Headers "*" # for CDN edge cache differentiation Header append Vary "Origin" # 1 Year Cache for hashed / static assets Header set Cache-Control "public, max-age=31536000, immutable" 

Running WordPress on shared hosting and hitting mixed content or SSL handshake issues while pulling assets? Walk through our guide on fixing SSL mixed content with .htaccess so your rewrite rules don’t fight your CDN headers.

Step 4: Choosing the Right Cache-Control Directives

Not all static assets should get a 1-year cache TTL. Slapping immutable on files that change under the same name will ruin your deployments, while setting cache times too low will hammer your origin.

Here’s the caching strategy I stick to for production:

  • Fingerprinted assets (e.g., bundle.a9f81b.js, inter-v12.woff2): Use public, max-age=31536000, immutable. The hash in the filename changes on every build, so browsers and CDNs can safely cache it forever without asking your origin.
  • Un-hashed static files (e.g., favicon.ico, logo.svg): Use public, max-age=86400, stale-while-revalidate=604800. This caches for 24 hours, then serves the cached version in the background while revalidating from the origin.
  • HTML files (e.g., index.html): Use no-cache or public, max-age=0, must-revalidate. Browsers must revalidate on every visit so they pick up new asset hashes immediately.

If you handle serious traffic and want to keep your origin quiet, pair these rules with an origin shield setup to collapse edge requests into single origin hits.

Step 5: Handling Preflight and Specific Allowed Origins

A wildcard * is fine for public fonts and images. But if you’re serving proprietary JSON data, WebAssembly blobs, or SVGs loaded with fetch(url, { credentials: ‘include’ }), browsers reject wildcard origins outright when credentials are attached.

Here’s how to dynamically allow specific subdomains in Nginx without breaking CDN caching:

# Define allowed origins map in the http context
map $http_origin $cors_allowed_origin { default ""; "https://app.example.com" "https://app.example.com"; "https://staging.example.com" "https://staging.example.com"; "https://example.com" "https://example.com";
} server { # inside your static files location location ~* .(json|wasm)$ { if ($cors_allowed_origin != "") { add_header Access-Control-Allow-Origin $cors_allowed_origin always; add_header Access-Control-Allow-Credentials "true" always; add_header Access-Control-Allow-Methods "GET, OPTIONS" always; add_header Access-Control-Allow-Headers "Authorization, Content-Type" always; } # Always vary by origin so the CDN does not serve the wrong origin header add_header Vary "Origin" always; add_header Cache-Control "public, max-age=86400, stale-while-revalidate=3600" always; }
}

For more details on preflight mechanics and specs, check the MDN CORS documentation and the official W3C CORS Specification.

The “CDN Cache Poisoning” Gotcha and How to Fix It

Here’s an exact bug I hit after updating Nginx: CORS errors kept triggering for half our visitors even though Nginx was configured to return Access-Control-Allow-Origin: *.

Here’s what happened:

  1. A bot requested https://cdn.example.com/fonts/inter.woff2 without sending an Origin header.
  2. The CDN edge node pulled the file from the origin. Because no Origin header was in the request, the origin didn’t send CORS headers, and the CDN cached that naked response.
  3. A real user on app.example.com requested the font. The CDN served the cached copy—missing CORS headers. The browser blocked the font instantly.

To permanently avoid this cache poisoning trap:

  • Always return Vary: Origin on every static asset response, even if the client didn’t send an Origin header.
  • Always purge your CDN cache immediately after changing CORS rules on your origin.

Trigger a full purge across your CDN edge POPs before running verification tests.

Step 6: Verifying CDN Cache Hit and Header Propagation

With the origin updated and the CDN cache cleared, run two back-to-back cURL requests. We want to check both the cache miss and the subsequent cache hit.

# First request - should fetch from origin
curl -s -D - -o /dev/null -H "Origin: https://app.example.com"  https://cdn.example.com/assets/app.1b4c9e.js # Second request - should be a CDN edge hit
curl -s -D - -o /dev/null -H "Origin: https://app.example.com"  https://cdn.example.com/assets/app.1b4c9e.js

On that second request, make sure you see:

  • Access-Control-Allow-Origin: Present with your domain or *.
  • Cache-Control: Your configured max-age policy, untouched.
  • Vary: Contains Origin.
  • A CDN hit indicator (like x-cache: HIT or cf-cache-status: HIT depending on your CDN).

For a full rundown on cache directive syntax, see the MDN Cache-Control specification.

Frequently Asked Questions

Why do fonts require CORS when images load without errors?

Browsers are much stricter with fonts and WebAssembly than regular images (JPEG, PNG). Font parsers run deep in browser rendering engines and OS subsystems. To keep that attack surface small, the CSS font loading spec enforces the Same-Origin Policy unless explicit CORS headers are returned.

Can I just put Access-Control-Allow-Origin: * on all files?

For public static assets (fonts, images, scripts, CSS), yes—wildcards are standard and safe. Just don’t put * on dynamic API endpoints or routes that handle session cookies and sensitive user data.

Why is my CDN stripping my Cache-Control headers?

Many CDNs have an “Override Origin Cache Headers” or “Edge Cache TTL” rule enabled by default. Check your distribution rules and change the caching policy to “Respect Origin Headers” so your server configs actually control edge behavior.

Next Steps

With CORS headers in place and long-term caching set up for fingerprinted assets, make sure your deployment pipeline purges only the specific assets that change during a release. If you’re managing complex DNS and origin setups, check our guide on deploying a web app with custom DNS and SSL.

all_in_one_marketing_tool