Configuring CORS Policies for Multi-Tenant APIs

Static Access-Control-Allow-Origin headers break the moment a SaaS platform serves more than one customer domain. In a multi-tenant architecture every tenant operates under a distinct origin — whether a branded subdomain like acme.app.example.com or a fully custom domain like dashboard.acme-corp.io. A single hard-coded origin header either blocks legitimate tenants or forces the use of a wildcard (*) that cannot be combined with credentials. The operational stake is real: a misconfigured CORS policy silently blocks production traffic in customer browsers while appearing healthy in server-side health checks.

Prerequisite concepts

This page assumes familiarity with the CORS & Cross-Origin Security topic, which covers browser enforcement rules, preflight mechanics, and the header composition contract. The configurations here live inside the middleware chains & request transformation layer of the gateway — specifically in the CORS middleware that runs before downstream routing. For broader gateway capability comparisons, see Gateway Selection Criteria.

How dynamic per-tenant CORS resolution works

The gateway must do two things before writing any CORS header:

  1. Resolve the tenant from the incoming request — via the Host header, a X-Tenant-ID header, or a claim extracted from a JWT in the Authorization header.
  2. Look up the allowed origin set for that tenant and evaluate the incoming Origin header against it.

If the incoming Origin matches, the gateway echoes that exact value in Access-Control-Allow-Origin and sets Vary: Origin. If it does not match, the gateway omits the CORS headers entirely — the browser then blocks the response itself. This “echo or omit” pattern is safer than returning an error, because an explicit 403 on a CORS mismatch can still confuse browser error handling.

The diagram below shows where this resolution sits inside the request lifecycle.

CORS tenant resolution sequence Sequence diagram: browser sends OPTIONS preflight to CORS middleware, which calls the tenant resolver to get an allowed origins list, evaluates the incoming Origin, echoes it back on match (or omits headers on no-match), then proxies the real request to the upstream with CORS headers injected. Browser CORS Middleware Tenant Resolver Upstream OPTIONS /api (preflight) resolve tenant (Host / JWT) allowed origins list match origin? ✓ match 204 + ACAO: echo origin ✗ no match 204 (no CORS headers) actual request (after preflight passes) GET /api (credentialed) proxy + inject ACAO header 200 response body 200 + ACAO + Vary: Origin

Gateway configurations: Envoy and Kong

Envoy 1.32+ — per-route CORS with regex origin matching

Envoy’s cors typed filter in the HttpConnectionManager chain handles both preflight interception and header injection. The key is placing the filter before any JWT authn filter so preflight OPTIONS requests are not rejected for missing tokens.

# envoy.yaml — 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:
                  # CORS must come before JWT authn
                  - name: envoy.filters.http.cors
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.CorsPolicy
                  - name: envoy.filters.http.jwt_authn
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
                      # ... jwt provider config
                  - name: envoy.filters.http.router
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
                route_config:
                  virtual_hosts:
                    - name: tenant_api
                      domains: ["api.example.com"]
                      cors:
                        allow_credentials: true
                        allow_methods: "GET,POST,PUT,DELETE,OPTIONS"
                        allow_headers: "Authorization,Content-Type,X-Tenant-ID,X-Request-ID"
                        expose_headers: "X-Request-ID,X-RateLimit-Remaining"
                        max_age: "7200"
                        # Anchored regex prevents partial-match origin spoofing
                        allow_origin_string_match:
                          - safe_regex:
                              regex: "^https://[a-z0-9-]+\\.app\\.example\\.com$"
                          - exact: "https://dashboard.enterprise-client.com"
                          - exact: "https://portal.another-customer.io"
                      response_headers_to_add:
                        - header:
                            key: "Vary"
                            value: "Origin"
                          keep_empty_value: false
                      routes:
                        - match: { prefix: "/api/v1/" }
                          route: { cluster: upstream_service }

The safe_regex field anchors both ends of the pattern with ^ and $. Without anchoring, an attacker can construct a URL like https://evil.com?=https://legit.app.example.com that passes a naive substring match. The max_age: "7200" value caches the preflight response for two hours; raise it in stable production environments to reduce OPTIONS traffic.

Kong 3.x — dynamic CORS plugin with per-route scope

Kong’s built-in cors plugin can be scoped to a service, route, or consumer. For multi-tenant APIs the most reliable pattern is to scope it to the route and use the origins array, which Kong evaluates as an exact-match list. For subdomain wildcard matching you must use Kong’s request transformer or a custom plugin that performs the echo-or-omit logic at runtime.

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

services:
  - name: tenant-api
    url: http://upstream-service:8080
    routes:
      - name: tenant-api-route
        paths:
          - /api/v1
        methods:
          - GET
          - POST
          - PUT
          - DELETE
          - OPTIONS

plugins:
  # Route-scoped CORS plugin
  - name: cors
    route: tenant-api-route
    config:
      origins:
        - "https://acme.app.example.com"
        - "https://beta.app.example.com"
        - "https://dashboard.enterprise-client.com"
      methods:
        - GET
        - POST
        - PUT
        - DELETE
        - OPTIONS
      headers:
        - Authorization
        - Content-Type
        - X-Tenant-ID
        - X-Request-ID
      exposed_headers:
        - X-Request-ID
        - X-RateLimit-Remaining
      credentials: true
      max_age: 7200
      preflight_continue: false   # Kong handles OPTIONS; do not forward to upstream

  # JWT validation runs after CORS — preflights never reach this plugin
  - name: jwt
    route: tenant-api-route
    config:
      claims_to_verify:
        - exp

preflight_continue: false is the critical flag: Kong intercepts OPTIONS requests and returns the CORS preflight response itself without forwarding to the upstream service. This avoids upstreams that cannot handle OPTIONS and ensures the auth plugin never sees a preflight.

For subdomain wildcard support in Kong 3.x, the cors plugin accepts a single * wildcard — but this cannot be combined with credentials: true. The production-safe pattern is to maintain a Redis-backed tenant registry and use a Lua plugin that reads the Origin header, validates it against the registry, and injects Access-Control-Allow-Origin dynamically. Pair this pattern with per-tenant rate limiting so that tenant origin lookups are not exploitable for cache-flooding:

-- Kong custom plugin: dynamic-cors/handler.lua
local redis = require "resty.redis"
local red = redis:new()

local function get_allowed_origins(tenant_id)
  red:connect("redis-service", 6379)
  local origins, err = red:smembers("cors:tenant:" .. tenant_id)
  red:set_keepalive(10000, 100)
  return origins
end

local DynamicCORS = {}
DynamicCORS.VERSION = "1.0.0"
DynamicCORS.PRIORITY = 2100  -- higher than built-in cors (2000)

function DynamicCORS:access(conf)
  local origin = kong.request.get_header("Origin")
  local tenant_id = kong.request.get_header("X-Tenant-ID")
  if not origin or not tenant_id then return end

  local allowed = get_allowed_origins(tenant_id)
  for _, v in ipairs(allowed) do
    if v == origin then
      kong.response.set_header("Access-Control-Allow-Origin", origin)
      kong.response.set_header("Access-Control-Allow-Credentials", "true")
      kong.response.set_header("Vary", "Origin")
      if kong.request.get_method() == "OPTIONS" then
        kong.response.set_header("Access-Control-Allow-Methods",
          "GET, POST, PUT, DELETE, OPTIONS")
        kong.response.set_header("Access-Control-Allow-Headers",
          "Authorization, Content-Type, X-Tenant-ID")
        kong.response.set_header("Access-Control-Max-Age", "7200")
        return kong.response.exit(204)
      end
      return
    end
  end
  -- no match: omit CORS headers silently
end

return DynamicCORS

Decision matrix: which approach to use

Scenario Recommended approach Trade-off
Fixed, known tenant origin list (< 50 tenants) Kong cors plugin with explicit origins array Simple, no runtime lookup; updating origins requires re-deploy or Admin API call
Subdomain wildcard (e.g., *.app.example.com) Envoy safe_regex anchored pattern Fast, zero external dependency; less flexible for custom domains
Custom domains per tenant, dynamically provisioned Kong custom Lua plugin + Redis registry Full flexibility; adds Redis latency (~1 ms) on every CORS request
Hybrid: common subdomains + select custom domains Envoy regex + static exact entries Config-driven, no runtime lookup; custom domain additions require re-deploy
Zero-trust overlay with mTLS between tenants Apply CORS upstream after mTLS at the gateway edge Cleanest isolation; CORS headers still needed for browser clients

Gotchas and failure signals

Wildcard plus credentials is always rejected. Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true cannot coexist. Browsers silently fail the CORS check and the developer console shows The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' alongside the origin mismatch. The fix is always to echo the validated origin explicitly.

Missing Vary: Origin poisons CDN caches. When a CDN edge node caches a response from tenant A and serves it to tenant B’s origin, the browser sees the wrong Access-Control-Allow-Origin value and blocks the request. Add Vary: Origin to every response that carries a CORS header — not just preflights. In Envoy, the response_headers_to_add block handles this; in Kong, the cors plugin adds it automatically when credentials: true is set.

Unanchored regex allows origin spoofing. A regex like https://.*\.example\.com matches https://evil.com?x=https://foo.example.com because the . is unbounded. Always anchor with ^ and $ and escape literal dots as \..

OPTIONS forwarded to JWT-protected upstreams. If the CORS middleware runs after a JWT validation filter, preflight requests fail with 401 Unauthorized because browsers send no Authorization header in preflights. The filter order in the Envoy config above (CORS before JWT authn) and Kong’s preflight_continue: false setting both prevent this.

Tenant context not resolved before CORS evaluation. If the tenant resolver is downstream of the CORS middleware, the middleware has no tenant ID to look up. Ensure tenant resolution (via Host, X-Tenant-ID, or JWT sub-claim extraction) happens in a filter that runs before or within the CORS plugin. In Envoy, a Lua filter that extracts and injects a X-Tenant-ID header can run at PRIORITY = 2200 (above the CORS filter priority).

max_age set too low. Preflight cache times below 60 seconds generate high OPTIONS traffic on busy APIs. For stable origin lists, 7200 (two hours) is a safe production value. Chrome caps max_age at 7200 regardless of what you set; Firefox caps at 86400.

Validation checklist

  • OPTIONS preflight to each tenant origin returns 204 with the correct Access-Control-Allow-Origin echoing the request’s Origin.
  • OPTIONS request with an unknown origin returns 204 with no Access-Control-Allow-Origin header (browser blocks it; gateway does not expose the mismatch).
  • Vary: Origin header is present on both preflight and actual responses when CORS headers are set.
  • Access-Control-Allow-Credentials: true is never combined with Access-Control-Allow-Origin: *.
  • Regex patterns are anchored with ^ and $ and literal dots are escaped.
  • JWT/auth middleware runs after CORS in the filter chain (verify with a curl -X OPTIONS that has no Authorization header).
  • CDN cache invalidation has been triggered after any change to the CORS policy; stale preflight responses are not being served.
  • Integration tests cover at least three cases: valid tenant origin, unknown origin, and cross-tenant origin spoofing attempt.

FAQ

Can I use a wildcard origin with credentials in a multi-tenant API?

No. Browsers reject any response that sets Access-Control-Allow-Origin: * alongside Access-Control-Allow-Credentials: true. For credentialed multi-tenant requests you must echo the validated Origin header value back explicitly.

Why do I need Vary: Origin in a multi-tenant CORS response?

Without Vary: Origin, a CDN or reverse proxy may serve one tenant’s cached CORS response to another tenant’s origin, causing incorrect Allow-Origin values and potentially leaking cross-tenant access grants.

Should OPTIONS preflight requests bypass JWT validation?

Yes. Browsers do not include Authorization headers in preflight OPTIONS requests. If your gateway applies JWT validation before CORS, every preflight will fail with 401. CORS evaluation must run first in the middleware chain.


Parent: CORS & Cross-Origin Security

Related