Content-Type Values That Avoid a CORS Preflight
An endpoint that worked from Postman starts failing the moment the same call is made from a browser page, and the console prints a complaint about a header nobody consciously added:
Access to fetch at 'https://collect.acme-metrics.io/v1/events' from origin
'https://dash.acme-metrics.io' has been blocked by CORS policy: Request header
field content-type is not allowed by Access-Control-Allow-Headers in preflight response.
The header in question is Content-Type: application/json, and it is the single most common reason a request that looks trivially simple gets promoted into a two-round-trip preflight negotiation.
Root Cause
The Fetch Standard defines a small set of CORS-safelisted request headers. A cross-origin request stays simple — sent immediately, with no OPTIONS probe in front of it — only while every header the page sets is on that safelist. Content-Type is on the safelist, but conditionally: its value must parse to one of exactly three media types. application/json is not one of them, so setting it makes Content-Type a non-safelisted header, and the browser must ask permission before sending it. That permission request is the preflight, and it fails here because the server’s Access-Control-Allow-Headers never listed content-type.
The three safelisted values are the three encodings an HTML <form> element can produce without any script: application/x-www-form-urlencoded, multipart/form-data, and text/plain. That is not a coincidence. Those payloads have always been sendable cross-site by any page on the web, so allowing script to send them adds no new capability, and the browser skips the negotiation. Everything else — JSON included — is new capability, and new capability needs an opt-in from the server.
This page sits under Simple vs Preflight Requests: CORS Mechanics, which covers the whole classification decision; here we deal only with the Content-Type half of it, and with the practical question of when it is worth reshaping a request body to stay on the fast path.
Prerequisite State
- A browser page on one origin (
https://dash.acme-metrics.io) calling an API on another (https://collect.acme-metrics.io). - The API already returns a correct
Access-Control-Allow-Originon real responses — if it does not, fix that first with Debugging Missing Access-Control-Allow-Origin Header. - You can change both the client call and the server’s body parser. If you can only change one side, the safelist route is usually closed to you and tuning the preflight cache is the better lever.
curlavailable locally, and DevTools open with Disable cache unticked so you can watch the preflight cache behave normally.
Step-by-Step Fix
Step 1 — Prove the Content-Type is the trigger
Send the same request twice with curl: once with the header, once without. Only the first should draw a preflight-shaped rejection from the server.
# With the JSON content type — the browser would preflight this
curl -sD - -o /dev/null -X OPTIONS https://collect.acme-metrics.io/v1/events \
-H 'Origin: https://dash.acme-metrics.io' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type' \
| grep -i 'access-control-allow-headers'
If that prints nothing, the server never authorised content-type and every JSON POST from the page is dead on arrival. Keep the output — you will re-run it in the verification step. For a fuller walkthrough of hand-built probes see Simulating a Preflight with curl -X OPTIONS.
Step 2 — Learn what the browser actually compares
The comparison is narrower than most people expect. The browser lowercases the value, splits off everything from the first semicolon, trims whitespace, and compares only the remaining essence against the three permitted strings. Parameters are not compared at all — but they still count toward two byte-level limits that can knock an otherwise safelisted value off the list.
The full rule set, in the order the browser applies it:
| Condition | Rule | Typical violation |
|---|---|---|
| Essence | Lowercased text before the first ;, whitespace-trimmed, must equal application/x-www-form-urlencoded, multipart/form-data, or text/plain |
application/json, application/graphql, text/xml |
| Parameters | Ignored by the comparison; charset, boundary and vendor parameters are all fine |
none — parameters never break the essence match |
| Value length | The complete header value must be 128 bytes or fewer | a multipart/form-data boundary long enough to push the line past 128 bytes |
| Byte set | The value must not contain ", (, ), :, <, >, ?, @, [, \, ], {, }, or a control byte |
a hand-built boundary containing brackets or a colon |
| Case | The essence match is case-insensitive; TEXT/PLAIN is safelisted |
none in practice |
That last pair of rows is the trap nobody sees coming: a value can be a safelisted media type and still force a preflight because a parameter dragged it over the byte limit or contained a forbidden character. Browsers generate their own multipart/form-data boundaries specifically so this cannot happen; hand-setting the header is what breaks it.
Step 3 — Let fetch pick the header for you
The cleanest fix is to stop setting Content-Type at all and choose a body value whose implied type is already safelisted. fetch() and XMLHttpRequest derive the header from the body object, and the derived value is always well-formed.
For a key/value payload, URLSearchParams is the least invasive change:
// Before: JSON body, non-safelisted header, one preflight per unique URL
await fetch("https://collect.acme-metrics.io/v1/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "signup", value: 1 }),
});
// After: URLSearchParams body, header derived and safelisted, no preflight
await fetch("https://collect.acme-metrics.io/v1/events", {
method: "POST",
body: new URLSearchParams({ event: "signup", value: "1" }),
});
Two details decide whether this works. First, do not re-add headers: { "Content-Type": ... } — an explicit header overrides the derived one and puts you straight back on the preflight path. Second, URLSearchParams stringifies every value, so 1 becomes "1" and nested objects flatten to [object Object]; only flat payloads survive the trip.
When the payload genuinely is a nested document, send the JSON text under a safelisted type instead:
await fetch("https://collect.acme-metrics.io/v1/events", {
method: "POST",
// text/plain is safelisted; the body is still JSON, the label just does not say so
headers: { "Content-Type": "text/plain;charset=UTF-8" },
body: JSON.stringify({ event: "signup", traits: { plan: "team", seats: 12 } }),
});
Step 4 — Teach the server to read the new body
A safelisted request that the server cannot parse is not progress. Express needs the matching parser mounted, and for the text/plain variant it needs an explicit type match because the default JSON parser will not touch that content type:
const express = require("express");
const app = express();
// For the URLSearchParams variant
app.use(express.urlencoded({ extended: false, limit: "32kb" }));
// For the JSON-inside-text/plain variant
app.use(express.text({ type: "text/plain", limit: "32kb" }));
app.post("/v1/events", (req, res) => {
const payload = typeof req.body === "string" ? JSON.parse(req.body) : req.body;
res.set("Access-Control-Allow-Origin", "https://dash.acme-metrics.io");
res.set("Vary", "Origin");
res.status(202).json({ accepted: payload.event });
});
app.listen(8080);
The same shape in FastAPI, where the raw body is read and decoded by hand:
import json
from fastapi import FastAPI, Request, Response
app = FastAPI()
@app.post("/v1/events")
async def ingest(request: Request, response: Response):
raw = await request.body()
payload = json.loads(raw.decode("utf-8"))
response.headers["Access-Control-Allow-Origin"] = "https://dash.acme-metrics.io"
response.headers["Vary"] = "Origin"
return {"accepted": payload["event"]}
Wrap json.loads in a guard before this reaches production: a body arriving as text/plain carries no promise of being valid JSON, and an unhandled parse error becomes a 500 that the browser will report as a generic network failure rather than a decoding problem.
Step 5 — Put back the protection the preflight was accidentally providing
The preflight was never a security control, but it was a barrier: a cross-site page could not make a browser send Content-Type: application/json to your API without your server’s cooperation. A safelisted body has no such barrier, because a plain HTML form on any site can produce exactly the same request. Add a control that does not depend on the request being non-simple:
// Reject state-changing requests that did not arrive with a matching double-submit token
app.post("/v1/events", (req, res, next) => {
const header = req.get("X-Csrf-Token");
if (!header || header !== req.cookies.csrf_token) {
return res.status(403).json({ error: "csrf_token_mismatch" });
}
next();
});
Note the irony worth naming out loud: X-Csrf-Token is itself a non-safelisted header, so adding it re-introduces the preflight. That is the correct outcome for an authenticated write endpoint. Keep the safelisted shape for anonymous, idempotent, low-value traffic such as telemetry, and accept the preflight where a real trust decision is being made. If you need both — authenticated writes and low latency — tune the cache instead, using How to Set Access-Control-Max-Age Effectively.
What the Change Actually Buys
One preflight is one extra round trip, paid before the real request may start, per unique method and URL combination until the browser’s preflight cache warms.
On a fast connection that is a few tens of milliseconds. On a mobile network reaching a distant origin it is often 200 ms or more, and it lands on the very first interaction of a session, when the preflight cache is empty. The measurement technique is covered in Measuring CORS Preflight Latency in Production.
Verification
Confirm the real request now succeeds without any OPTIONS in front of it:
curl -sD - -o /dev/null -X POST https://collect.acme-metrics.io/v1/events \
-H 'Origin: https://dash.acme-metrics.io' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'event=signup&value=1'
Expect HTTP/2 202 together with access-control-allow-origin: https://dash.acme-metrics.io and vary: origin. Then walk the checklist in the browser:
For a guided read of the panel itself see Reading a Preflight OPTIONS Request in DevTools.
Security Boundary Note
Avoiding the preflight changes nothing about who may read your response — that is still governed entirely by Access-Control-Allow-Origin, and a page from an unlisted origin remains unable to see the reply. What it changes is who may cause the request. A safelisted body is exactly the shape an HTML form can post cross-site, so a hostile page can fire the request blind, with the user’s cookies attached if your cookies allow it. Treat every endpoint you move onto the simple path as publicly reachable and unauthenticated by shape, and defend it with a token check, a SameSite=Lax or SameSite=Strict session cookie, or by keeping it strictly idempotent. The interaction between cookie policy and cross-origin credentials is unpacked in SameSite=None vs CORS Credentials: The Tradeoffs.
Common Mistakes
| Mistake | Technical impact | Fix |
|---|---|---|
Setting Content-Type by hand next to a URLSearchParams body |
The explicit header overrides the derived one, so a value such as application/json reinstates the preflight the change was meant to remove |
Delete the headers entry entirely and let the body object decide |
Assuming application/json; charset=UTF-8 is treated differently from application/json |
Parameters are stripped before comparison, so both preflight identically and hours are lost tweaking the parameter | Compare only the essence against the three permitted values |
| Switching the client to form encoding without changing the server parser | The body arrives intact but req.body is empty or a raw string, producing a 400 that looks like a CORS problem in the console |
Mount express.urlencoded() or express.text({ type: "text/plain" }) to match the new encoding |
| Moving an authenticated write endpoint onto the simple path | The route becomes forgeable from any cross-site form with ambient cookies attached | Keep the preflight on authenticated writes, or enforce a double-submit token and a strict SameSite cookie |
FAQ
Does adding a charset parameter to application/json make it safelisted?
No. The browser strips every parameter before it compares, so application/json and application/json; charset=UTF-8 are judged identically on the essence application/json, which is not one of the three safelisted values. Parameters can only take a value out of the safelist, never into it, which happens when a parameter contains a byte such as a parenthesis or a colon that is forbidden in a safelisted header value.
Is sending JSON as text/plain a hack I will regret?
It is legitimate but it costs you two things. Your server must parse a body whose declared type lies about its shape, which breaks schema tooling and content negotiation, and the endpoint becomes reachable by a plain cross-site HTML form, so any state-changing route needs an explicit anti-forgery control. On a hot analytics or telemetry endpoint the trade is often worth it; on an authenticated write API it usually is not.
Why does my URLSearchParams request still trigger a preflight?
Because something else on the request is not safelisted. The Content-Type is only one of the conditions: an Authorization header, an X-Request-ID header, a non-safelisted method such as PATCH, or a ReadableStream body each force the preflight on their own. Open the request in DevTools, compare its header list against the safelist, and remove or relocate whichever field is the outlier.