CORS gets weird fast once a CDN sits in front of your app.

Without a CDN, you mostly think about browser rules: Origin, preflights, Access-Control-Allow-Origin, maybe credentials. Add a CDN and now you also have cache keys, header normalization, OPTIONS caching, stale variants, and the classic bug where one origin gets cached and leaked to another.

I’ve seen teams debug this for hours because the app server was “correct” but the CDN was serving the wrong cached CORS headers.

Here’s the practical reference guide.

The core problem

CORS decisions are per-origin. CDNs want to cache aggressively across requests.

Those goals conflict unless you design for it.

If your response says:

Access-Control-Allow-Origin: https://app.example.com

then that response is only valid for that origin. If a CDN reuses it for:

Origin: https://admin.example.com

the browser will reject it, or worse, you’ll accidentally allow something you didn’t intend if your edge logic is sloppy.

The fix usually starts with this header:

Vary: Origin

That tells caches the response changes based on the Origin request header.

The three CORS patterns that matter with CDNs

1. Public API, no credentials

This is the easy one.

If the resource is truly public and you do not use cookies or HTTP auth, return:

Access-Control-Allow-Origin: *

That works well with CDNs because every origin gets the same response.

Real example from api.github.com:

access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset

That’s a good fit for a CDN: a single cacheable variant, no per-origin branching.

2. Specific allowed origins, no credentials

This is common for SPAs calling an API.

You reflect or select from an allowlist:

Access-Control-Allow-Origin: https://app.example.com
Vary: Origin

The CDN must vary on Origin, or bypass cache for these responses.

3. Credentials required

If you need cookies or authenticated cross-origin requests:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

You cannot use * with credentials. Browsers reject that combination.

This is where CDN mistakes hurt the most, because authenticated apps often have stricter origin rules and more dynamic responses.

What the CDN can break

Cached ACAO for the wrong origin

A request from https://app.example.com populates the CDN cache with:

Access-Control-Allow-Origin: https://app.example.com

Later, a request from https://partner.example.com hits the same cached object.

If Origin is not part of the cache key and the response doesn’t send Vary: Origin, you have a broken response.

Preflight responses cached incorrectly

Browsers send preflight OPTIONS requests for non-simple cross-origin requests.

Example:

OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type

If your CDN caches OPTIONS badly, one preflight variant can be reused for another method, header set, or origin.

For preflights, vary on at least:

Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers

Header stripping or normalization

Some CDN setups strip request headers they don’t consider cache-relevant. If Origin never reaches your origin server, your app can’t make the right CORS decision.

Check that the CDN forwards:

  • Origin
  • Access-Control-Request-Method
  • Access-Control-Request-Headers

Good baseline for origin-specific CORS behind a CDN

Use this for non-public APIs:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With
Access-Control-Expose-Headers: ETag, Link, Retry-After
Vary: Origin

And for preflight responses:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With
Access-Control-Max-Age: 600
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers

Express example

This is a sane pattern for an API behind a CDN.

import express from "express";

const app = express();

const allowedOrigins = new Set([
  "https://app.example.com",
  "https://admin.example.com",
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;

  if (origin && allowedOrigins.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Access-Control-Expose-Headers", "ETag, Link, Retry-After");
    res.setHeader("Vary", "Origin");
  }

  if (req.method === "OPTIONS") {
    const reqMethod = req.headers["access-control-request-method"];
    const reqHeaders = req.headers["access-control-request-headers"];

    if (origin && allowedOrigins.has(origin)) {
      res.setHeader(
        "Access-Control-Allow-Methods",
        "GET, POST, PUT, PATCH, DELETE, OPTIONS"
      );
      res.setHeader(
        "Access-Control-Allow-Headers",
        reqHeaders || "Authorization, Content-Type"
      );
      res.setHeader("Access-Control-Max-Age", "600");
      res.setHeader(
        "Vary",
        "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
      );
    }

    return res.status(204).end();
  }

  next();
});

app.get("/api/data", (req, res) => {
  res.json({ ok: true });
});

app.listen(3000);

My opinion: don’t blindly reflect any Origin. Use an allowlist. Reflection without validation is the “it worked in staging” version of CORS.

Nginx example

If you terminate behind a CDN and want Nginx to handle CORS:

map $http_origin $cors_origin {
    default "";
    "https://app.example.com" $http_origin;
    "https://admin.example.com" $http_origin;
}

server {
    listen 443 ssl;
    server_name api.example.com;

    location /api/ {
        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Origin $cors_origin always;
            add_header Access-Control-Allow-Credentials true always;
            add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
            add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With" always;
            add_header Access-Control-Max-Age 600 always;
            add_header Vary "Origin, Access-Control-Request-Method, Access-Control-Request-Headers" always;
            return 204;
        }

        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Credentials true always;
        add_header Access-Control-Expose-Headers "ETag, Link, Retry-After" always;
        add_header Vary "Origin" always;

        proxy_pass http://app_backend;
    }
}

If your CDN caches these responses, make sure its cache key respects Origin or disable caching for routes with dynamic CORS.

CDN strategy options

Option 1: Use Access-Control-Allow-Origin: * for public assets/APIs

Best for:

  • fonts
  • public JSON
  • public images
  • static files
  • anonymous APIs

Example:

Access-Control-Allow-Origin: *
Cache-Control: public, max-age=86400

This is the least painful setup.

Option 2: Vary cache by Origin

Best for:

  • allowlisted browser apps
  • credentialed APIs
  • tenant-specific frontends

Requirements:

  • forward Origin
  • include Origin in cache key or honor Vary: Origin
  • handle preflight variants correctly

Option 3: Don’t cache CORS-sensitive endpoints at the CDN

Sometimes this is the right answer.

If your API is highly dynamic, credentialed, and only lightly benefits from edge caching, it may be safer to skip CDN caching entirely for those routes.

Example:

Cache-Control: private, no-store
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

Preflight caching tips

Access-Control-Max-Age controls browser preflight caching, not necessarily CDN caching.

Example:

Access-Control-Max-Age: 600

That means the browser can reuse the preflight result for 10 minutes.

If you also cache OPTIONS at the CDN, be very deliberate. Cache key should include:

  • path
  • Origin
  • Access-Control-Request-Method
  • Access-Control-Request-Headers

If your CDN can’t express that cleanly, I’d rather not cache preflights there.

Debugging checklist

When CORS works locally but fails in production behind a CDN, I check these in order:

  1. Does the browser request include Origin?
  2. Does the CDN forward Origin to origin?
  3. Does the response include the expected Access-Control-Allow-Origin?
  4. If origin-specific, is Vary: Origin present?
  5. Are preflight responses varying on method and request headers?
  6. Is the CDN reusing a cached response across origins?
  7. Are credentials involved, making * invalid?
  8. Is some edge function rewriting headers after origin generated them?

Useful curl test:

curl -i https://api.example.com/data \
  -H 'Origin: https://app.example.com'

Preflight test:

curl -i -X OPTIONS https://api.example.com/data \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: PUT' \
  -H 'Access-Control-Request-Headers: authorization,content-type'

Then repeat with a different origin and compare the headers.

Exposed headers and CDN-backed APIs

Browsers only expose a limited set of response headers to JavaScript unless you opt in with Access-Control-Expose-Headers.

GitHub’s API is a solid real-world example. It exposes operational headers like:

access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset

That matters for CDN-backed APIs because clients often need:

  • ETag for cache validation
  • Link for pagination
  • Retry-After for rate limiting
  • custom request IDs for debugging

If frontend code needs a header, expose it explicitly.

The practical rule

If your CORS policy changes by origin, your cache behavior must also change by origin.

That’s the whole game.

For public resources, use Access-Control-Allow-Origin: * and keep it simple. For credentialed or allowlisted APIs, send Vary: Origin, validate origins strictly, and make sure your CDN cache key matches reality. If it doesn’t, CORS will fail in ways that look random and waste your afternoon.