Debugging CORS Preflight Failures: A Runbook
A CORS preflight failure looks alarming in the browser console — “blocked by CORS policy” — but the request usually never reaches your upstream at all, and the fix is almost always a header the gateway forgot to send. The browser fires an OPTIONS preflight before any non-simple cross-origin request, and if the gateway’s response is missing the right Access-Control-Allow-* headers, mismatches the requested method or header, or gets intercepted by an auth check, the browser silently refuses to make the real call. This runbook walks the failure back from the console message to the exact gateway misconfiguration, with symptom-to-fix mappings and corrected Kong and Envoy configuration.
Prerequisite concepts
This runbook assumes CORS is enforced at the gateway rather than in each upstream service, which is the model described in CORS and cross-origin security, and that CORS is one link in the broader middleware chain where its position relative to authentication is what makes or breaks the preflight. If you serve multiple front-end origins from one gateway — the case where wildcard shortcuts break down — read configuring CORS policies for multi-tenant APIs alongside this page, because per-tenant origin allowlists are the correct answer to most of the failures below.
A preflight is triggered whenever a cross-origin request is not a simple request: it uses a method other than GET/HEAD/POST, sends a non-safelisted header such as Authorization or a custom X- header, or uses a Content-Type other than the three form types. The browser sends OPTIONS with Access-Control-Request-Method and Access-Control-Request-Headers, and the gateway must answer with matching Access-Control-Allow-Method and Access-Control-Allow-Headers values or the real request is blocked.
The request flow that fails
Symptom → cause → fix
| Console / network symptom | Root cause | Fix |
|---|---|---|
“No Access-Control-Allow-Origin header is present” |
Gateway not sending CORS headers on OPTIONS; CORS plugin not attached to the route |
Attach the CORS handler to the route; confirm it emits Access-Control-Allow-Origin on the OPTIONS response |
“Response to preflight has 403/401” |
Auth runs before CORS and challenges the credential-less OPTIONS |
Order CORS before auth (higher Kong priority; earlier in Envoy filter chain) so OPTIONS is short-circuited |
“The value of Access-Control-Allow-Origin is '*' but credentials mode is 'include'” |
Wildcard origin combined with Access-Control-Allow-Credentials: true |
Reflect the specific Origin against an allowlist; never pair * with credentials |
“Method PATCH is not allowed by Access-Control-Allow-Methods” |
Requested method missing from the methods allowlist | Add the method to Access-Control-Allow-Methods / allow_methods |
“Request header authorization is not allowed by Access-Control-Allow-Headers” |
Custom/Authorization header not in the headers allowlist |
Add the header to Access-Control-Allow-Headers / allow_headers |
| Preflight succeeds but repeats on every call | No Access-Control-Max-Age, so the browser never caches the result |
Set Access-Control-Max-Age (e.g. 3600) on the preflight response |
Fix in Kong 3.x
Kong’s cors plugin answers preflights, but it only works if it runs before the auth plugin. Kong runs plugins by descending priority; the cors plugin’s default priority is high enough to precede jwt/openid-connect, but verify it explicitly rather than assume — a custom plugin or reordered chain can break it:
# Kong 3.x — cors plugin scoped to a route, ordered ahead of auth
plugins:
- name: cors
route: app-api # attach to the route, not globally, so origins stay scoped
config:
# reflect these exact origins; do NOT use "*" when credentials are on
origins:
- https://app.example.com
- https://admin.example.com
methods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"]
headers: ["Authorization", "Content-Type", "X-Request-Id"]
exposed_headers: ["X-Request-Id"]
credentials: true # requires specific origins above, never "*"
max_age: 3600 # browser caches the preflight for 1 hour
preflight_continue: false # Kong answers OPTIONS itself; do NOT forward upstream
The two fields that fix the most incidents are preflight_continue: false — which makes Kong short-circuit the OPTIONS and return the headers itself instead of proxying it to an upstream that may reject it — and the explicit origins list, which lets credentials: true be legal. If the auth plugin still challenges preflights, the cors plugin is running too late; the authentication proxying and token validation layer must not see the OPTIONS request.
Fix in Envoy 1.32+
Envoy answers preflights in the cors filter, which must sit before jwt_authn in the HTTP filter chain, and the per-route policy defines the allowlists:
# Envoy 1.32+ — cors filter ordered before jwt_authn
http_filters:
- name: envoy.filters.http.cors
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
- name: envoy.filters.http.jwt_authn # AFTER cors, so OPTIONS is answered first
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
# ...providers as configured elsewhere...
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
# Envoy 1.32+ — per-route CORS policy on the virtual host
virtual_hosts:
- name: app_vhost
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:
- exact: "https://app.example.com"
- exact: "https://admin.example.com"
allow_methods: "GET, POST, PATCH, PUT, DELETE, OPTIONS"
allow_headers: "authorization, content-type, x-request-id"
expose_headers: "x-request-id"
allow_credentials: true # requires exact origins, not a prefix/regex wildcard
max_age: "3600"
Envoy’s cors filter automatically short-circuits OPTIONS and returns a 200/204 with the allow headers, so no route to an upstream is needed for the preflight. Use exact origin matches with allow_credentials: true; a safe_regex or prefix origin match that resolves to a broad set is the equivalent of the forbidden wildcard-with-credentials combination.
Gotchas and failure signals
Wildcard origin with credentials. Access-Control-Allow-Origin: * plus Access-Control-Allow-Credentials: true is rejected by every browser. Signal: the real request is blocked even though the preflight returned 200, with a console message naming the credentials mode. Fix: reflect a specific allowlisted origin.
Auth intercepting OPTIONS. The preflight carries no cookies or Authorization header by design, so any auth check that runs before CORS returns 401/403 on the OPTIONS. Signal: preflight status is 401, not a CORS error per se. Fix: order CORS ahead of auth and let it short-circuit OPTIONS.
Header/method allowlist gaps. The preflight echoes only the methods and headers you list; a missing entry blocks the real request even though the preflight returned 200. Signal: console names the specific disallowed method or header. Fix: add it to the allowlist and remember Authorization and any custom X- header must be listed explicitly.
Missing Access-Control-Max-Age. Without it the browser re-runs the preflight on every request, adding a round trip and load. Signal: an OPTIONS for every single API call in the network panel. Fix: set max_age (browsers cap it — Chromium honours up to two hours).
Case and comma formatting. Access-Control-Allow-Headers is compared case-insensitively but must be a correctly comma-separated list; a stray value or missing comma silently drops a header from the allowlist. Signal: one header rejected while others pass. Fix: validate the emitted header string, not just the config.
Validation
-
OPTIONSto a protected route returns200/204withAccess-Control-Allow-Originechoing the requestOrigin. - CORS handler runs before authentication;
OPTIONSnever reaches the auth plugin/filter. -
Access-Control-Allow-Credentials: trueis paired only with specific origins, never*or a broad regex. - Every method and header the front end uses (
PATCH,Authorization, customX-headers) is in the allowlist. -
Access-Control-Max-Ageis set so preflights are cached and not repeated per request. - Preflight is short-circuited at the gateway (
preflight_continue: false/ Envoycorsfilter) and not proxied upstream. - Tested with a real cross-origin
fetchfrom the actual front-end origin, not justcurl(which does not enforce CORS).
FAQ
Why does my browser send an OPTIONS request before the real API call?
That OPTIONS request is a CORS preflight. Browsers send it automatically before any cross-origin request that is not a simple request — meaning it uses a method other than GET, HEAD, or POST, sets a non-safelisted header such as Authorization or a custom X- header, or uses a Content-Type other than the three form-encoding types. The preflight asks the server, via Access-Control-Request-Method and Access-Control-Request-Headers, whether the real request will be allowed. If the gateway does not answer with matching Access-Control-Allow-* headers, the browser blocks the real request and you never see it reach the upstream.
Why does CORS fail when I use a wildcard origin with credentials?
The Fetch standard forbids combining Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. When a request carries credentials such as cookies or an Authorization header, the browser requires the response to echo a specific origin, not a wildcard, and to include Access-Control-Allow-Credentials: true. Configure the gateway to reflect the request’s Origin against an allowlist and return that exact value, and set the credentials flag only for origins you trust.
Why does my preflight return 401 instead of 200?
The CORS preflight OPTIONS request intentionally carries no credentials, so if an authentication plugin runs before the CORS handler and challenges the OPTIONS request, it returns 401 and the browser blocks the real call. The fix is to ensure the CORS handler runs before authentication and short-circuits OPTIONS, or to exempt the OPTIONS method from the auth plugin. In Kong this means the cors plugin must have a higher priority than the auth plugin; in Envoy the cors filter must sit before jwt_authn in the filter chain.
How do I stop the browser sending a preflight on every request?
Set Access-Control-Max-Age on the preflight response so the browser caches the preflight result and skips the OPTIONS round trip for subsequent identical requests. A value of 600 to 86400 seconds is typical, though browsers cap it — Chromium honours up to two hours. You cannot eliminate the first preflight, only the repeats, and you can avoid preflights entirely only by keeping requests within the simple-request rules, which most authenticated APIs cannot do because they send an Authorization header.
Parent: CORS & Cross-Origin Security
Related
- Configuring CORS Policies for Multi-Tenant APIs — per-tenant origin allowlists that replace unsafe wildcards.
- CORS & Cross-Origin Security — how CORS enforcement fits the cross-origin security model.
- Middleware Chains & Request Transformation — plugin ordering that determines whether the preflight is answered before auth.