WEB / FIELD NOTES

Why a CORS error can mean the server never saw the POST

A cross-origin JSON POST makes the browser send OPTIONS first. A fictional pair of hosts separates a failed preflight, which never sends POST, from a POST the script still cannot read.

The page is https://notes.example, and the save URL is https://api.example/notes. Saving a title sends a POST with JSON. The console can show a CORS error while the API log has no POST. The reverse happens too: the log shows a saved note, and the script still cannot read the response body. Those two results are not the server rejecting the same thing.

The hosts, the token string example-token, and the title “milk” are fictional. The Korean diagram translates that same title. This article was not written by sending the request or collecting a server log.

A different origin keeps the response from script

An origin is scheme, host, and port together. https://notes.example and https://api.example differ by host. So do http and https, and localhost ports 3000 and 3001. The path is not part of the origin.

A same-origin fetch skips this check. A fetch started from a URL string uses CORS mode on another origin. Until the response allows this page's origin, script cannot read the body. The browser does the check. curl and a server-to-server call do not. A 200 from curl is not permission for script to read the body.

Diagram separating the fictional page https://notes.example from the fictional API https://api.example. Same-origin calls skip this check. A different localhost port, or http versus https, is also a different origin.
A different host, port, or scheme is a different origin.

JSON and Authorization make OPTIONS go first

The conditions older guides called a simple request are still there. The Fetch standard does not use that name. It decides from the method and the headers. GET, HEAD, and POST with safelisted headers go out without a preflight. Whether script may read the response is a separate check.

The safelisted names are Accept, Accept-Language, Content-Language, Content-Type, and Range. Content-Type is safe only when the parsed type is application/x-www-form-urlencoded, multipart/form-data, or text/plain. application/json is not on that list, and it is not one of the report exceptions. Range is safelisted only as a single range with a start, such as bytes=256-.

The fictional request is POST /notes, Content-Type application/json, Authorization Bearer example-token, and the body {"title":"milk"}. POST is a safelisted method, so the headers create the OPTIONS. A value over 128 bytes, or safelisted values totaling over 1024 bytes, also requires a preflight. This example assumes shorter values.

Diagram of why the fictional POST /notes starts with OPTIONS. POST is a safelisted method. Content-Type application/json and Authorization Bearer example-token are not safelisted. The example values are assumed to stay under the 128-byte and 1024-byte limits.
OPTIONS comes from JSON and Authorization, not from POST itself.

The browser does not send POST until OPTIONS passes

When the request qualifies, OPTIONS goes to the same URL first. Access-Control-Request-Method is POST. Access-Control-Request-Headers joins the unsafe names in lowercase with a comma and no space. Here that value is authorization,content-type, and those headers are not attached again to the POST.

The preflight credentials mode is always same-origin, so a cross-origin preflight carries neither cookies nor HTTP authentication. A later request can include cookies. OPTIONS does not send them ahead.

The preflight continues only when the status is in the 200 range and the CORS check passes. A 401 or 404 fails even with Access-Control-Allow-Origin, and then the real POST is not sent. That is one reason a log can lack a POST. If OPTIONS is dropped before the app, the log can be empty. An empty log does not by itself mean the browser sent nothing.

Allow-Headers has to include the name authorization. That header is not covered by *. content-type can be covered by * when credentials are not included. PUT, DELETE, and PATCH are not safelisted methods, so Access-Control-Allow-Methods has to allow the method. Without credentials, * in that list can stand in for the method.

Sequence for the fictional request. OPTIONS sends Access-Control-Request-Method POST and Request-Headers authorization,content-type. Only after a 200-range status that allows authorization does POST send the body. The POST response also needs a matching Allow-Origin before script can read the body.
POST leaves only after OPTIONS passes, and that response is checked again.

A POST can arrive and still be unreadable

After the preflight, the POST carries the body and Authorization. The default fetch credentials mode is same-origin, so cookies are not sent cross-origin. Cookies require credentials set to include.

The POST response is checked again. A missing or mismatched Allow-Origin fails. For a request that is not include, * passes. With include, the value must equal the request origin, Allow-Credentials must be the case-sensitive true, and * does not stand in for names in Allow-Origin, Allow-Headers, or Allow-Methods. On failure, script cannot read the body, and the browser does not say whether the server already stored the POST. That failure does not undo a save.

The response headers script can read by default are Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, and Pragma. Other names belong in Access-Control-Expose-Headers. Without credentials, * can stand in for that list. Set-Cookie and Set-Cookie2 stay hidden from script.

With no Access-Control-Max-Age, the allowed methods and headers are cached for 5 seconds. A browser may impose a shorter limit and may drop the entry earlier. 86400 does not promise that tomorrow's call skips the preflight. Entries are separated by origin, URL, and whether credentials are included.

Three branches of the same fictional POST. A failed OPTIONS never sends POST. If POST was sent but the response check fails, the server may have stored the note while script still cannot read the body. When both checks pass, script can read the body. With no Max-Age, the allow-cache default is 5 seconds.
A failed OPTIONS and a failed POST response leave different evidence.

A console line does not replace the server log

Script cannot read why CORS failed. The missing header belongs to the console. How far the request got belongs to the method the server received.

Write down both origins, whether the call is POST with application/json and Authorization, whether credentials is include, the OPTIONS status, whether POST arrived, and the response Allow-Origin. With include, look first for a * Allow-Origin and a missing Allow-Credentials.

A redirect to another origin drops Authorization. Following a redirect after a preflight can still differ by browser, and this article did not re-run that difference.

When only the console fails, do not start from the idea that the server rejected the POST. First separate an OPTIONS failure that never sent POST from a POST that was handled without the headers script needs in order to read the response.

END OF NOTEBack to the library
COMMENTS BOX

Add your perspective.

Share a question, another approach, or something you have tried.

Newest first

Checking sign-in…

Loading comments…