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:
- Frontend calls only the API gateway
- Frontend calls multiple services directly
- Frontend calls gateway plus a few direct services like uploads or auth
- 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: Originor caches can serve the wrong CORS response. - Return
204for 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:
AuthorizationheaderContent-Type: application/json- methods like
PUT,PATCH,DELETE - custom headers
In microservices, preflights can become noisy because every browser action may generate:
OPTIONS /resource- 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:
- What is the browser origin?
- Which hop adds CORS headers? CDN, load balancer, ingress, gateway, service?
- Does
OPTIONSreach the same policy layer? - Are there duplicate CORS headers from multiple layers?
- Is
Vary: Originpresent? - Are credentials involved?
- 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"
Recommended patterns
Public read-only API
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: ETag, Link, Retry-After
No credentials. Simple and safe.
SPA + API gateway + cookie auth
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.