CORS Preflight in Practice: Credentials, Simple Requests, Misconfigurations

Easy•

CORS allows a server to tell the browser which other origins are allowed to read its responses. For some cross-origin requests, the browser first performs a preflight request using OPTIONS to ask whether the actual request is allowed. The most common real-world failures happen when the frontend sends credentials or custom headers, but the server's CORS response does not match what the browser expects.

Quick Decision Guide

Decision Guide:

- Start with the question: is this request simple or preflighted? - Preflight means the browser sends an OPTIONS request first. - The browser checks origin, method, and requested headers before sending the real request. - Credentialed CORS is stricter: the server must explicitly allow credentials, and Access-Control-Allow-Origin: * does not work with credentials. - Many CORS bugs are not frontend code bugs. They are policy mismatches between browser, API server, proxy, and CDN.

Interview framing: CORS is a browser-enforced access control layer on top of HTTP. Debugging it means reasoning across the request flow, not just reading one frontend error message.

CORS Mental Model

CORS is not a server-side security feature by itself.

It is a browser enforcement mechanism for cross-origin reads.

That means:

•the request may still reach the server
•the server may even return 200 OK
•but the browser can still block your JavaScript from reading the response

Same-origin refresher

Two URLs are considered different origins if they differ in scheme, host, or port.

Examples:

•https://app.example.com → different from http://app.example.com
•https://app.example.com → different from https://api.example.com
•https://app.example.com:443 → different from https://app.example.com:8443

High-level flow

Frontend code on Origin A
        |
        | fetch()/XHR
        v
Browser checks CORS rules
        |
        |-- simple request -> send actual request directly
        |
        |-- non-simple request -> send preflight OPTIONS first
                                |
                                v
                       Server approves or denies
                                |
                                v
                       Browser decides whether JS can read response

Simple Requests vs Preflighted Requests

A request is commonly treated as simple only if it stays within a narrow safe set.

Usually simple

•method is GET, HEAD, or POST
•manually set headers stay within the safelisted request headers
•Content-Type, if set, stays within:

- application/x-www-form-urlencoded

- multipart/form-data

- text/plain

Common preflight triggers

•method like PUT, PATCH, or DELETE
•custom request headers like Authorization or X-*
•Content-Type: application/json
•other request characteristics outside the simple request rules

Interview trap

A POST request is not automatically simple.

For example:

fetch('https://api.example.com/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ ok: true })
});

This often triggers preflight because application/json is not one of the simple content types.

Quick classifier

GET / HEAD / POST
+ no custom headers
+ simple content-type
=> likely simple

PUT / PATCH / DELETE
OR Authorization / X-Custom-Header
OR Content-Type: application/json
=> preflight likely

Preflight Request Flow

When the browser decides a request is non-simple, it first sends an OPTIONS request.

The goal is to ask the server:

•do you allow this origin?
•do you allow this method?
•do you allow these request headers?

Browser-driven preflight

1. Browser wants to send:
   POST /orders
   Origin: https://app.example.com
   Content-Type: application/json
   Authorization: Bearer ...

2. Browser first sends:
   OPTIONS /orders
   Origin: https://app.example.com
   Access-Control-Request-Method: POST
   Access-Control-Request-Headers: content-type, authorization

3. Server responds with policy:
   Access-Control-Allow-Origin: https://app.example.com
   Access-Control-Allow-Methods: POST, GET, OPTIONS
   Access-Control-Allow-Headers: Content-Type, Authorization
   Access-Control-Max-Age: 86400

4. If browser accepts the policy, it sends the real POST request.

Preflight flow diagram

ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│  Frontend JS │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
       │ fetch(...)
       v
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│   Browser    │
│ CORS engine  │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
       │ OPTIONS preflight
       v
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│ API / Proxy  │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
       │ Access-Control-Allow-*
       v
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│   Browser    │
│ validates    │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
       │ allowed
       v
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│ Actual call  │
│ POST/PUT/... │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜

Important note

Preflight is about permission to send the actual request.

It does not guarantee the actual request's final response will be readable. The actual response must also satisfy the needed CORS rules.

Credentials Make CORS Stricter

Credentialed cross-origin requests include things like:

•cookies
•HTTP authentication
•TLS client certificate behavior in some environments

Frontend example

fetch('https://api.example.com/me', {
  credentials: 'include'
});

Server requirements

For credentialed requests, the server must be explicit:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

Critical rule

This does not work:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

If credentials are involved, wildcard origin is invalid for exposing the response.

Practical mental model

No credentials:
  ACAO can sometimes be *

With credentials:
  ACAO must be a specific allowed origin
  ACAC must be true

Another real-world gotcha

Even when server and client look correct, third-party cookie policies can still block cookie-based flows. So credentialed CORS can fail because of browser cookie policy, not only because of missing CORS headers.

Important CORS Headers

Request headers sent by the browser

#### Origin

Sent on cross-origin requests so the server knows who is asking.

#### Access-Control-Request-Method

Sent on preflight to tell the server which actual method the browser wants to use.

#### Access-Control-Request-Headers

Sent on preflight to tell the server which non-safelisted headers will be used.

Response headers from the server

#### Access-Control-Allow-Origin

Which origin is allowed to read the response.

#### Access-Control-Allow-Methods

Which methods are allowed for the actual request after preflight.

#### Access-Control-Allow-Headers

Which request headers are allowed for the actual request after preflight.

#### Access-Control-Allow-Credentials

Whether the browser may expose the response when credentials are included.

#### Access-Control-Max-Age

How long the preflight result may be cached by the browser.

#### Access-Control-Expose-Headers

Which response headers JavaScript is allowed to read beyond the default exposed set.

Production nuance: `Vary: Origin`

If your server dynamically echoes back an allowed origin instead of using *, you usually also want:

Vary: Origin

That helps caches understand that the response can differ by request origin.

Common Misconfigurations

1) `application/json` unexpectedly triggers preflight

Teams assume a POST is simple, but JSON content type often makes it preflighted.

2) Missing one header in `Access-Control-Allow-Headers`

The frontend sends:

Authorization, Content-Type

But the server only allows:

Access-Control-Allow-Headers: Content-Type

Result: preflight fails.

3) Credentials enabled on client, not allowed on server

Client:

fetch(url, { credentials: 'include' })

Server forgets:

Access-Control-Allow-Credentials: true

Result: browser blocks response exposure.

4) Wildcard origin with credentials

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Looks permissive, but browser rejects it for credentialed access.

5) Preflight passes but actual response still fails

Some teams only add CORS headers on OPTIONS, not on the actual GET/POST/PUT response.

The browser may allow the send, then still block JS from reading the final response.

6) Proxy/CDN rewrites headers

Your backend is correct, but an edge layer strips Vary: Origin or overwrites Access-Control-Allow-* headers.

7) Redirects after preflight

Cross-origin redirects after a preflighted request can still fail in some browser situations and are a painful debugging case.

Debugging Mindset

When debugging CORS, do not stop at the frontend error overlay.

Open DevTools and inspect the network flow.

What to check

1. What is the page origin?

2. Did the browser send a preflight OPTIONS request?

3. What did preflight send in:

- Origin

- Access-Control-Request-Method

- Access-Control-Request-Headers

4. What did the preflight response return in:

- Access-Control-Allow-Origin

- Access-Control-Allow-Methods

- Access-Control-Allow-Headers

- Access-Control-Allow-Credentials

5. Did the actual response also include the needed CORS headers?

6. Are credentials involved?

7. Is a proxy, CDN, gateway, or nginx layer changing headers?

Practical debugging flow

Frontend error says: blocked by CORS
        |
        v
Check Network tab
        |
        |-- no OPTIONS request?
        |      likely simple request or blocked before preflight scenario
        |
        |-- OPTIONS exists?
               inspect request + response headers
               confirm exact method + exact requested headers
               confirm origin handling
               confirm credentials rules
        |
        v
Then inspect actual request response
        |
        v
Then inspect proxy/CDN/server config mismatch

Senior-level insight

CORS debugging is often about finding the layer that owns the policy:

•application server
•API gateway
•reverse proxy
•CDN / edge middleware
•browser cookie policy

Interview Scenarios

Scenario 1: Why does `GET` work but `PUT` fail?

Because PUT commonly triggers preflight, while the GET may be simple enough to skip it.

Scenario 2: Why does Postman work but browser fails?

Because CORS is a browser enforcement mechanism. Tools like Postman are not enforcing browser CORS policy the same way.

Scenario 3: Why does localhost work but production fails?

Because local dev often uses a same-origin dev proxy, while production becomes truly cross-origin.

Scenario 4: Why does server return `200` but frontend still errors?

Because the browser received the response but refused to expose it to JavaScript.

Scenario 5: Why are cookies not being sent?

Possible reasons:

•client did not set credentials: 'include'
•server did not send Access-Control-Allow-Credentials: true
•server used wildcard origin
•browser third-party cookie policy blocked it

Key Takeaways

1CORS is enforced by the browser, not by Postman-like clients.
2A preflight is an OPTIONS request sent before certain non-simple cross-origin requests.
3POST is not always simple; application/json often triggers preflight.
4Credentialed requests require explicit origin handling and Access-Control-Allow-Credentials: true.
5Wildcard origin does not work with credentialed CORS.
6Preflight success does not guarantee the final response will be readable by JavaScript.
7When origin is echoed dynamically, Vary: Origin is an important production detail.
8Debugging CORS means inspecting both the preflight and the actual response.