CORS gets messy fast in microservices.

A single frontend might call an API gateway, which fans out to auth, billing, search, notifications, and a couple of legacy services nobody wants to touch. Then one team enables Access-Control-Allow-Origin: *, another requires cookies, a third forgets OPTIONS, and suddenly the browser is your loudest incident reporter.

This guide is the version I wish more teams used: practical rules, copy-paste configs, and the stuff that breaks in real systems.

The mental model for CORS in microservices

Browsers enforce CORS. Your backend doesn’t “use CORS” for server-to-server calls. CORS only matters when browser JavaScript on one origin calls another origin.

In microservices, that usually means one of these setups:

  1. Frontend calls only the API gateway
  2. Frontend calls multiple services directly
  3. Frontend calls gateway plus a few direct services like uploads or auth
  4. Third-party web apps call your public APIs

If you can choose, pick frontend -> one gateway origin. It reduces CORS policy sprawl, keeps auth consistent, and avoids every service inventing its own half-broken header set.

The core headers you actually need

For a browser request from https://app.example.com to https://api.example.com, the response may need:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-Id, ETag
Vary: Origin

For preflight OPTIONS requests, you also need:

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-Request-Id
Access-Control-Max-Age: 600
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers

Rule #1: * and credentials do not mix

This is the mistake I see most often.

If your frontend sends cookies or Authorization headers and you expect browser credentialed requests, do not use:

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

Browsers reject that combination.

Use the exact origin instead:

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

Rule #2: centralize policy when possible

In microservices, you want one of these approaches:

Option A: Enforce CORS at the API gateway

Best default for internal microservices behind a public edge.

Pros:

  • one policy to maintain
  • consistent behavior
  • easier debugging
  • fewer accidental public exposures

Cons:

  • special cases can leak through
  • direct-to-service browser traffic still needs service-level config

Option B: Every public-facing service owns its own CORS

Needed when multiple services are intentionally browser-accessible.

Pros:

  • service teams control their clients
  • flexible per-service policies

Cons:

  • drift
  • inconsistent credentials handling
  • duplicated bugs

My bias: put CORS at the edge unless you have a clear reason not to.

API gateway example: Nginx

This pattern works well when the browser only talks to the 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, PATCH, DELETE, OPTIONS" always;
            add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Request-Id" 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, X-Request-Id" always;
        add_header Vary "Origin" always;

        proxy_pass http://upstream_cluster;
    }
}

A couple of gotchas here:

  • Don’t reflect arbitrary origins blindly unless you really mean “allow any website”.
  • Use Vary: Origin or caches can serve the wrong CORS response.
  • Return 204 for preflight and keep it fast.

Express example for a service exposed to browsers

If a service is directly called from the frontend, make the policy explicit.

import express from "express";
import cors from "cors";

const app = express();

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

app.use(cors({
  origin(origin, cb) {
    if (!origin) return cb(null, false); // non-browser or same-origin tools
    if (allowlist.has(origin)) return cb(null, origin);
    return cb(new Error("Origin not allowed by CORS"));
  },
  credentials: true,
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
  allowedHeaders: ["Authorization", "Content-Type", "X-Request-Id"],
  exposedHeaders: ["ETag", "X-Request-Id"],
  maxAge: 600,
}));

app.get("/profile", (req, res) => {
  res.set("X-Request-Id", "abc-123");
  res.json({ ok: true });
});

app.listen(3000);

If you need public unauthenticated endpoints, split them by route instead of making everything permissive:

app.use("/public", cors({ origin: "*" }));
app.use("/private", cors({
  origin: "https://app.example.com",
  credentials: true
}));

Spring Boot example

For Java shops, this is usually enough:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

import java.util.List;

@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of(
            "https://app.example.com",
            "https://admin.example.com"
        ));
        config.setAllowCredentials(true);
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Request-Id"));
        config.setExposedHeaders(List.of("ETag", "X-Request-Id"));
        config.setMaxAge(600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return new CorsFilter(source);
    }
}

If Spring Security is in front, make sure it doesn’t block OPTIONS before CORS runs. That one wastes hours.

Kubernetes ingress example

If you terminate traffic at ingress, that’s a good place for CORS too.

For NGINX Ingress:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api
  annotations:
    nginx.ingress.kubernetes.io/enable-cors: "true"
    nginx.ingress.kubernetes.io/cors-allow-origin: "https://app.example.com, https://admin.example.com"
    nginx.ingress.kubernetes.io/cors-allow-credentials: "true"
    nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, PUT, PATCH, DELETE, OPTIONS"
    nginx.ingress.kubernetes.io/cors-allow-headers: "Authorization, Content-Type, X-Request-Id"
    nginx.ingress.kubernetes.io/cors-expose-headers: "ETag, X-Request-Id"
    nginx.ingress.kubernetes.io/cors-max-age: "600"
spec:
  ingressClassName: nginx
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api-gateway
                port:
                  number: 80

Check the exact behavior of your ingress controller version. Some annotations look nice in docs and behave less nicely in production.

Preflight behavior in distributed systems

Preflights happen when the browser thinks a request is “non-simple”. Typical triggers:

  • Authorization header
  • Content-Type: application/json
  • methods like PUT, PATCH, DELETE
  • custom headers

In microservices, preflights can become noisy because every browser action may generate:

  1. OPTIONS /resource
  2. actual request

That’s why Access-Control-Max-Age matters. A short cache means extra latency and extra load at the edge.

Use something reasonable:

Access-Control-Max-Age: 600

I usually avoid giant values during active development because stale CORS policy in the browser is annoying.

Exposed headers: don’t forget them

Browsers only let JavaScript read a limited set of response headers unless you expose more.

Real-world example: GitHub’s API sends:

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 reminder that Access-Control-Expose-Headers is not optional if your frontend needs pagination, rate-limit info, or request tracing.

For example:

Access-Control-Expose-Headers: ETag, Link, X-Request-Id, X-RateLimit-Remaining

Cookies across services

If your SPA uses cookies for auth across origins:

  • backend must send Access-Control-Allow-Credentials: true
  • frontend must use fetch(..., { credentials: "include" })
  • cookies need SameSite=None; Secure

Example:

fetch("https://api.example.com/session", {
  method: "GET",
  credentials: "include"
});

And on the server:

Set-Cookie: sid=abc123; Path=/; HttpOnly; Secure; SameSite=None
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

If you’re discussing cookie hardening and related headers with your team, csp-guide.com is useful for the broader browser security side beyond CORS.

Debugging checklist

When CORS fails in microservices, I check these in order:

  1. What is the browser origin?
  2. Which hop adds CORS headers? CDN, load balancer, ingress, gateway, service?
  3. Does OPTIONS reach the same policy layer?
  4. Are there duplicate CORS headers from multiple layers?
  5. Is Vary: Origin present?
  6. Are credentials involved?
  7. Are required headers exposed?

To inspect live headers quickly, I like using headertest.com alongside browser devtools. It’s faster than guessing which proxy ate your response headers.

You can also reproduce preflight with curl:

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: authorization,content-type"

And the actual request:

curl -i https://api.example.com/orders \
  -H "Origin: https://app.example.com" \
  -H "Authorization: Bearer test" \
  -H "Content-Type: application/json"

Public read-only API

Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: ETag, Link, Retry-After

No credentials. Simple and safe.

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-Request-Id
Access-Control-Expose-Headers: ETag, X-Request-Id
Vary: Origin

Multiple trusted frontends

Reflect only allowlisted origins:

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

Do not turn “multiple trusted origins” into “echo whatever Origin says”.

Anti-patterns to avoid

1. Setting CORS in every layer

Gateway adds one origin, service adds *, CDN caches the wrong variant. Now you have a ghost bug.

Pick one owner per route.

2. Allowing every origin for internal APIs

If an API is only meant for your app, say so explicitly.

3. Forgetting Vary

Dynamic origin without Vary: Origin is cache poison bait.

4. Ignoring exposed headers

Your frontend can’t read Link or X-Request-Id just because the server sent them.

5. Treating CORS as auth

CORS is not access control for attackers using curl, servers, mobile apps, or compromised browsers. It’s a browser policy. Real authorization still belongs in your app.

If your microservices setup is growing, the cleanest approach is still boring: one browser-facing gateway, one well-defined CORS policy, and as few exceptions as possible. Boring CORS is good CORS.