Multi-region APIs are great right up until the browser gets involved.

Your backend can happily serve traffic from us-east-1, eu-west-1, and ap-southeast-1, but once a frontend starts calling those endpoints cross-origin, CORS becomes one of the easiest ways to break an otherwise solid deployment. I’ve seen teams spend days debugging “random” browser failures that turned out to be region-specific CORS drift.

If you run APIs behind a CDN, global load balancer, edge worker, or region-aware gateway, you need to treat CORS as part of your routing architecture, not just a couple of headers added somewhere in Express.

Why multi-region makes CORS trickier

Single-region CORS is usually straightforward:

  • browser sends Origin
  • server replies with Access-Control-Allow-Origin
  • maybe handles a preflight with OPTIONS

Multi-region deployments add a few failure modes:

  • different regions return different CORS headers
  • a CDN caches one origin’s response and serves it to another
  • preflight hits one layer, actual request hits another
  • failover sends traffic to a backup region with stale config
  • edge logic rewrites headers inconsistently

The browser doesn’t care that your traffic manager made a smart latency decision. If the headers don’t line up exactly, the request fails.

The baseline: know what “good” looks like

A public API with no credentials can often get away with wildcard CORS. GitHub’s API is a good real-world example. api.github.com returns:

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, Warning

That works because GitHub is exposing a public API pattern, not cookie-based authenticated browser sessions. For multi-region public APIs, Access-Control-Allow-Origin: * is often the simplest and most robust choice.

The moment you need cookies or browser credentials, wildcard stops being valid. Then you need per-origin reflection or a strict allowlist.

Rule #1: centralize CORS policy

If each region implements CORS separately, they will drift.

I prefer one of these setups:

  1. Edge-owned CORS
    CDN, gateway, or edge worker handles all CORS responses consistently.

  2. Shared middleware package
    Every region uses the exact same library and config source.

  3. Config-driven policy
    Allowed origins, methods, and headers come from one distributed config store.

What I don’t like is hand-written CORS code copied into three regional services and “kept in sync” by hope.

A safe pattern for credentialed multi-region APIs

If your frontend is hosted at:

  • https://app.example.com
  • https://admin.example.com

and your API is served globally from https://api.example.com, the browser will send:

Origin: https://app.example.com

Your API should validate that origin and echo it back exactly.

Node.js / Express example

import express from "express";

const app = express();

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

function corsMiddleware(req, res, next) {
  const origin = req.headers.origin;

  if (origin && allowedOrigins.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin");
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
    res.setHeader(
      "Access-Control-Allow-Headers",
      "Content-Type, Authorization, X-Request-Id"
    );
    res.setHeader("Access-Control-Max-Age", "600");
    res.setHeader("Access-Control-Expose-Headers", "ETag, X-Request-Id");
  }

  if (req.method === "OPTIONS") {
    return res.status(204).end();
  }

  next();
}

app.use(corsMiddleware);

app.get("/profile", (req, res) => {
  res.json({ ok: true, region: process.env.AWS_REGION || "unknown" });
});

app.listen(3000);

A few things matter here:

  • Echo the exact origin, not *
  • Set Vary: Origin, or caches may serve the wrong CORS response
  • Handle OPTIONS consistently in every region
  • Keep Access-Control-Max-Age sane so browsers cache preflights

That Vary: Origin header is non-negotiable when responses differ by origin. Without it, one region or CDN POP can poison cache behavior for another frontend.

CORS at the edge: better for consistency

If you terminate requests at Cloudflare Workers, Fastly Compute, Vercel Edge, or similar, edge-owned CORS is usually cleaner.

Cloudflare Worker example

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

function buildCorsHeaders(origin) {
  const headers = new Headers();

  if (origin && allowedOrigins.has(origin)) {
    headers.set("Access-Control-Allow-Origin", origin);
    headers.set("Access-Control-Allow-Credentials", "true");
    headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
    headers.set(
      "Access-Control-Allow-Headers",
      "Content-Type, Authorization, X-Request-Id"
    );
    headers.set("Access-Control-Max-Age", "600");
    headers.set("Access-Control-Expose-Headers", "ETag, X-Request-Id");
    headers.append("Vary", "Origin");
  }

  return headers;
}

export default {
  async fetch(request, env) {
    const origin = request.headers.get("Origin");
    const corsHeaders = buildCorsHeaders(origin);

    if (request.method === "OPTIONS") {
      return new Response(null, {
        status: 204,
        headers: corsHeaders,
      });
    }

    const region = request.cf?.colo || "unknown";
    const upstream = await fetch("https://origin-api.internal" + new URL(request.url).pathname, request);

    const responseHeaders = new Headers(upstream.headers);
    corsHeaders.forEach((value, key) => responseHeaders.set(key, value));
    responseHeaders.set("X-Served-By-Region", region);

    return new Response(upstream.body, {
      status: upstream.status,
      headers: responseHeaders,
    });
  },
};

This pattern gives you one place to enforce CORS regardless of which backend region actually serves the request.

Preflight and region failover

Preflight failures are extra painful in multi-region systems because the browser hides a lot of detail and the actual app code never sees the request.

A common bad path looks like this:

  1. OPTIONS /orders goes to eu-west-1
  2. POST /orders fails over to us-east-1
  3. regions have different Access-Control-Allow-Headers
  4. browser blocks before your app gets useful telemetry

That’s why I recommend:

  • same CORS config in every region
  • same OPTIONS behavior everywhere
  • synthetic tests against each region directly
  • logging Origin, request method, and chosen region

Example NGINX config at a regional gateway

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 / {
        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, DELETE, OPTIONS" always;
            add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Request-Id" always;
            add_header Access-Control-Max-Age 600 always;
            add_header Vary Origin 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, X-Request-Id" always;
        add_header Vary Origin always;

        proxy_pass http://regional_backend;
    }
}

I still prefer app or edge code over complex NGINX conditionals, but this works if your gateway layer owns CORS.

CDN caching can break CORS in weird ways

If your API sits behind a CDN, check these carefully:

  • Is OPTIONS cached?
  • Is cache key varied by Origin?
  • Are response headers normalized or stripped?
  • Does the CDN cache a 204 preflight differently by region?

For public APIs using Access-Control-Allow-Origin: *, cache behavior is easier. For reflected origins, you must make sure the cache respects Vary: Origin.

If you want a quick way to inspect returned headers from different paths and edge locations, HeaderTest is handy for spotting whether your CDN or gateway is mutating CORS unexpectedly.

Don’t mix wildcard and credentials

I still see this in production:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Browsers reject that combination. If credentials are involved, you need an explicit origin value.

That includes:

  • cookies
  • Authorization with credentials: "include" patterns
  • session-based browser auth

For truly public read-only APIs, wildcard is fine and often preferable.

Exposed headers matter more in real APIs

A lot of teams set allow-origin and call it done, then wonder why frontend code can’t read useful metadata.

If your client needs access to response headers like:

  • ETag
  • Link
  • Retry-After
  • rate-limit headers
  • request IDs

you need Access-Control-Expose-Headers.

GitHub does this well by exposing operational headers like ETag, Link, Retry-After, and several rate-limit fields. That’s a good model for globally distributed APIs where frontend clients need pagination or throttling info.

Testing strategy for multi-region CORS

I’d test CORS in four layers:

1. Per-region direct tests

Hit each regional endpoint directly:

curl -i -X OPTIONS https://us-east-1.api.example.com/orders \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization"

2. Global endpoint tests

curl -i -X OPTIONS https://api.example.com/orders \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization"

3. Browser tests

Use real browser fetch calls because CORS is enforced by browsers, not curl.

fetch("https://api.example.com/orders", {
  method: "POST",
  credentials: "include",
  headers: {
    "Content-Type": "application/json",
    "X-Request-Id": crypto.randomUUID(),
  },
  body: JSON.stringify({ sku: "abc123" }),
});

4. Failover tests

Force traffic to secondary regions and repeat the same preflight and actual request flow.

Security boundaries

CORS is not an authentication control. It only tells browsers which cross-origin frontend code can read responses.

If an endpoint is sensitive, secure it with real auth, CSRF protections where relevant, and sane cookie settings. If you’re also reviewing the rest of your header posture, Content-Security-Policy and related controls deserve attention too, and https://csp-guide.com is a good reference for that side of things.

My default recommendations

For multi-region deployments, this is the setup I trust most:

  • put CORS policy at the edge if possible
  • use one shared allowlist source
  • reflect exact origins for credentialed requests
  • use * only for truly public APIs
  • always send Vary: Origin when origin-specific
  • keep preflight handling identical across regions
  • expose the headers your frontend actually needs
  • test failover, not just happy-path region routing

CORS bugs in multi-region systems usually aren’t browser bugs. They’re config consistency bugs wearing a browser-shaped mask. Once you treat CORS as distributed infrastructure, the failures get a lot less mysterious.