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

Where a CORS preflight fails in the gateway middleware chain Top row shows the broken order: browser sends an OPTIONS preflight, the auth plugin runs first and returns 401, the browser blocks the real request. Bottom row shows the fixed order: the CORS handler runs first, short-circuits OPTIONS with a 204 and Access-Control-Allow headers, and the browser then sends the real request through auth to the upstream. Broken order Browser OPTIONS Auth plugin runs first 401 no credentials Browser blocks real request Fixed order Browser OPTIONS CORS handler runs first 204 + Allow-* headers Real request → auth → upstream the preflight carries no credentials — it must be answered before auth challenges it

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

  • OPTIONS to a protected route returns 200/204 with Access-Control-Allow-Origin echoing the request Origin.
  • CORS handler runs before authentication; OPTIONS never reaches the auth plugin/filter.
  • Access-Control-Allow-Credentials: true is paired only with specific origins, never * or a broad regex.
  • Every method and header the front end uses (PATCH, Authorization, custom X- headers) is in the allowlist.
  • Access-Control-Max-Age is set so preflights are cached and not repeated per request.
  • Preflight is short-circuited at the gateway (preflight_continue: false / Envoy cors filter) and not proxied upstream.
  • Tested with a real cross-origin fetch from the actual front-end origin, not just curl (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