CORS Preflight Requests Explained

Networking & Caching

You're building a React app on localhost:3000 that calls an API on api.example.com. You open the browser, fire a request, and see this in the console:

Access to fetch at 'https://api.example.com/data' from origin
'http://localhost:3000' has been blocked by CORS policy.

Before you add Access-Control-Allow-Origin: * to every response and move on, it's worth understanding why CORS exists and what the browser is actually doing — because the real solution depends heavily on the situation.

Why CORS Exists: The Same-Origin Policy

The same-origin policy is a browser security rule: scripts from one origin cannot read responses from a different origin. An origin is the combination of protocol + hostname + port. http://localhost:3000 and https://api.example.com are different origins.

Without this restriction, a malicious page could use your authenticated session cookies to silently call your bank's API and read the response. The same-origin policy prevents that.

CORS (Cross-Origin Resource Sharing) is the mechanism that lets servers opt in to cross-origin reads — declaring which origins, methods, and headers they trust.

Simple Requests: No Preflight

Not all cross-origin requests trigger a preflight. A request is "simple" if it meets all of these conditions:

  • Method is GET, POST, or HEAD
  • Headers are only Accept, Accept-Language, Content-Language, Content-Type
  • Content-Type is application/x-www-form-urlencoded, multipart/form-data, or text/plain

Simple requests reflect the types of cross-origin requests that existed before CORS — HTML form submissions, image loads. The browser sends them directly, and the server response either includes Access-Control-Allow-Origin (allowed) or doesn't (blocked by the browser after the fact).

Preflighted Requests: The OPTIONS Dance

Any request that doesn't meet the simple request criteria triggers a preflight. The browser automatically sends an OPTIONS request to the target URL before your actual request:

OPTIONS /api/data HTTP/1.1
Origin: http://localhost:3000
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Authorization, Content-Type

The browser is asking: "I want to send a PUT request with Authorization and Content-Type headers. Is that allowed?"

The server must respond with the appropriate CORS headers:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

Access-Control-Max-Age tells the browser to cache this preflight result for 86,400 seconds (1 day) — avoiding a preflight on every subsequent request to the same endpoint.

If the server responds correctly, the browser then sends the actual request. If not — or if the server returns an error or wrong headers — the request is blocked and you see a CORS error.

Common Triggers for a Preflight

ConditionTriggers preflight?
GET with no custom headersNo
POST with Content-Type: application/jsonYes
Any PUT, PATCH, DELETEYes
Any custom header (e.g. Authorization, X-Request-ID)Yes

The Content-Type: application/json case surprises many developers. A JSON body is not in the simple request allowlist, so POST with JSON always preflights.

Setting Headers Correctly on the Server

The exact headers your server needs to set for a typical JSON API:

Access-Control-Allow-Origin: https://yourfrontend.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true   (only if sending cookies)

Important: Access-Control-Allow-Origin: * cannot be combined with Access-Control-Allow-Credentials: true. If you need to send cookies or Authorization headers, you must whitelist the specific origin.

For Express.js, the cors package handles this:

import cors from 'cors'

app.use(
  cors({
    origin: 'https://yourfrontend.com',
    methods: ['GET', 'POST', 'PUT', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization'],
    credentials: true,
  }),
)

// Explicitly handle OPTIONS preflight
app.options('*', cors())

CORS Is a Browser Restriction, Not a Server Restriction

CORS is enforced by the browser, not the server. The server always receives and processes the request — CORS only controls whether the browser allows your JavaScript to read the response.

This means:

  • Server-to-server requests are never affected by CORS
  • curl and Postman are never affected by CORS
  • A browser extension or a proxy that adds headers can bypass CORS
  • CORS doesn't protect your server from malicious requests — it protects users from malicious pages

Debugging CORS Issues

  1. Check the Network tab, not just the Console. Select the failed request and look at the Response Headers. Are the Access-Control-Allow-* headers present?
  2. Check the preflight separately. Filter requests by OPTIONS method. Did the preflight succeed with a 200 or 204?
  3. Don't use Access-Control-Allow-Origin: * with credentials. This combination is invalid and causes a different type of CORS error.
  4. Verify the origin. The header must exactly match your origin — including protocol and port. http://localhost:3000https://localhost:3000.

Conclusion

CORS preflight requests are the browser's safety mechanism for cross-origin requests that could have side effects — anything beyond simple GET/POST or requests with sensitive headers. The browser asks the server for permission with an automatic OPTIONS request before sending the real one. The server grants permission with Access-Control-Allow-* headers. Understanding this flow turns a cryptic console error into a straightforward configuration task.