Proxy Bypass Strategies for CORS Preflight

Without explicit configuration, reverse proxies and CDNs silently break CORS preflight optimization: they either forward OPTIONS to a backend that returns 405 Method Not Allowed, strip the Access-Control-Allow-Origin response header before it reaches the browser, or block OPTIONS outright at the WAF layer. The result is a preflight failure the developer never sees coming, because the network tab shows the correct server config but an intermediary has already mutated or swallowed the response.

This page is part of Preflight Request Optimization & Caching Strategies, which covers the full lifecycle of preflight from browser emission through caching and server-side response.

What the Fetch Specification Requires From the Network Path

The WHATWG Fetch Standard (§4.8, CORS-preflight fetch) requires that OPTIONS responses carry Access-Control-Allow-Origin, Access-Control-Allow-Methods, and (when the preflight includes an Access-Control-Request-Headers field) Access-Control-Allow-Headers. The browser performs an exact string comparison — any header that disappears in transit, even if the origin added it correctly, produces the same failure as if the origin never sent it.

This means every layer between origin and browser must either:

The second approach — edge termination — is the foundation of every strategy below.

Reference Table: OPTIONS Request Fields and Edge Handling Rules

Request field Type Role in preflight Edge handling requirement
Origin Request header Identifies the requesting origin Proxy must allowlist-match before reflecting
Access-Control-Request-Method Request header Declares the intended method Must appear in Access-Control-Allow-Methods of the 204
Access-Control-Request-Headers Request header Lists non-simple request headers Must be reflected in Access-Control-Allow-Headers when present
Access-Control-Allow-Origin Response header Grants or denies origin access Must survive every proxy hop; never wildcarded with credentials
Access-Control-Allow-Methods Response header Lists permitted methods Must match declared request method exactly
Access-Control-Allow-Headers Response header Lists permitted headers Case-insensitive match; reflects request headers list
Access-Control-Max-Age Response header Browser-side preflight cache TTL Proxy cache TTL must align; see Cache Duration Tuning & Max-Age
Vary Response header Scopes CDN cache by request dimension Must include Origin; omitting it causes cross-origin cache poisoning

Architecture: Where OPTIONS Termination Happens

The diagram below shows the two routing paths: the default (broken) path where OPTIONS reaches the backend, and the edge-termination path where the proxy intercepts and responds with 204 before the backend ever sees the request.

OPTIONS routing: default forwarding vs edge termination Two paths through the network. Top path: browser sends OPTIONS to proxy, proxy forwards to backend, backend returns 405 or missing CORS headers, browser blocks the request. Bottom path: browser sends OPTIONS to proxy, proxy intercepts and returns 204 with correct CORS headers, backend is never contacted, browser allows the subsequent request. Browser Edge Proxy / CDN Backend API Default (broken) Browser Proxy Backend OPTIONS OPTIONS forwarded 405 / missing headers Browser blocks ✗ Edge termination (correct) Browser Proxy (intercepts OPTIONS) Backend (not contacted) OPTIONS 204 + CORS headers ✓ Subsequent real request (GET/POST): Browser Proxy Backend

Step-by-Step Implementation: Nginx, Express, Envoy, AWS API Gateway, Fastly

Step 1 — Nginx: Intercept OPTIONS Before Upstream

Use a map block to build an allowlisted $cors_origin variable, then short-circuit OPTIONS in the location block. Using map rather than a naked $http_origin reflection prevents arbitrary origin injection.

map $http_origin $cors_origin {
  default         "";
  "https://app.example.com"   $http_origin;
  "https://admin.example.com" $http_origin;
}

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

  location /api/ {
    # Terminate preflight without contacting upstream
    if ($request_method = OPTIONS) {
      add_header Access-Control-Allow-Origin  $cors_origin always;
      add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
      add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Request-ID" always;
      add_header Access-Control-Max-Age       600 always;
      add_header Vary                         "Origin" always;
      return 204;
    }

    # Pass non-preflight requests upstream; origin adds CORS headers there
    proxy_pass http://backend_upstream;
    proxy_hide_header Access-Control-Allow-Origin;  # prevent duplication
    add_header Access-Control-Allow-Origin $cors_origin always;
    add_header Vary Origin always;
  }
}

The always flag on add_header ensures headers are appended even when Nginx returns error status codes — without it, headers silently vanish on 4xx and 5xx responses.

Step 2 — Express / Node.js: OPTIONS Handler as Middleware

Register a dedicated OPTIONS middleware before your route handlers so the response is returned before any authentication or database logic runs:

import express from 'express';

const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://admin.example.com',
]);

const app = express();

// Preflight handler — runs before auth middleware
app.options('/api/*', (req, res) => {
  const origin = req.headers.origin;
  if (ALLOWED_ORIGINS.has(origin)) {
    res
      .header('Access-Control-Allow-Origin', origin)
      .header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
      .header('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Request-ID')
      .header('Access-Control-Max-Age', '600')
      .header('Vary', 'Origin')
      .sendStatus(204);
  } else {
    // Origin not permitted — return 204 without CORS headers;
    // browser will block the subsequent request
    res.sendStatus(204);
  }
});

// Auth and route handlers follow
app.use('/api/', authMiddleware);
app.get('/api/resource', handler);

Placing the OPTIONS route before authMiddleware is deliberate: the preflight itself must not require credentials, per the Fetch spec §4.8 rule that the preflight fetch excludes credentials.

Step 3 — Envoy: direct_response Route

Envoy’s direct_response action returns a response entirely within the proxy plane — no cluster is selected, no backend TCP connection is opened:

virtual_hosts:
  - name: api_service
    domains: ["api.example.com"]
    routes:
      # Intercept OPTIONS before any other route
      - match:
          prefix: "/api/"
          headers:
            - name: ":method"
              string_match:
                exact: "OPTIONS"
        direct_response:
          status: 204
          body:
            inline_string: ""
        response_headers_to_add:
          - header:
              key: "Access-Control-Allow-Origin"
              value: "https://app.example.com"
          - header:
              key: "Access-Control-Allow-Methods"
              value: "GET, POST, PUT, DELETE, OPTIONS"
          - header:
              key: "Access-Control-Allow-Headers"
              value: "Authorization, Content-Type"
          - header:
              key: "Access-Control-Max-Age"
              value: "600"
          - header:
              key: "Vary"
              value: "Origin"

      # All other methods pass to cluster
      - match:
          prefix: "/api/"
        route:
          cluster: backend_api

Note: Envoy’s direct_response does not support per-request origin reflection through a map. If you need multi-origin allowlisting in Envoy, use a Lua filter or an ext-proc filter that reads :authority and origin headers before the direct response route fires.

Step 4 — AWS API Gateway: Mock Integration

An API Gateway mock integration terminates the OPTIONS method entirely within the gateway — no Lambda function or EC2 invocation occurs:

# OpenAPI 3 definition with x-amazon-apigateway-integration extensions
paths:
  /api/resource:
    options:
      summary: CORS preflight
      operationId: corsPreflightResource
      responses:
        "204":
          description: CORS preflight response
          headers:
            Access-Control-Allow-Origin:
              schema: {type: string}
            Access-Control-Allow-Methods:
              schema: {type: string}
            Access-Control-Allow-Headers:
              schema: {type: string}
            Access-Control-Max-Age:
              schema: {type: string}
      x-amazon-apigateway-integration:
        type: mock
        requestTemplates:
          application/json: '{"statusCode": 204}'
        responses:
          default:
            statusCode: "204"
            responseParameters:
              method.response.header.Access-Control-Allow-Origin:  "'https://app.example.com'"
              method.response.header.Access-Control-Allow-Methods: "'GET,POST,PUT,DELETE,OPTIONS'"
              method.response.header.Access-Control-Allow-Headers: "'Authorization,Content-Type'"
              method.response.header.Access-Control-Max-Age:       "'600'"

For HTTP APIs (not REST APIs), enable CORS in the API settings instead — HTTP API CORS configuration automatically generates the mock OPTIONS routes and is simpler to maintain.

Step 5 — Fastly VCL: Synthesise 204 in vcl_recv

sub vcl_recv {
  if (req.method == "OPTIONS") {
    # Synthesise response; vcl_deliver will add CORS headers
    return(synth(204, "No Content"));
  }
}

sub vcl_deliver {
  if (resp.status == 204 && req.method == "OPTIONS") {
    set resp.http.Access-Control-Allow-Origin  = "https://app.example.com";
    set resp.http.Access-Control-Allow-Methods = "GET, POST, PUT, DELETE, OPTIONS";
    set resp.http.Access-Control-Allow-Headers = "Authorization, Content-Type";
    set resp.http.Access-Control-Max-Age       = "600";
    set resp.http.Vary                         = "Origin";
  }
}

Multi-origin support in Fastly requires reading req.http.Origin in vcl_deliver and branching per origin value, or using a Fiddle/Compute@Edge function.

Edge-Case and Security Boundary Handling

Origin Allowlisting vs Reflection

Never reflect $http_origin or req.http.Origin directly without validating it against an allowlist. Blind reflection makes every origin that reaches the proxy appear permitted, which effectively converts your API into one that accepts requests from any browser page — including attacker-controlled domains. Always compare against a static set or a validated subdomain pattern.

Subdomain Normalisation and null-Origin

The null origin appears in Origin headers when requests originate from sandboxed iframes, data: URIs, or cross-origin redirects. Reflecting null in Access-Control-Allow-Origin: null grants access to those sandboxed contexts. Unless your application explicitly supports sandboxed-iframe workflows, treat null as an unmatched origin and return 204 without CORS headers.

Subdomain wildcards (*.example.com) are not valid in Access-Control-Allow-Origin. If you need to permit multiple subdomains, enumerate them in the allowlist map or perform a suffix match (origin.endsWith('.example.com') && origin.startsWith('https://')) in the proxy script layer, then reflect the exact matched value.

Avoiding Duplicate Headers

When the proxy terminates OPTIONS at the edge, the origin never emits CORS headers for that request — no duplication is possible. For non-OPTIONS requests that pass through to the origin, only one layer should add Access-Control-Allow-Origin. The Nginx example above uses proxy_hide_header Access-Control-Allow-Origin to strip upstream headers before the proxy injects its own. See Header Deduplication Techniques for the full analysis of how browsers react to duplicate headers in a comma-separated list versus repeated header lines.

Credentials and the Wildcard Prohibition

When the subsequent request will carry cookies or Authorization headers, Access-Control-Allow-Origin must be an exact origin — * is forbidden in that combination per the Fetch spec. Returning Access-Control-Allow-Origin: * on the preflight when the actual request includes withCredentials: true causes the browser to reject the real response, not the preflight. For correct credential handling, see Credential Sharing & Security Boundaries.

Proxy Cache TTL Alignment

If the edge proxy caches the 204 preflight response, its TTL must not exceed the value in Access-Control-Max-Age. A proxy caching a preflight for 3600 seconds while Access-Control-Max-Age is 600 means policy updates (for example, adding a new allowed header) are invisible to browsers hitting cached proxy responses for up to an hour. Set the proxy cache TTL to the Access-Control-Max-Age value or lower. For cache duration reasoning, see Cache Duration Tuning & Max-Age.

Proxy / CDN Interaction With Caching Layers

The Vary: Origin header on the preflight response tells shared caches to treat each unique Origin value as a separate cache entry. Omitting Vary: Origin from the edge-terminated 204 response means a CDN may serve one origin’s preflight response to a different origin — the receiving browser then sees Access-Control-Allow-Origin: https://app.example.com when its own origin is https://admin.example.com, and the preflight fails.

The Vary header must be set on the 204 response itself, not only on the non-OPTIONS responses. Some CDN configurations add Vary: Origin globally but only to cacheable status codes — verify that 204 responses are included in the global Vary policy.

DevTools + curl Verification Checklist

Use these checks after configuring your proxy to confirm edge termination is working correctly.

Common Mistakes Table

Issue Technical impact Mitigation
Proxy reflects $http_origin without allowlist Any browser origin gains CORS access to the API Use a map or set-lookup; only reflect matched values
add_header without always in Nginx Headers silently drop on error status codes (4xx, 5xx) Add always to every add_header Access-Control-* directive
Both proxy and origin emit Access-Control-Allow-Origin Browser sees duplicate headers and treats the response as malformed Strip upstream CORS headers at the proxy with proxy_hide_header
Proxy returns 200 with a JSON body for OPTIONS Body is buffered and parsed unnecessarily; some framework middleware may mismatch Return 204 No Content with an empty body
Proxy cache TTL longer than Access-Control-Max-Age Policy updates are invisible until proxy TTL expires Set proxy TTL ≤ Access-Control-Max-Age; flush caches on policy changes
Missing Vary: Origin on cached 204 responses CDN serves one origin’s preflight to another origin Always include Vary: Origin in the 204 response; verify with curl -sI
WAF blocks OPTIONS before proxy route fires Preflight receives 403 Forbidden; browser cannot distinguish this from a CORS rejection Add WAF method exception for OPTIONS on the relevant path prefix before testing proxy config
Returning Access-Control-Allow-Origin: * with credential requests Browser rejects the real credentialed response even when the preflight succeeds Reflect the exact allowlisted origin string; never use * with withCredentials

FAQ

Should my proxy cache CORS preflight responses?

Yes, but only if the proxy honours the origin’s Access-Control-Max-Age directive and varies the cache key on Origin, Access-Control-Request-Method, and Access-Control-Request-Headers. Varying on fewer dimensions leaks one origin’s allowed headers to another.

Why does my CDN return 403 for OPTIONS requests?

Most CDN WAF rule sets block OPTIONS by default because it is uncommon on non-CORS traffic. Add an explicit WAF exception or managed rule override for OPTIONS on the affected path prefix.

Can I configure a proxy to eliminate preflight entirely?

No. Preflight is enforced by the browser per the WHATWG Fetch specification. A proxy can terminate OPTIONS at the edge so the backend never sees it, but it cannot suppress the browser from sending it. The only server-side way to avoid preflight is to keep all requests within the “simple request” constraints defined in the Fetch spec — that means GET, HEAD, or POST with a Content-Type of application/x-www-form-urlencoded, multipart/form-data, or text/plain, and no custom headers.

What status code should a proxy return for an OPTIONS preflight?

204 No Content is conventional — it carries no body, minimising transfer size. Some implementations return 200 OK, which browsers also accept, but 200 may cause proxy body-buffering overhead. Avoid 405 Method Not Allowed, which tells the browser the preflight itself was rejected.

How do I prevent duplicate Access-Control-Allow-Origin headers when both the proxy and origin emit them?

If the proxy terminates OPTIONS and the origin never sees the request, duplication cannot occur for preflight. For non-OPTIONS traffic where the origin adds CORS headers, configure the proxy to strip upstream Access-Control-* headers before injecting its own, or suppress proxy CORS injection entirely for non-preflight routes. Full treatment is in Header Deduplication Techniques.