CORS Preflight Requests Explained
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, orHEAD - Headers are only
Accept,Accept-Language,Content-Language,Content-Type Content-Typeisapplication/x-www-form-urlencoded,multipart/form-data, ortext/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
| Condition | Triggers preflight? |
|---|---|
GET with no custom headers | No |
POST with Content-Type: application/json | Yes |
Any PUT, PATCH, DELETE | Yes |
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
curland 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
- 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? - Check the preflight separately. Filter requests by
OPTIONSmethod. Did the preflight succeed with a 200 or 204? - Don't use
Access-Control-Allow-Origin: *with credentials. This combination is invalid and causes a different type of CORS error. - Verify the origin. The header must exactly match your origin — including protocol and port.
http://localhost:3000≠https://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.
