CORS and Linkerd live at different layers, and that mismatch is where most confusion starts.
Linkerd is a service mesh. CORS is a browser enforcement model for cross-origin HTTP requests. Linkerd is great at mTLS, traffic policy, retries, and observability between services. It is not, by itself, a CORS engine. If you expect Linkerd to “handle CORS” the way an API gateway or app framework does, you’ll hit a wall pretty quickly.
The practical question is not “does Linkerd support CORS?” but “where should I implement CORS when I’m running Linkerd?”
Here’s the short answer:
- Best default: handle CORS in the application or ingress controller
- Usually bad idea: try to force CORS into the mesh layer
- Good platform compromise: centralize CORS at ingress for public APIs, keep service-to-service traffic out of it
The three realistic options
If you run Linkerd, you usually end up choosing one of these patterns:
- Application-level CORS
- Ingress-level CORS
- Proxy/filter layer near the mesh
I’ve used all three. Only two are worth recommending for most teams.
Option 1: Application-level CORS
This is the most boring option, which is exactly why I like it.
Your API service sets the right CORS headers itself. Linkerd stays out of the way and just proxies traffic as normal.
Example: Express behind Linkerd
import express from "express";
import cors from "cors";
const app = express();
const allowedOrigins = [
"https://app.example.com",
"https://admin.example.com"
];
app.use(cors({
origin(origin, callback) {
if (!origin) return callback(null, true); // curl, server-to-server
if (allowedOrigins.includes(origin)) return callback(null, true);
return callback(new Error("Not allowed by CORS"));
},
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["ETag", "Link", "Location", "Retry-After"],
credentials: true,
maxAge: 600
}));
app.options("*", cors());
app.get("/api/user", (req, res) => {
res.json({ ok: true });
});
app.listen(3000);
Pros
- Correct ownership: the service knows which origins, methods, and headers are valid
- Easy to test locally without Kubernetes or mesh complexity
- Works cleanly with Linkerd because Linkerd just forwards the request
- Per-route flexibility if some endpoints should be public and others locked down
Cons
- Repeated config across services if you have a lot of APIs
- Inconsistent behavior if each team rolls their own CORS rules
- More app code in services that shouldn’t care about browser concerns
When this is the right choice
Use app-level CORS when:
- you have a small number of public APIs
- different services need different CORS rules
- your teams own their services end-to-end
- you want precise behavior for credentials, exposed headers, and preflight responses
For most teams, this is the cleanest answer.
Option 2: Ingress-level CORS
This is the common platform-engineering move: put CORS at the edge, before traffic reaches services in the mesh.
Linkerd often sits alongside an ingress controller such as NGINX or an API gateway. That ingress can answer preflight OPTIONS requests and attach CORS headers to normal responses.
Example: NGINX Ingress annotations
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: public-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-methods: "GET, POST, PUT, DELETE, OPTIONS"
nginx.ingress.kubernetes.io/cors-allow-headers: "Authorization, Content-Type"
nginx.ingress.kubernetes.io/cors-expose-headers: "ETag, Link, Location, Retry-After"
nginx.ingress.kubernetes.io/cors-allow-credentials: "true"
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
port:
number: 80
Pros
- Centralized management for public browser-facing APIs
- Less duplicated code in backend services
- Preflight can be handled early, reducing load on apps
- Good fit with Linkerd since the mesh still handles service connectivity and policy behind ingress
Cons
- Coarse-grained control unless your ingress config gets complicated
- Can drift from app reality if the ingress allows methods or headers the app doesn’t actually support
- Harder for service teams to reason about, because CORS behavior lives outside the app
- Multiple ingress controllers can mean inconsistent CORS behavior
When this is the right choice
Use ingress-level CORS when:
- you expose many APIs from a shared edge
- platform teams want standard policy
- most services share similar browser access rules
- you want to keep browser-specific behavior out of application code
This is my preferred option for large Kubernetes setups. Public traffic gets CORS at the edge. Internal traffic in Linkerd remains unaffected.
Option 3: Mesh or sidecar-level CORS hacks
This is where people get tempted to over-engineer.
They see a proxy in the path and think: “Can I just make the sidecar inject CORS headers?” With Linkerd specifically, that’s not really its job. Linkerd’s proxy is intentionally focused on transport and service communication, not arbitrary HTTP response transformation like a full API gateway.
You can sometimes bolt on another proxy layer, custom filter, or gateway adjacent to the mesh. But at that point, Linkerd isn’t solving CORS. Something else is.
Pros
- Potentially centralized
- Can work in niche environments
- Useful if you already run an edge proxy with advanced HTTP filters
Cons
- Not a native Linkerd strength
- Operationally messy
- Harder debugging because CORS failures already confuse frontend teams
- Adds another abstraction layer for a problem your ingress or app can solve more simply
When this is the right choice
Almost never.
If you need policy-driven HTTP transformations, choose a proper ingress or gateway layer. Don’t try to turn Linkerd into one.
Linkerd-specific reality check
Linkerd will happily proxy browser-originated requests to your services. It will also proxy preflight OPTIONS requests. But if your app or ingress doesn’t produce the needed CORS headers, the browser will block the response.
That means Linkerd can be present in the path without being the place where CORS is configured.
This distinction matters during debugging:
- If
curlworks but the browser fails, suspect CORS - If pod-to-pod traffic works, that tells you nothing about browser CORS
- If Linkerd metrics show successful HTTP traffic, the browser may still reject the response because headers are wrong or missing
I’ve seen teams waste hours in mesh dashboards for what was really a missing Access-Control-Allow-Origin.
What good CORS looks like in practice
Don’t treat CORS as just Access-Control-Allow-Origin. Real APIs often need more.
For 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 access-control-expose-headers list is the part a lot of teams forget. If your frontend needs to read pagination, rate-limit, retry, or deprecation headers, you must explicitly expose them.
For a public read-only API, wildcard origin can be fine:
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: ETag, Link, Location, Retry-After
For authenticated browser APIs, be stricter:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Access-Control-Expose-Headers: ETag, Link, Retry-After
And don’t do this:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Browsers reject that combination, and they should.
Pros and cons summary
App-level CORS
Pros
- precise
- easy to test
- service-owned
- works naturally with Linkerd
Cons
- duplicated config
- can become inconsistent
Ingress-level CORS
Pros
- centralized
- good for many public APIs
- keeps app code cleaner
Cons
- less granular
- can drift from backend behavior
- ingress complexity grows fast
Mesh-level CORS
Pros
- theoretically centralized
Cons
- not what Linkerd is built for
- awkward operations
- poor debugging experience
My recommendation
If you run Linkerd, use this rule:
- Public browser traffic: configure CORS at ingress
- Service-specific exceptions: override in the app
- Internal service traffic: ignore CORS entirely
- Do not expect Linkerd itself to be your CORS layer
That split keeps responsibilities clean:
- Linkerd handles secure, observable service communication
- ingress handles edge/browser concerns
- applications own endpoint-specific behavior when needed
That’s usually the least painful setup.
If you want to go deeper on Linkerd behavior itself, stick to the official docs: Linkerd Documentation. For browser policy details and exact header behavior, the best source is still the standard platform docs you already use in your stack.
CORS is annoying enough without making your service mesh responsible for it.