What CORS actually checks (and what it doesn't)
CORS — Cross-Origin Resource Sharing — is a browser-side security policy that decides whether JavaScript on origin A is allowed to read responses from origin B. It does not exist server-side. It does not exist outside the browser. curl never sees a CORS error. Mobile apps never see a CORS error. The browser invented the rule and the browser enforces it.
The piece most CORS-error guides skip: a plain HTML form submission is not subject to CORS. When the browser navigates to a new URL because a form's default submit handler ran, it doesn't evaluate Access-Control headers. That's why <form action="https://splitforms.com/api/submit"> works on every site without configuring anything. CORS only kicks in when JavaScript tries to read the response, which means: fetch(), XMLHttpRequest, the Beacon API. If you're seeing a CORS error during a form submission, check whether you're intercepting the submit with JavaScript first.
How to read the actual error
Modern Chromium prints the most useful CORS errors in the Network tab, not the Console. The console message is short:
Access to fetch at 'https://splitforms.com/api/submit' from origin
'https://example.com' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check: No
'Access-Control-Allow-Origin' header is present on the requested resource.The three pieces that matter: the failing URL (the request the browser tried to make), the origin (the page making the request), and the specific reason. The most common reasons:
- "Response to preflight request doesn't pass access control check." The OPTIONS request returned without the right headers. The real POST never went out.
- "The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*'." You're sending credentials (a cookie, an Authorization header) and the server returned
*. Replace the wildcard with the explicit origin. - "The 'Access-Control-Allow-Headers' header doesn't allow the request header." You're sending a custom header (
X-Form-Token,Content-Type: application/json) and the server hasn't opted into accepting it. - "Origin is not allowed." The server explicitly returned an Access-Control-Allow-Origin that doesn't match your origin. The fix is on the server.
Diagnose: which kind of request am I making?
Open the Network tab, submit the form, and look at the failing request. Three indicators tell you what kind of request the browser thinks it is.
- If you see an OPTIONS request followed by a POST, the browser is making a preflight check. That happens when the request has a non-simple content type (e.g.
application/json) or non-simple headers (e.g.Authorization: Bearer ...). - If you see only a POST with red text in the Status column, the request is "simple" (URL-encoded body, no custom headers) but the response was rejected on the basis of its CORS headers — usually
Access-Control-Allow-Origindoesn't match your origin. - If you see no request at all, a Content Security Policy or extension blocked the request before the browser tried to make it. CSP errors look similar in the Console but mention
connect-src.
# Reproduce the request from the terminal — without browser CORS
curl -i -X OPTIONS https://splitforms.com/api/submit \
-H "Origin: https://example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type"
# Look for these headers in the response:
# Access-Control-Allow-Origin: https://example.com
# Access-Control-Allow-Methods: POST, OPTIONS
# Access-Control-Allow-Headers: content-type
# Access-Control-Max-Age: 86400If the curl preflight returns the right headers but the browser still errors, the problem is on your side (different origin sent, custom headers, etc.). If the curl preflight is missing headers or returns 405, the problem is server-side.
Fix 1: switch from JSON to URL-encoded body
The fastest CORS-error fix on a form is to drop the Content-Type: application/json and use either URLSearchParams or FormDataas the body. Both are "simple" content types under the CORS spec, so the browser skips the OPTIONS preflight entirely.
// ❌ This triggers preflight — and many backends don't handle OPTIONS.
fetch('https://splitforms.com/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ access_key: KEY, name, email, message }),
});
// ✅ This is a "simple" request. No preflight. No CORS error.
const params = new URLSearchParams({
access_key: KEY,
name, email, message,
});
fetch('https://splitforms.com/api/submit', {
method: 'POST',
body: params,
headers: { Accept: 'application/json' },
});
// ✅ Also simple. Use this when the form has file uploads.
const formEl = document.querySelector('form')!;
fetch(formEl.action, {
method: 'POST',
body: new FormData(formEl),
headers: { Accept: 'application/json' },
});The splitforms backend accepts JSON and URL-encoded equally — there's no functional reason to use JSON for a form submission. Form data is the encoding the web was built around; switching to it removes an entire class of CORS errors and is what every example in our docs uses. See the HTML form action guide for the full coverage.
Fix 2: add your origin to the backend's allow-list
Hosted form backends (splitforms, Formspree, Web3Forms) lock submissions to a configured list of origins to prevent rebranded spam. The error you see when your domain isn't in the list looks identical to a missing-CORS-headers error, but the cause is different.
On splitforms, the access key's allowed-origins list lives in the dashboard. Add the production domain, the preview pattern, and http://localhost:3000 for local dev. Wildcards work on Pro plans (*.vercel.app, *.netlify.app). Save and the error disappears within seconds — the change propagates immediately, no deploy needed.
- Open the splitforms dashboard, select the form, click "Allowed origins".
- Add
https://yourdomain.com,https://*.vercel.app(Pro), andhttp://localhost:3000. - Submit a test form to confirm.
Configuration walkthrough at /docs.
Fix 3: remove the JS intercept (when you don't need it)
If your form is just a contact form and you don't need a custom success state, the cleanest fix is to remove the fetch()and let the browser submit the form natively. The browser POSTs the form, splitforms returns a redirect to your thank-you page, the browser follows it. No CORS error because there's no JavaScript reading the response.
<!-- ❌ Form intercepted by JS — fetch hits CORS rules -->
<form id="contact" action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_KEY">
<input name="name" required>
<input name="email" type="email" required>
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>
<script>
document.getElementById('contact').addEventListener('submit', async (e) => {
e.preventDefault(); // <- this is what triggers CORS
await fetch(...); // ...
});
</script>
<!-- ✅ Native form submit — no CORS rules apply -->
<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_KEY">
<input type="hidden" name="redirect" value="https://yoursite.com/thanks">
<input name="name" required>
<input name="email" type="email" required>
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>The redirect hidden input tells splitforms where to send the visitor after a successful submission. The browser navigates there, the page reloads with whatever thank-you UI you want. Less code, no CORS complications, works for visitors with JavaScript disabled. This is the right pattern unless you specifically need an in-place success state without a page reload.
Fix 4: configure CORS on your own backend
When the backend is yours (not a hosted form service), the fix is to set the right response headers. The patterns by stack:
// Express + cors middleware
import express from 'express';
import cors from 'cors';
const app = express();
app.use(cors({
origin: ['https://yoursite.com', 'http://localhost:3000'],
methods: ['POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // only if you actually send cookies
}));
app.post('/api/contact', express.urlencoded({ extended: true }), (req, res) => {
// ... handle the form submission
res.json({ ok: true });
});// Next.js 15 route handler
import { NextResponse } from 'next/server';
const ALLOW_ORIGIN = 'https://yoursite.com';
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': ALLOW_ORIGIN,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
},
});
}
export async function POST(req: Request) {
const data = await req.formData();
// ... handle
return new NextResponse(JSON.stringify({ ok: true }), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOW_ORIGIN,
},
});
}Two things every example needs to get right: (1) the OPTIONS handler must return the same allow-origin as the POST; (2) Access-Control-Max-Age caches the preflight result so subsequent submits don't re-check. 86400 seconds (24h) is a safe default. If you'd rather skip building the CORS middleware yourself, point the form at the splitforms HTML form backend and let it handle origin validation.
Common traps that look like CORS but aren't
- Mixed content. An HTTPS page submitting to an HTTP backend is blocked by the browser before CORS even runs. The console says "Mixed Content," not CORS.
- Content Security Policy. A CSP
connect-srcdirective that doesn't include the form backend blocks the request. The console error mentions CSP, not CORS. - Browser extensions. Privacy extensions (Privacy Badger, uBlock Origin) sometimes block submissions to known form backends. Test in incognito with extensions disabled.
- Network proxy. Corporate proxies that strip CORS response headers can make a working backend look broken in the browser. Check the response headers in
curlfrom the same network. - Wrong HTTP method. A 405 response on the OPTIONS preflight isn't a CORS error per se — it's the server saying "I don't accept OPTIONS." The fix is to handle OPTIONS, not to fiddle with allow-origin.
Skip CORS configuration entirely
The fastest way to never debug a CORS error on a form again: point the form at splitforms. The backend handles preflight, returns the right CORS headers per allowed origin, and the dashboard's allowed-origins list is the only thing you need to maintain. Sign up at /login; the free tier covers 1,000 submissions/month. Pricing details at /pricing.
FAQ
Why does my <form> trigger a CORS error when I submit it?
It usually doesn't — the browser fires CORS errors for `fetch()` and `XMLHttpRequest`, not for native form submission with the default content type. If you're seeing a CORS error, check whether you're actually submitting via fetch (or whether some library is wrapping the submit). A plain `<form action="...">` with no JavaScript intercept doesn't trigger preflight, and the browser doesn't enforce CORS on the response (it follows the redirect or reloads the page).
What's the difference between `Content-Type: application/json` and `application/x-www-form-urlencoded`?
Browsers treat `application/json` as a non-simple request and trigger a preflight OPTIONS check. `application/x-www-form-urlencoded` and `multipart/form-data` are simple types — no preflight, no CORS error on the request. The fastest way to make a CORS error disappear on a form submission is to switch the fetch from JSON to URLSearchParams or FormData. The splitforms backend accepts both encodings.
Does setting `mode: 'no-cors'` on fetch fix it?
No, and it makes things worse. `mode: 'no-cors'` makes the browser send the request but blocks JavaScript from reading the response. The form submission appears to succeed but you can't tell whether it actually did, you can't read the server's response body, and you can't show a real success/error state. Never reach for `no-cors`; the right fix is on the server.
Why does the production form work but the preview deployment fails with CORS?
Because the form backend (or your own API) whitelists specific origins, and the preview URL isn't in the list. splitforms allows submissions only from the domains configured on the access key. Vercel preview URLs (`my-app-abc123.vercel.app`) are different origins from your production domain. Add the preview pattern to the access key's allowed origins, or use a wildcard (splitforms supports `*.vercel.app` style wildcards on Pro plans).
How do I fix CORS on my own backend (not a hosted form service)?
Add the right `Access-Control-Allow-Origin` and `Access-Control-Allow-Methods` headers to the response. For Node/Express, the `cors` middleware does this in two lines. For Next.js, set headers in `next.config.js` or in the route handler. Code examples for both are below. Never echo `Access-Control-Allow-Origin: *` on a request that includes credentials — the browser will reject it.
Why does the OPTIONS request never include the body?
By design. The OPTIONS preflight is a permission check the browser sends before the real request. The server replies with CORS headers indicating whether the cross-origin request is allowed; if yes, the browser then sends the real request with the body. If your server returns 405 or 404 on OPTIONS, the preflight fails and the real request never goes out. Always handle OPTIONS explicitly.
Will switching to a hosted form backend make my CORS problem go away?
Mostly, yes. splitforms returns the right CORS headers for every allowed origin and handles OPTIONS preflight correctly. The remaining gotcha is the access key's allowed-origins list — if your domain isn't on it, you still get a CORS error. Add your domain in the dashboard and the error disappears. See /forms/html for the form-side setup and /docs for the dashboard configuration.