CORS & Cross-Origin Security at the API Gateway

Cross-Origin Resource Sharing (CORS) is the browser’s enforcement mechanism for the same-origin policy — it determines which domains may read responses originating from a different origin. Enforcing CORS inside the middleware chain at the gateway edge, rather than inside each backend service, centralises policy, eliminates header-duplication bugs, and allows preflight requests to be absorbed before they consume backend resources. This page covers how to configure and harden gateway-level CORS across Kong, Envoy, and NGINX, with production-grade configuration for every scenario from simple public APIs to credentialed multi-tenant architectures.

Architectural Baseline

Three mental models must be clear before working with gateway-level CORS:

The preflight contract. Browsers send an HTTP OPTIONS request to a cross-origin endpoint before any non-simple request — one using a custom header, a non-GET/POST method, or a Content-Type outside the standard set (application/x-www-form-urlencoded, multipart/form-data, text/plain). The server must respond with Access-Control-Allow-* headers and a 2xx status; only then does the browser issue the real request. When the gateway terminates this preflight and the backend never sees it, backend throughput improves and backend frameworks require no CORS configuration.

The origin reflection constraint. Wildcard origins (Access-Control-Allow-Origin: *) are forbidden whenever Access-Control-Allow-Credentials: true is set. For credentialed requests the gateway must reflect the exact value of the Origin request header into Access-Control-Allow-Origin — but only after validating that value against an explicit allowlist. Reflecting an unvalidated origin is equivalent to granting any caller access to authenticated responses.

The shared-cache hazard. When the gateway reflects a per-request origin dynamically, it must also emit Vary: Origin. Without this header, a CDN or upstream proxy may cache a response for one origin and serve it to a different one — producing either a 403 or, worse, silently leaking data across tenant boundaries. This interaction with caching and response optimisation is the most commonly underestimated CORS risk in CDN-fronted deployments.

These three constraints apply regardless of which gateway you are configuring. The sections below show how each platform implements them.


CORS Preflight and Credentialed Request Flow Sequence diagram: browser sends OPTIONS preflight to the API gateway; gateway validates the origin against an allowlist and returns 204 with Access-Control headers without calling the backend; browser sends actual GET with Authorization header; gateway proxies to backend, injects CORS headers including Vary: Origin, and returns 200 to the browser. Browser API Gateway (edge / CORS layer) Backend Service OPTIONS /api/resource Origin: https://app.example.com Validate origin vs allowlist regex / exact-match check 204 No Content Access-Control-Allow-* headers backend not called GET /api/resource Authorization: Bearer … proxy to backend 200 OK + payload Inject ACAO header + Vary: Origin 200 OK + CORS headers Access-Control-Allow-Origin: (reflected)

Primary Concept: Preflight Interception and Origin Validation

Kong 3.x

Kong’s built-in cors plugin handles preflight termination, dynamic origin reflection, and credential propagation. Apply it at the service or route level in declarative configuration:

# kong.yml — declarative (Kong 3.x)
_format_version: "3.0"

services:
  - name: api-service
    url: http://backend:8080
    plugins:
      - name: cors
        config:
          origins:
            - "https://app.example.com"
            - "https://admin.example.com"
            - "^https://[a-z0-9-]+\\.preview\\.example\\.com$"
          methods:
            - GET
            - POST
            - PUT
            - PATCH
            - DELETE
            - OPTIONS
          headers:
            - Authorization
            - Content-Type
            - X-Request-ID
            - X-Tenant-ID
          exposed_headers:
            - X-RateLimit-Remaining
            - X-Trace-ID
          credentials: true
          max_age: 3600
          preflight_continue: false

Key behaviour notes:

  • preflight_continue: false causes Kong to short-circuit OPTIONS requests and respond directly, never routing them to the upstream. This is the correct production setting.
  • Entries in origins beginning with ^ are treated as regular expressions. Anchor both ends (^...$) to prevent prefix-matching attacks — for example, ^https://evil-example\\.com would match https://evil-example.com.attack.io without a trailing $.
  • When credentials: true, Kong reflects the exact Origin request header rather than emitting *. If the origin does not match any allowlist entry, Kong returns the response without Access-Control-Allow-Origin, causing the browser to block it.
  • exposed_headers lists response headers that browser JavaScript is permitted to read. Omitting this means custom headers such as X-RateLimit-Remaining are invisible to client code even when the backend sends them.

Envoy 1.32+

Envoy’s CORS filter is configured as an HTTP filter in the connection manager filter chain, with per-virtual-host or per-route policy overrides via typed_per_filter_config:

# envoy.yaml — static bootstrap (Envoy 1.32+)
static_resources:
  listeners:
    - name: listener_0
      address:
        socket_address: { address: 0.0.0.0, port_value: 8080 }
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                stat_prefix: ingress_http
                http_filters:
                  - name: envoy.filters.http.cors
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
                  - name: envoy.filters.http.router
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
                route_config:
                  name: local_route
                  virtual_hosts:
                    - name: api
                      domains: ["api.example.com"]
                      typed_per_filter_config:
                        envoy.filters.http.cors:
                          "@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.CorsPolicy
                          allow_origin_string_match:
                            - safe_regex:
                                regex: "^https://[a-z0-9-]+\\.example\\.com$"
                          allow_methods: "GET,POST,PUT,DELETE,PATCH,OPTIONS"
                          allow_headers: "Authorization,Content-Type,X-Request-ID"
                          expose_headers: "X-Trace-ID,X-RateLimit-Remaining"
                          max_age: "3600"
                          allow_credentials: true
                          filter_enabled:
                            default_value: { numerator: 100, denominator: HUNDRED }
                      routes:
                        - match: { prefix: "/" }
                          route: { cluster: backend_cluster }

Envoy evaluates the Origin header against allow_origin_string_match entries. If the match fails, Envoy passes the response through without CORS headers (the browser then blocks it) rather than sending an explicit 403. For an explicit rejection before CORS evaluation runs, combine this with an ext_authz filter that inspects the origin header directly — a pattern also useful in zero-trust security boundary deployments where every request must be authorised before any response data is returned.

Critical Envoy caveat: Envoy’s CORS filter does not automatically add Vary: Origin. You must inject it explicitly via response_headers_to_add in the route or virtual host config:

response_headers_to_add:
  - header:
      key: Vary
      value: Origin
    keep_empty_value: false

Omitting Vary: Origin on a CDN-fronted Envoy deployment is a high-severity cache-poisoning risk.

NGINX 1.25+

Standard NGINX CORS configuration uses the map directive for origin matching, avoiding the well-known limitations of if blocks in the proxy path:

# nginx.conf snippet (NGINX 1.25+)
map $http_origin $cors_origin {
    default                                          "";
    "https://app.example.com"                        $http_origin;
    "https://admin.example.com"                      $http_origin;
    ~^https://[a-z0-9-]+\.preview\.example\.com$     $http_origin;
}

server {
    listen 443 ssl;
    server_name api.example.com;

    location / {
        # Preflight: absorb at the gateway, never proxy to backend
        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Origin      $cors_origin always;
            add_header Access-Control-Allow-Methods     "GET, POST, PUT, DELETE, PATCH, OPTIONS" always;
            add_header Access-Control-Allow-Headers     "Authorization, Content-Type, X-Request-ID" always;
            add_header Access-Control-Allow-Credentials "true" always;
            add_header Access-Control-Max-Age           3600 always;
            add_header Vary                             "Origin" always;
            add_header Content-Length                   0;
            return 204;
        }

        # Actual requests: inject CORS headers on all proxied responses
        add_header Access-Control-Allow-Origin      $cors_origin always;
        add_header Access-Control-Allow-Credentials "true" always;
        add_header Access-Control-Expose-Headers    "X-Trace-ID, X-RateLimit-Remaining" always;
        add_header Vary                             "Origin" always;

        proxy_pass http://upstream;
    }
}

The map block is evaluated once per request. When $cors_origin is empty (origin not in allowlist), the add_header directives emit a blank value — most browsers treat a blank Access-Control-Allow-Origin as absent and block the response, which is the correct security outcome. The always flag ensures CORS headers are present even on 4xx/5xx responses, which is required for browser JavaScript to read the error body in a cross-origin context.

Secondary Concept: Advanced Knobs and Vendor-Specific Behaviour

Preflight caching with Access-Control-Max-Age

Without Access-Control-Max-Age, Chrome caches preflight results for 5 seconds, Firefox for up to 86400 seconds, and Safari for 0 seconds (every request triggers a preflight). Setting max_age: 3600 on Kong or max_age: "86400" in Envoy instructs compliant browsers to skip the preflight entirely for that duration. For gateways serving high-frequency API clients this can eliminate 80–95% of OPTIONS traffic. Pair this with rate limiting and throttling to cap the residual preflight volume from low-frequency or first-time clients.

Per-route CORS policies

Monolithic wildcard CORS policies applied at the service level are operationally fragile — a single credentials: true on a public read endpoint is a security regression. In Kong 3.x, override at the route level with distinct configurations:

routes:
  - name: public-read
    paths: ["/v1/public"]
    plugins:
      - name: cors
        config:
          origins: ["*"]
          credentials: false
          methods: [GET, HEAD, OPTIONS]

  - name: authenticated-write
    paths: ["/v1/protected"]
    plugins:
      - name: cors
        config:
          origins:
            - "https://app.example.com"
          credentials: true
          methods: [GET, POST, PUT, DELETE, OPTIONS]
          headers: [Authorization, Content-Type]

Public read endpoints can safely use origins: ["*"] without credential exposure. Authenticated write endpoints require strict origin validation and explicit credentials: true. This also prevents the subtle bug where adding a new protected route inherits a wildcard policy from the service level.

Disabling backend CORS middleware

When CORS responsibility moves to the gateway, every backend framework’s own CORS middleware must be disabled. If both the gateway and the backend emit Access-Control-Allow-Origin, the response arrives at the browser with duplicate headers — browsers reject this with a “multiple values” console error. The remediation depends on the stack:

  • Express: remove app.use(cors()) and any manual res.header('Access-Control-Allow-Origin', ...) calls
  • Spring Boot: remove all @CrossOrigin annotations and any WebMvcConfigurer CORS configuration beans
  • FastAPI: remove CORSMiddleware from the application middleware stack
  • Django REST Framework: remove django-cors-headers or set CORS_ALLOW_ALL_ORIGINS = False with an empty CORS_ALLOWED_ORIGINS

Backend CORS middleware documentation frequently recommends framework-level configuration, but in a gateway-first middleware architecture the gateway owns this concern entirely.

The credentials: true + wildcard incompatibility

The CORS specification (Fetch Standard §3.2) is unambiguous: when Access-Control-Allow-Credentials is true, the browser rejects an Access-Control-Allow-Origin value of *. The failure is invisible from the server side — the response is sent successfully, but the browser silently discards the response body. The only observable signal is a console error in browser developer tools:

The value of the ‘Access-Control-Allow-Origin’ header in the response must not be the wildcard ‘*’ when the request’s credentials mode is ‘include’.

This is one of the most frequent CORS misconfigurations in gateway setups where a developer adds credentials: true without removing the wildcard origin entry. The gateway selection criteria page covers which gateways validate this combination at configuration load time and which discover it only at runtime.

Origin allowlist design for dynamic environments

Preview environments and feature branches present a recurring challenge: each deployment gets a unique subdomain such as https://pr-1234.preview.example.com. Two safe patterns exist:

Pattern A — regex matching (Kong, NGINX map): Use an anchored regex covering the subdomain namespace: ^https://pr-[0-9]+\\.preview\\.example\\.com$. This requires that the namespace is narrow enough to trust any valid match, and that no external party can register subdomains under preview.example.com.

Pattern B — dynamic lookup (Lua/custom plugin): At request time, look up the Origin header against a Redis-backed list maintained by your CI/CD pipeline. Each deploy upserts its origin; the gateway plugin reads the list with a short TTL. This approach works well alongside JWT-based authentication proxying, where the JWT claim tenant_id can also drive which origin list is consulted.

Pattern A is simpler and sufficient for most organisations. Pattern B is appropriate when the list of valid origins changes at deploy frequency and regex can’t cover the full namespace safely.


Origin Validation Decision Tree Decision flow showing three paths for validating the Origin request header: exact-string match for a fixed allowlist, anchored regex for subdomain namespaces, and dynamic Redis lookup for CI/CD-generated origins. Each path leads to either reflecting the origin in Access-Control-Allow-Origin or returning no CORS header. Incoming Origin header Match strategy? Exact list Exact-string O(n) compare Regex Anchored regex ^subdomain\\.host\\.com$ Dynamic Redis lookup CI/CD managed Match → Reflect origin Match → Reflect origin Hit → Reflect origin (no match on any path → no ACAO header emitted)

Comparative Implementation Table

Gateway Preflight termination Origin matching Credential support Max-Age config key Vary: Origin automatic?
Kong 3.x (cors plugin) preflight_continue: false String or regex (^...$) credentials: true max_age (integer, seconds) Yes — when dynamic origin is reflected
Envoy 1.32+ Built-in filter safe_regex, exact allow_credentials: true max_age (string) No — add via response_headers_to_add
NGINX 1.25+ return 204 in if block map directive + regex add_header ACAC "true" add_header ACMA 86400 No — must be explicit
Tyk 5.x Built-in CORS middleware Allowlist strings allow_credentials: true max_age (integer, seconds) Yes — reflects origin when matched

Envoy requires explicit Vary: Origin injection. Forgetting it on a CDN-fronted Envoy deployment is a high-severity cache-poisoning risk. NGINX also requires explicit injection; the map + always pattern above handles it.

Operational Gotchas

Duplicate Access-Control-Allow-Origin headers. If the gateway and the backend both emit Access-Control-Allow-Origin, the browser receives multiple values and blocks the request. The browser console shows: The 'Access-Control-Allow-Origin' header contains multiple values. Fix: confirm preflight_continue: false in Kong and disable all CORS middleware in backend services.

Missing Vary: Origin causing cache poisoning. A CDN caches a response for https://app.example.com and serves it to https://evil.com. The cached response carries Access-Control-Allow-Origin: https://app.example.com, which the browser rejects for the second origin — but if the CDN strips CORS headers before forwarding the body, the response may have already been read. Add Vary: Origin unconditionally whenever the gateway performs dynamic origin reflection.

Regex anchoring mistakes. The pattern https://example\\.com matches https://example.com.evil.io because it lacks anchoring. Always write ^https://example\\.com$ in Kong and in Envoy’s safe_regex. Test candidate patterns against both valid and adversarial origins before deploying; regex101.com with the RE2 engine flag reflects what Envoy uses.

OPTIONS requests counted against rate limits. Preflights are synthetic browser traffic generated automatically before real requests. If your rate limiting middleware treats OPTIONS as billable requests, clients may be throttled before their actual request is sent. Exempt OPTIONS from rate-limit counters, or position the CORS preflight handler earlier in the filter chain than the rate limiter.

Access-Control-Expose-Headers omission. JavaScript can read only Cache-Control, Content-Type, Expires, Last-Modified, and Pragma by default. Any other response header — X-RateLimit-Remaining, X-Request-ID, X-Trace-ID — requires explicit listing in Access-Control-Expose-Headers. Symptom: response.headers.get('X-RateLimit-Remaining') returns null in the browser even though the backend sends the header.

Credential cookies vs. Authorization headers. Access-Control-Allow-Credentials: true covers both cookie-based sessions and Authorization header tokens when fetch uses credentials: 'include'. If the API uses only bearer tokens sent in Authorization, the credential flag may not be required — tokens in headers work without it, provided Authorization is listed in Access-Control-Allow-Headers. Enabling credentials: true unnecessarily widens the attack surface.

Access-Control-Max-Age browser ceiling. Chrome caps the effective Max-Age at 7200 seconds (2 hours), regardless of the value you send. Firefox honours up to 86400 seconds. Setting a value higher than 7200 has no additional effect in Chrome; your OPTIONS traffic will not drop to zero for Chrome users beyond the 2-hour window.

Production Configuration Checklist

  • CORS plugin or filter is configured at the gateway; backend CORS middleware is disabled on all services
  • preflight_continue: false (Kong) or equivalent preflight short-circuit is active and verified in access logs
  • All origins entries use exact strings or anchored regexes (^...$); no unanchored patterns exist
  • Access-Control-Allow-Origin: * is not combined with credentials: true on any route
  • Access-Control-Max-Age is set to at least 600 seconds on all routes
  • Vary: Origin is emitted on all responses where origin is reflected dynamically (Envoy: response_headers_to_add; NGINX: explicit add_header)
  • Access-Control-Expose-Headers lists every custom header clients need to read (e.g. X-RateLimit-Remaining, X-Trace-ID)
  • OPTIONS requests are excluded from rate-limit counters and do not consume authentication proxying budget
  • CORS rejection events (origin not in allowlist) are routed to the security telemetry pipeline with origin and path fields
  • Regex patterns are validated against both valid and adversarial origins in staging before promotion

FAQ

Can I use Access-Control-Allow-Origin: * with credentials?

No. The CORS specification explicitly forbids combining a wildcard origin with Access-Control-Allow-Credentials: true. The browser rejects the response at the JavaScript layer — the server side sees no indication of failure. You must validate the requesting Origin against an allowlist and reflect the exact value when credentials are involved.

Should CORS be handled at the gateway or in the backend service?

At the gateway, always. Terminating preflight OPTIONS requests at the edge eliminates unnecessary backend compute, centralises policy so all services share a single enforced configuration, and prevents the duplicate-header bug that arises when both layers try to add Access-Control-Allow-Origin.

Why does Access-Control-Max-Age matter for gateway performance?

Without a Max-Age directive, browsers issue a preflight before every cross-origin request. Setting Access-Control-Max-Age to 3600–7200 seconds (the practical ceiling for Chrome) instructs the browser to cache the preflight result, eliminating 80–95% of OPTIONS traffic for active sessions.

What is the Vary: Origin header and why is it critical for shared caches?

When a gateway reflects a dynamic origin into Access-Control-Allow-Origin, the response content differs per requesting origin. The Vary: Origin header instructs CDNs and shared proxies to cache responses separately per origin value, preventing a cached response for one origin being served — and accepted — by a different origin.


Up: Middleware Chains & Request Transformation