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.
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: falsecauses Kong to short-circuitOPTIONSrequests and respond directly, never routing them to the upstream. This is the correct production setting.- Entries in
originsbeginning with^are treated as regular expressions. Anchor both ends (^...$) to prevent prefix-matching attacks — for example,^https://evil-example\\.comwould matchhttps://evil-example.com.attack.iowithout a trailing$. - When
credentials: true, Kong reflects the exactOriginrequest header rather than emitting*. If the origin does not match any allowlist entry, Kong returns the response withoutAccess-Control-Allow-Origin, causing the browser to block it. exposed_headerslists response headers that browser JavaScript is permitted to read. Omitting this means custom headers such asX-RateLimit-Remainingare 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 manualres.header('Access-Control-Allow-Origin', ...)calls - Spring Boot: remove all
@CrossOriginannotations and anyWebMvcConfigurerCORS configuration beans - FastAPI: remove
CORSMiddlewarefrom the application middleware stack - Django REST Framework: remove
django-cors-headersor setCORS_ALLOW_ALL_ORIGINS = Falsewith an emptyCORS_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.
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
originsentries use exact strings or anchored regexes (^...$); no unanchored patterns exist -
Access-Control-Allow-Origin: *is not combined withcredentials: trueon any route -
Access-Control-Max-Ageis set to at least 600 seconds on all routes -
Vary: Originis emitted on all responses where origin is reflected dynamically (Envoy:response_headers_to_add; NGINX: explicitadd_header) -
Access-Control-Expose-Headerslists every custom header clients need to read (e.g.X-RateLimit-Remaining,X-Trace-ID) -
OPTIONSrequests 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
originandpathfields - 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.
Related
- Configuring CORS Policies for Multi-Tenant APIs — per-tenant origin isolation, dynamic policy lookup, and JWT-claim-driven CORS routing
- Debugging CORS Preflight Failures: A Runbook — symptom-to-fix triage for failing
OPTIONSpreflights at the gateway - Caching & Response Optimization —
Vary-aware caching, CDN integration, and shared-cache invalidation strategies - Security Boundaries & Zero-Trust — gateway-level security posture, mTLS enforcement, and defence-in-depth patterns
- Kong vs Tyk vs Envoy for Microservices — gateway capability comparison including CORS plugin maturity and configuration-time validation