Serving Fonts from a CDN with Correct CORS

This guide is part of the Font CDN & Edge Delivery topic, itself part of the Font Loading & Delivery Strategies area.

Move a font file to a different origin — a dedicated static-assets CDN, a fonts.example.com subdomain, or a third-party host — and the browser stops treating it as a same-origin resource. Fonts are fetched under CORS rules regardless of @font-face never mentioning CORS explicitly: the spec mandates an anonymous, credential-less CORS request for every font fetch, cross-origin or not. If the response doesn't carry Access-Control-Allow-Origin, the browser discards the font bytes as an opaque, unusable response and silently falls back to the next source in the src list, or to the fallback font if there is none. The failure mode is confusing precisely because it produces no console error in older browser versions and no failed network request — DevTools shows a 200 OK with a full response body, yet the glyph never paints.

Prerequisites

  • A font hosted on an origin different from the page (a CDN subdomain, an S3/R2 bucket behind a custom domain, or an npm-published package served from someone else's edge).
  • Control over the response headers that origin serves, or knowledge of your CDN's CORS configuration surface (Cloudflare, Fastly, CloudFront behaviors, Netlify _headers, Vercel headers()).
  • An existing @font-face block and, ideally, a <link rel="preload" as="font"> tag for the same file.

Implementation

The fix has two halves that must match: the CDN must send an Access-Control-Allow-Origin header, and the browser-side reference — both the preload hint and the @font-face fetch itself — must request the resource in CORS mode. For fonts, @font-face always fetches in CORS mode; you don't add a crossorigin attribute to @font-face because there's no such attribute in CSS. But <link rel="preload"> is a separate fetch with its own mode, and it defaults to no-cors unless you tell it otherwise. That mismatch — preloading without crossorigin, then loading via @font-face with implicit CORS — is the single most common cause of fonts fetched twice.

Here's a minimal, correct CDN response for a WOFF2 file served from https://static.example.com:

HTTP/2 200
content-type: font/woff2
access-control-allow-origin: https://www.example.com
cache-control: public, max-age=31536000, immutable
timing-allow-origin: https://www.example.com

And the matching page markup:

<link
  rel="preload"
  as="font"
  type="font/woff2"
  href="https://static.example.com/inter-var.woff2"
  crossorigin
/>
@font-face {
  font-family: "Inter";
  src: url("https://static.example.com/inter-var.woff2") format("woff2");
  font-weight: 100 900;
  font-display: swap;
}

Line by line: access-control-allow-origin names the exact origin permitted to read the response (or * if the font has no auth and is meant to be public — the common case for font CDNs). content-type: font/woff2 matters less for CORS itself but avoids MIME-sniffing rejections in strict configurations. crossorigin on the <link> (bare, which is shorthand for crossorigin="anonymous") forces the preload fetch into the same anonymous-CORS mode the later @font-face fetch will use, so the browser can match the two requests in its cache and avoid a duplicate download. timing-allow-origin is optional but valuable: without it, cross-origin Resource Timing entries for the font are heavily redacted (transferSize reads 0, most timing marks read 0), which breaks the field measurement techniques described in tracking font load time with PerformanceObserver.

Using * versus an explicit origin is a real trade-off. Access-Control-Allow-Origin: * is simplest and is exactly what Google Fonts and most public font CDNs send, because a font file carries no user-specific data and there's nothing to leak. Reflecting a specific origin is only necessary if the CDN also serves credentialed requests (cookies) for other asset types on the same path pattern — fonts themselves should never need Access-Control-Allow-Credentials, and you should actively avoid sending it for font responses, since credentials: true combined with * is invalid per the Fetch spec and combined with a reflected origin adds needless complexity for zero benefit on a static asset.

Configuring CORS at the Edge

Most teams don't hand-write these headers per request; they set them once in the CDN's edge configuration. A Cloudflare Worker or Transform Rule:

export default {
  async fetch(request) {
    const response = await fetch(request);
    const headers = new Headers(response.headers);
    headers.set("Access-Control-Allow-Origin", "https://www.example.com");
    headers.set("Timing-Allow-Origin", "https://www.example.com");
    headers.set("Cache-Control", "public, max-age=31536000, immutable");
    headers.set("Vary", "Origin");
    return new Response(response.body, { ...response, headers });
  },
};

The Vary: Origin header matters once you serve more than one allowed origin (staging, production, a marketing microsite) — it tells shared caches that the response body is the same but the Access-Control-Allow-Origin value depends on the requester's origin, preventing a cache from serving https://staging.example.com's permission header to https://www.example.com. If you always allow *, Vary: Origin isn't necessary since the header never changes.

For a CloudFront distribution, the equivalent is a Response Headers Policy attached to the font-serving behavior, setting Access-Control-Allow-Origin and enabling Access-Control-Allow-Methods: GET — CloudFront's built-in CORS policies handle the preflight-adjacent bookkeeping so you don't hand-roll OPTIONS handling. Font GET requests are "simple" CORS requests in the Fetch spec sense (safelisted method, no custom headers), so no preflight OPTIONS round-trip actually occurs for a standard @font-face/preload combination — the browser sends the GET directly with an Origin header and inspects the response's Access-Control-Allow-Origin value. Preflight only appears if you've added custom request headers via fetch() yourself, which is unusual for fonts.

CORS Header vs Preload Outcome Matrix comparing same-origin and cross-origin font scenarios against whether the CORS header and crossorigin attribute are present, and the resulting outcome. CORS Header vs Preload Outcome ACAO header crossorigin attr Result Same-origin font Not required Optional Renders Cross-origin, matched Present Set Renders, 1 fetch Cross-origin, no attr Present Missing Renders, 2 fetches Cross-origin, no head… Missing Set Blocked, fallback
How the ACAO header and the crossorigin attribute combine to decide the render result.

A Defensive Variant: Multiple Allowed Origins

A single font CDN commonly serves several page origins — production, staging, a preview-deploy wildcard, a documentation subdomain. Access-Control-Allow-Origin accepts exactly one value (or *), never a comma-separated list, so multi-origin support requires echoing back the requester's Origin header after validating it against an allowlist:

const ALLOWED = new Set([
  "https://www.example.com",
  "https://staging.example.com",
  "https://docs.example.com",
]);

export default {
  async fetch(request) {
    const origin = request.headers.get("Origin");
    const response = await fetch(request);
    const headers = new Headers(response.headers);
    if (origin && ALLOWED.has(origin)) {
      headers.set("Access-Control-Allow-Origin", origin);
      headers.set("Vary", "Origin");
    }
    headers.set("Cache-Control", "public, max-age=31536000, immutable");
    return new Response(response.body, { ...response, headers });
  },
};

If Origin doesn't match the allowlist, the code above simply omits Access-Control-Allow-Origin, and the browser will reject the font for that caller — a safe default. Resist the temptation to fall back to reflecting any Origin unconditionally; that reintroduces the same "allow everything" posture as * but without *'s simplicity, and for a genuinely public font asset you're usually better off just using * outright and dropping the allowlist entirely, per the Font CDN & Edge Delivery guidance on treating fonts as public, cacheable assets rather than access-controlled ones. Reserve the allowlist pattern for CDNs that also serve non-public assets from the same edge worker and need per-path logic.

Verification

Open DevTools → Network, reload, and click the font request. Three things confirm CORS is correct:

  1. Status is 200, Type reads font, and the Response Headers panel shows access-control-allow-origin present and matching (or *).
  2. In the Console, there is no Access to font at '...' from origin '...' has been blocked by CORS policy error. This exact message, with blocked by CORS policy, is the unambiguous signal — search for it before assuming any other cause.
  3. In the Elements panel or a quick document.fonts.check('16px "Inter"') in the console, the font actually renders — a true result after document.fonts.ready resolves confirms the FontFace entered the loaded state rather than silently falling through to the fallback.

To catch a missing crossorigin attribute specifically (the double-fetch case, distinct from an outright CORS block), filter the Network panel to Font and count requests for the same URL. Two entries for one font file, one of them (disk cache) or (memory cache) and one a fresh network transfer, means the preload's fetch mode didn't match the @font-face fetch mode — add crossorigin to the <link rel="preload">.

curl -sI https://static.example.com/inter-var.woff2 -H "Origin: https://www.example.com" | grep -i access-control

Run that from the command line to confirm the header independent of the browser cache — it strips out any ambiguity from cached preflight results or service-worker interception.

Verifying CDN Font CORS Five-step verification workflow: open the Network panel, filter to Font, check the response status and headers, check the Console for a CORS block message, then confirm the font loaded with document.fonts.check. Verifying CDN Font CORS 1 Open Network panel reload page 2 Filter to Font one entry per URL 3 Check ACAO header Response Headers 4 Scan Console no CORS block msg 5 document.fonts.check() confirms loaded
The DevTools and command-line checks that confirm CORS is configured correctly.

Common Pitfalls

  • Preloading without crossorigin while @font-face uses implicit CORS. The preload fetch (no-cors by default) and the render fetch (always CORS for fonts) are treated as different cache entries, so the browser downloads the font twice — visible in Network as two full-size transfers for one URL. Fix: always pair <link rel="preload" as="font"> with a bare crossorigin attribute.
  • Assuming same-origin fonts need crossorigin too. They don't — same-origin @font-face and preload requests don't require CORS headers at all. Adding crossorigin to a same-origin preload is harmless, but chasing CORS headers on a same-origin CDN path (e.g., a reverse-proxied /fonts/ path under the same domain) is wasted effort; the actual bug is usually elsewhere (wrong path, wrong as value).
  • Sending Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true. This combination is invalid per the Fetch spec and browsers reject the response outright for credentialed requests. Fonts should never need credentials — remove the credentials header rather than trying to pair it with a wildcard.
  • A CDN or WAF stripping Access-Control-Allow-Origin on cached responses. Some edge caches only add CORS headers on cache misses (the first, origin-fetched response) and serve stale cached copies without them afterward. Verify with the curl -I check above from a cold cache and again from a warm one — a cache configuration that varies its own headers by hit/miss will show a difference.
  • Font-loading libraries that fetch fonts via fetch() for base64-inlining. A JS-driven fetch() call is a real CORS-checked request distinct from @font-face's built-in mechanism, and it will throw a TypeError: Failed to fetch in the console if Access-Control-Allow-Origin is missing, rather than silently falling back — check for this explicit failure if you're inlining fonts as data URIs at build or run time.
  • Forgetting Timing-Allow-Origin and then getting zeroed-out Resource Timing entries. Everything renders correctly, but PerformanceResourceTiming.transferSize, encodedBodySize, and most timing marks read 0 for the cross-origin font, breaking RUM measurement silently — the font works, but your telemetry pipeline reports garbage.
Wasted Bytes from Missing crossorigin Meter showing 32KB of duplicated transfer against a zero-waste target for a 64KB variable font file. Wasted Bytes from Missing crossorigin 0KB 64KB 32KB duplicated target 0KB waste
A 32KB variable font fetched twice when the preload's crossorigin attribute doesn't match the font fetch mode.

Frequently Asked Questions

Does @font-face need a crossorigin attribute like <img> or <script> does? No — there's no crossorigin attribute in CSS. @font-face fonts are always fetched in anonymous CORS mode by the browser's font-loading machinery, regardless of any attribute, which is exactly why the response must carry Access-Control-Allow-Origin whenever the font is cross-origin. The crossorigin attribute only exists on the HTML <link rel="preload"> tag, where it needs to be set explicitly to match that implicit CORS mode.

Will a missing CORS header show up as a failed network request? Not always. In most current browsers you'll see a blocked by CORS policy message in the console and the font simply won't apply — the request itself often still returns 200 in the Network panel because the bytes did arrive; the browser only discards them at the point of use. This is why checking the Console tab, not just the Network panel's status column, is essential when diagnosing a font that "won't show up."

Do I need CORS if I'm using font-display: optional or a fallback stack? Yes — CORS is independent of font-display. A CORS failure prevents the FontFace from ever reaching the loaded state at all, so with font-display: optional the browser behaves exactly as if the font never arrived: it keeps the fallback permanently, regardless of network speed. Fixing CORS is a prerequisite for font-display tuning to have any effect.

Can I use a wildcard Access-Control-Allow-Origin: * in production? Yes, and for genuinely public font assets it's the recommended default — the same choice Google Fonts, Fontsource's CDN, and most font-hosting services make, since a font file has no per-user data to protect. Restrict to an explicit origin allowlist only when the same edge path also serves non-public, credentialed resources and you need consistent per-path policy.

Related