Learn · HTTP
CORS and HTML forms
CORS confuses form handling because it answers a different question than people expect. It does not decide whether a request may be sent. It decides whether JavaScript on one origin may read the response from another. Once that clicks, the rest of the behaviour follows.
Classic form posts are not subject to CORS
A <form> submission is a navigation: the browser sends the request and shows whatever comes back as a new page. No script reads the response, so there is nothing for CORS to protect. Forms have been able to post to any origin since long before CORS existed, which is why the CORS design treats requests a form could send as "simple" and lets them through without asking the server first.
The consequence for servers: CORS headers will never stop another website from posting a form to your endpoint. If you want to restrict who can submit, check the Origin header on the server and reject the request there.
fetch(): simple requests and preflights
A cross-origin fetch() is sent straight away, without a preflight, when all of these hold: the method is GET, HEAD or POST; the only headers you set are CORS-safelisted (Accept, Accept-Language, Content-Language, Content-Type, and Range with a single range); and Content-Type, if set, is application/x-www-form-urlencoded, multipart/form-data or text/plain. Safelisted values also have to be at most 128 bytes and free of a few unsafe bytes.
Anything else gets an OPTIONS preflight first, and the real request is only sent if the preflight response allows the origin, method and headers. For form submissions:
body: new FormData(form)withAccept: application/json: simple, no preflight.body: new URLSearchParams(...): simple, no preflight.body: JSON.stringify(...)withContent-Type: application/json: preflight.- Any custom header, including
X-Requested-Withor anAuthorizationheader: preflight. Formward's endpoint allowsX-Requested-Within that preflight, butAccept: application/jsonis the preferred way to ask for JSON and needs none. - JSON sent as
text/plainto dodge the preflight: simple, but the server now has to parse JSON from a text body. Do not build on this.
A CORS error does not mean the request failed
For a simple request, the browser sends the POST, the server processes it, and only then does the browser check Access-Control-Allow-Origin and decide whether your script may see the response. If the header is missing, fetch() rejects with a TypeError and the console shows a CORS error, but the submission has already been stored.
This is how duplicate submissions happen: the developer sees an error, the user clicks again. When you see a CORS error on a simple POST, check the server side before assuming nothing arrived. For preflighted requests the opposite is true: a failed preflight means the real request was never sent.
Credentials
By default fetch() uses credentials: "same-origin", so cookies are not sent cross-origin. A form endpoint does not need them; leave the default. With credentials: "include", the response must carry Access-Control-Allow-Credentials: true and an explicit origin in Access-Control-Allow-Origin (the * wildcard is not allowed), or the browser hides the response.
Allowing origins on a form backend
A form endpoint needs two separate controls: CORS headers so allowed sites can read the JSON response, and a server-side origin check so other sites cannot submit at all. Reflect the request's origin only when it is on the list, and send Vary: Origin so a cache never serves one site's header to another:
js
const ALLOWED = new Set(["https://example.com", "https://www.example.com"]);
function applyCors(req, res) {
res.setHeader("Vary", "Origin");
const origin = req.headers.origin;
if (!origin || !ALLOWED.has(origin)) return false;
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
return true;
}
app.options("/contact", (req, res) => {
applyCors(req, res); // no CORS headers = browser blocks the real request
res.sendStatus(204);
});
app.post("/contact", (req, res) => {
if (!applyCors(req, res)) { // the check CORS cannot do for you
return res.status(403).json({ ok: false, error: "origin not allowed" });
}
// ...handle the submission
});Origins compare as exact strings: scheme, host and port, no path, no trailing slash. https://example.com and https://www.example.com are different origins, and so is http://localhost:3000 versus http://localhost:5173. Pages opened from file:// and sandboxed iframes send Origin: null.
An origin check stops other websites' visitors' browsers. It does not authenticate anything: curl can send whatever Origin header it likes. Rate limiting and spam filtering still have to run behind it.
How this works with Formward
Each Formward form has an allowed-origins list in its settings (comma-separated, empty means any origin). It is enforced on every POST, classic and fetch alike: a request whose Origin is not on a non-empty list, or that has no Origin at all, gets a 403 before anything is stored. Entries are compared exactly against the Origin header, so write https://example.com with no path or trailing slash, and list www separately if you use it.
The endpoint answers preflights with a 204 and, for allowed origins, reflects the origin with Vary: Origin, allowing POST and the Content-Type request header. It also allows X-Requested-With, but Accept: application/json is the preferred way to ask for JSON and needs no preflight entry. Leave credentials at its default, since the endpoint does not send Access-Control-Allow-Credentials. See the AJAX docs.
Sources
Point a form at an EU endpoint
Formward receives the POST, filters spam and stores submissions in Sweden. No server to run.