CORS Preflight Explained: OPTIONS and the Actual Response
By Ankit Jain · Updated
Follow JSON POST and PUT requests from the browser’s permission probe to the actual API response, including authentication and caching.
A preflight is the browser’s OPTIONS request asking permission to send a cross-origin request with a particular method and non-safelisted headers. It contains header names, not your JSON body or bearer token.
Follow a preflighted JSON POST
Content-Type: application/json triggers preflight even though POST is a safelisted method. Follow the permission check and actual request separately:
The browser has no cached preflight permission. fetch uses its default credentials setting. This request has no cross-origin cookies or Authorization header.
Page: https://app.example.com
Server API: https://api.example.com
Loading diagram. You can read the request and response details below while it loads.
On a small screen, scroll sideways to see the full diagram.
Read request and response details
-
JavaScript → Browser: Ask to send JSON
const response = await fetch('https://api.example.com/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'hello' }) }); -
Browser → Server API: Preflight request
OPTIONS /messages Origin: https://app.example.com Access-Control-Request-Method: POST Access-Control-Request-Headers: content-typeThe browser asks about the header name. It does not send the JSON body in OPTIONS.
-
Server API → Browser: Preflight response
204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: POST Access-Control-Allow-Headers: Content-Type Vary: Origin -
Browser check: The server allows the POST
The preflight succeeds and allows the origin and request header. The browser sends the POST. The OPTIONS response is handled by the browser; it is not the result returned by fetch.
-
Browser → Server API: Actual request
POST /messages Origin: https://app.example.com Content-Type: application/json {"message":"hello"} -
Server API → Browser: Actual response
201 Created Access-Control-Allow-Origin: https://app.example.com Vary: Origin Content-Type: application/json {"id":123} -
Browser check: The POST response allows this origin
The browser checks the POST response for origin permission too. It needs its own CORS headers even though preflight succeeded.
-
Browser → JavaScript: JavaScript receives the POST response
response.type // "cors" response.status // 201 response.ok // true await response.json() // {"id":123}
If preflight fails, the POST is not sent. If only the POST response lacks CORS permission, the POST was sent but JavaScript cannot read its response.
Only the relevant headers and bodies are shown. Browsers add other headers. Your code reads the response body with response.json() or another body-reading method.
Walk through a JSON PUT
The page at https://app.example.com intends to send a PUT with Content-Type: application/json and Authorization. The browser first sends:
OPTIONS /profile HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type
The API can approve that request with:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Headers: Authorization, Content-Type
Vary: Origin
The browser can then send the actual PUT. That response needs its own permission:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
Content-Type: application/json
{"updated":true}
If the frontend uses credentials: include, add Access-Control-Allow-Credentials: true to both permission responses. The preflight itself normally carries no cookies. An auth filter that requires a session on OPTIONS can therefore prevent the real request from ever happening. Authenticate and authorize the actual request.
What if it fails?
A non-2xx OPTIONS response, missing origin permission, or rejected method/header stops the actual request. A later 401 on the actual request is different: with valid CORS permission, JavaScript can read that 401 and handle it.
Why OPTIONS sometimes disappears
Browsers maintain a preflight cache. Access-Control-Max-Age can allow permission reuse, subject to browser caps. HTTP cache controls do not directly clear that dedicated permission cache. Verify changed policy in a fresh browser context when a prior permission may still be cached.
Next: CORS headers and response exposure. For a failure, inspect OPTIONS.