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:

  1. Application-level CORS
  2. Ingress-level CORS
  3. 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 curl works 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.