TL;DR
- The bug is usually the preflight (
OPTIONS), not yourfetchcall or the client library. -
Access-Control-Allow-Origin: *andcredentials: 'include'never work together. - Echo an exact origin from an allowlist — not a wildcard — when the SPA and API sit on different hosts.
Your SPA lives on https://app.example.com. Your API lives on https://api.example.com. You open DevTools, hit a button, and get a red CORS error. The first instinct is to blame axios, the SDK, or “browser fetch being weird.”
Pause. Cross-origin requests that are not simple GETs almost always send an OPTIONS preflight first. If that preflight fails, the real request never leaves the browser — and nothing you change in the client will fix a missing or wrong response header on the API.
This post is a portable SPA→API CORS recipe: minimal repro, Express and Caddy configs that engineers actually paste, the credentials gotcha, and a failure checklist. No product tour. Just headers that make the browser happy.
Why “but curl works” lies
Curl never sends a CORS preflight. The browser does — and it can block the real request before your route handler runs.
From a terminal, curl -X POST https://api.example.com/v1/items -H 'Content-Type: application/json' -d '{}' talks HTTP like any other client. There is no same-origin policy. There is no Origin header the browser injects. There is no automatic OPTIONS probe.
In Chrome or Firefox, a cross-origin POST with Content-Type: application/json is a non-simple request. The browser first asks:
OPTIONS /v1/items HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
If the API does not answer with the right Access-Control-* headers (or does not handle OPTIONS at all), DevTools shows a CORS failure. Your Node route for POST /v1/items may be perfect. You never reached it.
Mental model: curl proves the route and auth work. The browser proves the CORS policy works. Fix both.
Minimal browser repro
Reproduce with one fetch from https://app.example.com to https://api.example.com, then watch Network for a failed OPTIONS (or a successful POST with missing CORS headers on the response).
Serve a tiny page from the SPA origin (static file, Vite, whatever — origin must be https://app.example.com):
<!DOCTYPE html>
<html>
<body>
<button id="go">Call API</button>
<pre id="out"></pre>
<script>
document.getElementById("go").onclick = async () => {
const out = document.getElementById("out");
try {
const res = await fetch("https://api.example.com/v1/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "cors-check" }),
// credentials: "include", // turn on only when you need cookies
});
out.textContent = `status ${res.status}\n` + (await res.text());
} catch (err) {
out.textContent = String(err);
}
};
</script>
</body>
</html>
Expected when CORS is broken
- Console: something like
TypeError: Failed to fetch(Chrome) or a CORS policy message namingAccess-Control-Allow-Origin. - Network: an
OPTIONSto/v1/itemswith status(failed),403, or404— or aPOSTthat returns 200 in Network but still throws in JS because response headers omit CORS allowances. - You do not see a normal JSON body in the page until both preflight and response headers are correct.
Expected when CORS is fixed
- Network:
OPTIONS→204(or200) withAccess-Control-Allow-Origin: https://app.example.com(exact match). - Then
POST→ your real status (200/201/401…); JS reads the body without a CORSTypeError.
Swap hostnames for your staging domains and keep the same shape.
What the API must send
For a browser SPA on another origin, the API must answer preflight and real responses with an allowlisted Access-Control-Allow-Origin, plus methods and headers the SPA actually uses — and Allow-Credentials only if cookies (or other credentialed mode) are in play.
Minimum response headers for the repro above (no cookies):
| Header | Typical value |
|---|---|
Access-Control-Allow-Origin |
https://app.example.com (from allowlist — not *) |
Access-Control-Allow-Methods |
GET, POST, PUT, PATCH, DELETE, OPTIONS |
Access-Control-Allow-Headers |
Content-Type, Authorization (mirror what the SPA sends) |
Access-Control-Allow-Credentials |
true only if you use credentials: 'include'
|
Access-Control-Max-Age |
optional; e.g. 600 to cache preflight |
Echo the request Origin when it is in the allowlist. Do not invent a second origin string.
Express (allowlist + optional credentials)
// npm i cors@2
import express from "express";
import cors from "cors";
const app = express();
app.use(express.json());
const allowlist = new Set([
"https://app.example.com",
"http://localhost:5173", // local Vite; drop in prod if you prefer
]);
app.use(
cors({
origin(origin, cb) {
// non-browser / same-origin tools may omit Origin
if (!origin || allowlist.has(origin)) return cb(null, true);
return cb(new Error(`Origin ${origin} not allowlisted`));
},
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: false, // set true only when cookies / credentialed fetch
maxAge: 600,
})
);
app.post("/v1/items", (req, res) => {
res.status(201).json({ ok: true, item: req.body });
});
app.listen(3000);
Manual OPTIONS (if you prefer not to use cors):
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && allowlist.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
res.setHeader(
"Access-Control-Allow-Methods",
"GET,POST,PUT,PATCH,DELETE,OPTIONS"
);
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization"
);
// res.setHeader("Access-Control-Allow-Credentials", "true"); // only if needed
}
if (req.method === "OPTIONS") return res.sendStatus(204);
next();
});
Expected: with the SPA on an allowlisted origin, DevTools shows OPTIONS 204 and Access-Control-Allow-Origin equal to that origin. A non-allowlisted origin stays blocked — that is the point of the list.
Caddy (allowlist + OPTIONS)
Put CORS on the API site block (or in front of a reverse proxy). Match an exact allowlisted Origin, then echo that value — do not reflect arbitrary Origins.
api.example.com {
# Exact Origin allowlist (values on one line are OR'd)
@allowed header Origin https://app.example.com
# Local Vite — OR localhost onto the same matcher:
# @allowed header Origin https://app.example.com http://localhost:5173
@preflight method OPTIONS
handle @preflight {
header @allowed {
Access-Control-Allow-Origin "{http.request.header.Origin}"
Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
Access-Control-Allow-Headers "Content-Type, Authorization"
Access-Control-Max-Age "600"
Vary Origin
# Access-Control-Allow-Credentials "true" # only if cookies
}
respond 204
}
handle {
header @allowed {
Access-Control-Allow-Origin "{http.request.header.Origin}"
Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
Access-Control-Allow-Headers "Content-Type, Authorization"
Vary Origin
}
reverse_proxy 127.0.0.1:3000
}
}
Warning: reflecting any Origin (no allowlist matcher) is insecure once credentials are on — any site can trigger credentialed requests against your API. Keep the @allowed matcher tight.
Expected: curl -i -X OPTIONS https://api.example.com/v1/items -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: POST' returns 204 with Access-Control-Allow-Origin: https://app.example.com. A non-allowlisted Origin gets no CORS allowance. The same allowlisted origin in the browser repro then succeeds.
Credentials gotcha
If the SPA sends cookies (or credentials: 'include'), the API must return Access-Control-Allow-Credentials: true and an exact Access-Control-Allow-Origin — never *.
Common own-goal:
// SPA
fetch(url, { credentials: "include", method: "POST", ... });
// API (broken)
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Credentials", "true");
Browsers reject that combination. Spec rule: credentialed responses cannot use *. You must echo the precise requesting origin (https://app.example.com), and that origin must be on your allowlist.
Also check:
- Cookie
SameSite/Secure/ domain — CORS success does not fix a cookie the browser refuses to store or send. -
Authorizationheader alone does not requirecredentials: 'include'. Prefer bearer tokens in headers when you can; turn credentials on only for cookie sessions.
Confirm steps + failure checklist
Confirm with DevTools Network: green OPTIONS, exact Allow-Origin, then a readable POST body — then walk the checklist if anything is still red.
Confirm
- Hard-refresh the SPA; clear “Disable cache” oddities if you were testing aggressively.
- Filter Network by
OPTIONS— status 2xx/204, headers match allowlist origin. - Inspect the following
POST/GET— sameAllow-Originon the response (preflight alone is not enough for reading the body). - Console has no CORS
TypeError; yourpre/ UI shows the JSON (or a real app error like 401 — that is progress). - Repeat from a second origin that is not allowlisted — it must still fail.
Failure checklist
-
Missing
OPTIONSroute — proxy or framework returns 404/405 on preflight; add middleware or Caddyhandle @preflight. -
Allow-Origin: *with credentials — browser blocks; switch to exact origin echo. -
Wrong origin string —
https://app.example.comvshttps://www.app.example.comvs trailing slash; allowlist the exactOriginheader value. -
Proxy stripping headers — CDN / nginx / Caddy upstream drops
Access-Control-*; set headers at the edge that the browser sees. -
Allow-Headersincomplete — SPA sendsAuthorizationor a custom header not listed; preflight fails even when Origin is fine. -
Only preflight fixed —
OPTIONSOK but actualPOSTresponse omits CORS headers; apply the same policy to all methods. -
HTTP vs HTTPS mismatch — SPA on
https://, API redirect or mixed content; fix TLS before debugging CORS. -
Cached bad preflight — old
Max-Ageremembered; try a private window or wait out cache while iterating.
Same OPTIONS wall on another host
Any browser SDK that POSTs from your SPA origin to a different host hits the same preflight rules — allowlist the SPA origin and set credentials correctly on that host too. If you need a place to stand an ingest host up, the self-hosting install notes cover the basics.
Discussion
What was your dumbest CORS own-goal — * with credentials, missing OPTIONS route, or a proxy stripping headers?
Top comments (0)