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

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.

Which part of a Content-Type value the safelist test reads A single Content-Type header line is split into three annotations: the essence before the semicolon is compared against three permitted media types, the parameters after it are never compared, and the whole value must stay under a byte limit and avoid forbidden characters. All three conditions feed one verdict strip. Only part of a Content-Type value takes part in the safelist comparison content-type: text/plain; charset=UTF-8 essence: text/plain compared, case-folded, against exactly three types parameter: charset=UTF-8 never part of the match, so any parameter is allowed the value as a whole stays at 128 bytes or less and avoids ( ) [ ] { } @ : ? bytes All three hold, so the header is safelisted and the request goes out with no preflight application/json fails the first test, so no charset or parameter tweak can rescue it

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.

Body value, generated Content-Type, and preflight outcome Six rows pair a fetch body value with the Content-Type the browser derives from it and the resulting classification. URLSearchParams, FormData, a raw string and a typed array stay on the simple path, while a JSON string with an explicit header always preflights and a Blob depends on its declared type. Value passed as fetch body Content-Type the browser generates Classification new URLSearchParams({a:1}) application/x-www-form-urlencoded ;charset=UTF-8 no preflight new FormData(formEl) multipart/form-data; boundary=generated-by-the-browser no preflight "event=signup&value=1" text/plain;charset=UTF-8 no preflight JSON.stringify(payload) with a hand-set header application/json always preflights new Blob([s], {type: t}) whatever t says it is depends on the type new Uint8Array(buffer) no header is sent at all no preflight A hand-set header always wins over the derived one, which is why removing it is usually the whole fix

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.

Message sequence with and without a safelisted Content-Type The left sequence shows four messages between the browser and the API: an OPTIONS preflight, a 204, the real POST and its 200. The right sequence shows only the POST and its 200, because the form-encoded body keeps the request on the simple path. Body labelled application/json Browser Collector API OPTIONS preflight 204 plus allow headers POST the real body 202 plus allow origin two round trips before any data moves Body labelled x-www-form-urlencoded Browser Collector API POST the real body 202 plus allow origin one round trip, no OPTIONS at all The saving repeats on every cold cache entry, not just the first request of the session

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.