Your Flutter app works perfectly on Android and iOS. You run flutter run -d chrome, hit the same Node.js endpoint, and the request dies before it even reaches your server. The console fills with red text about Access-Control-Allow-Origin, and suddenly a project that was 95% done feels broken. How to enabel CORS for flutter web Apps covers this in more depth.
A flutter web CORS error is not a Flutter bug and it is almost never something you fix inside your Dart code. It is a browser security rule that your Node.js API has to answer correctly. This guide walks through why Flutter Web behaves differently from mobile builds, maps every common browser console message to the exact server-side fix, and shows how to set up a clean development proxy so you stop launching Chrome with security disabled.
Why mobile builds never hit CORS and Flutter Web always does
On Android and iOS, your HTTP requests go through dart:io and the operating system socket layer. There is no browser, no origin, and no same-origin policy. Your app can call any host it wants.
On the web, Flutter compiles to JavaScript (or WasmGC) and every request from package:http, dio or Image.network ends up going through the browser’s fetch API. That means your app is now subject to the exact same rules as any JavaScript app:
- The page loaded from
http://localhost:5000has an origin. - Any request to a different scheme, host or port is a cross-origin request.
- The browser will only hand the response back to your Dart code if the server explicitly opts in with CORS headers.
Important detail that trips up most developers: the request usually reaches your Node.js server just fine. Your Express logs show a 200. The browser simply refuses to give the response to your app because the headers were missing. That is why “it works in Postman” is never proof that CORS is configured.
The preflight request Flutter Web sends and mobile does not
Browsers split cross-origin requests into two categories.
Simple requests go straight to the server. A request stays simple only if it uses GET, HEAD or POST, and the only content type is application/x-www-form-urlencoded, multipart/form-data or text/plain, with no custom headers. CORS errors on Flutter web covers this in more depth.
Preflighted requests are sent twice: first an OPTIONS request asking permission, then the real request. Your Flutter app triggers a preflight the moment it does any of the following, which is basically always:
- Sets
Content-Type: application/jsonwhen posting a body - Sends an
Authorization: Bearer ...header - Sends a custom header such as
X-Api-Key,X-Client-Versionor a tenant ID - Uses PUT, PATCH or DELETE
So a plain http.post(url, headers: {'Content-Type': 'application/json'}, body: jsonEncode(data)) becomes an OPTIONS /api/orders followed by POST /api/orders. If your Express app only defines app.post('/api/orders', ...), the OPTIONS call returns 404 or 405 and the browser aborts. On mobile, that OPTIONS call never existed.

Decoding the exact browser console errors
Each Chrome or Firefox message points at a specific missing header. Use this table as a lookup before touching any code.
| Console message | Real cause | Server-side fix |
|---|---|---|
| No ‘Access-Control-Allow-Origin’ header is present on the requested resource | CORS middleware missing, or it runs after the route, or an error handler bypasses it | Register cors() before all routes and before body parsers |
| Response to preflight request doesn’t pass access control check: It does not have HTTP ok status | The OPTIONS request returned 404, 401 or 500. Often auth middleware is rejecting it | Answer OPTIONS with 204 before authentication runs |
| Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response | The header allowlist does not include the header your Dart code sends | Add it to allowedHeaders |
| Method PATCH is not allowed by Access-Control-Allow-Methods in preflight response | Verb missing from the methods allowlist | Add the verb to methods |
| The value of the ‘Access-Control-Allow-Origin’ header must not be the wildcard ‘*’ when the request’s credentials mode is ‘include’ | You enabled cookies but kept origin: '*' |
Echo back a specific origin from an allowlist |
| Credentials flag is ‘true’, but the ‘Access-Control-Allow-Credentials’ header is ” | Client asks for cookies, server never opted in | Set credentials: true |
| Redirect is not allowed for a preflight request | A 301/302 sits in front of the API, usually HTTP to HTTPS or a trailing slash rule | Call the final URL directly, matching scheme and slash exactly |
| XMLHttpRequest error / ClientException: Failed to fetch with no other detail | Generic Dart-side wrapper. The real reason is in the browser Network tab, not the Dart exception | Open DevTools, inspect the OPTIONS row |
One thing to remember: Dart never sees the CORS reason. package:http just throws a ClientException and dio throws a DioException with type connectionError. Always debug from the browser Network tab.
The correct Express CORS configuration
Install the official middleware:
npm install cors
1. A permissive setup for local development
const express = require('express');
const cors = require('cors');
const app = express();
// Must come BEFORE routes and before express.json()
app.use(cors());
app.use(express.json());
app.post('/api/orders', (req, res) => {
res.json({ ok: true });
});
app.listen(3000);
With no options, the cors package reflects Access-Control-Allow-Origin: * and automatically answers preflight OPTIONS requests for every route. This is enough for a token-in-header API during development, but it will not work once you need cookies.
2. Production setup with an origin allowlist
const allowedOrigins = [
'http://localhost:5000', // flutter run -d chrome --web-port=5000
'https://app.yourdomain.com', // production Flutter Web build
'https://staging.yourdomain.com'
];
const corsOptions = {
origin(origin, callback) {
// Allow tools with no origin (curl, Postman, mobile builds)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) return callback(null, true);
return callback(new Error('Origin not allowed by CORS: ' + origin));
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Api-Key', 'X-Client-Version'],
exposedHeaders: ['Content-Disposition', 'X-Total-Count'],
credentials: true,
maxAge: 86400
};
app.use(cors(corsOptions));
app.options('*', cors(corsOptions)); // explicit preflight handler
Key points that solve most remaining failures:
- allowedHeaders must match exactly what Dart sends. If you add an
X-Device-Idheader in your interceptor later, add it here too or every request breaks again. - exposedHeaders is required to read custom headers in Dart. Without it,
response.headers['x-total-count']is always null even though the header is visible in DevTools. This catches out a lot of pagination code. - maxAge caches the preflight for 24 hours, so the browser stops sending an OPTIONS before every single call. This alone can halve your request count on a chatty dashboard.
- Order matters. If a logging, rate limiting or auth middleware sits above
cors(), it will answer the OPTIONS request without CORS headers and the browser will reject it.
3. Stop auth middleware from blocking preflight
The browser never attaches your Authorization header to the OPTIONS request. If your JWT guard runs on every method, preflight returns 401 and Chrome reports “It does not have HTTP ok status”. Skip OPTIONS explicitly:
function requireAuth(req, res, next) {
if (req.method === 'OPTIONS') return next();
const header = req.headers.authorization;
if (!header) return res.status(401).json({ error: 'Missing token' });
// ... verify token
next();
}
4. Do not forget your error handler
A 500 thrown from inside a route often skips the CORS headers, so a real backend bug shows up in the console as a CORS error and sends you chasing the wrong problem. Make sure your Express error handler is registered after cors() and that it does not short-circuit the middleware chain. This explainer is clearer than most.

Cookies and credentials: the part that breaks silently
If your Node.js API uses session cookies or refresh-token cookies instead of bearer tokens, three things must line up.
Server side
app.use(cors({
origin: 'https://app.yourdomain.com', // never '*'
credentials: true
}));
res.cookie('sid', token, {
httpOnly: true,
secure: true, // required with SameSite=None
sameSite: 'none', // required for cross-site Flutter Web
path: '/'
});
Client side in Dart
package:http does not send cookies cross-origin by default. Use the browser client and enable credentials:
import 'package:http/browser_client.dart';
final client = BrowserClient()..withCredentials = true;
final res = await client.get(Uri.parse('https://api.yourdomain.com/me'));
With dio, attach the browser adapter:
import 'package:dio/dio.dart';
import 'package:dio/browser.dart';
final dio = Dio(BaseOptions(baseUrl: 'https://api.yourdomain.com'));
(dio.httpClientAdapter as BrowserHttpClientAdapter).withCredentials = true;
The rules you cannot break
| Requirement | Why |
|---|---|
Origin must be an exact string, never * |
Browsers reject wildcards in credentialed mode |
Access-Control-Allow-Credentials: true |
Without it the response is discarded even if it is a 200 |
SameSite=None; Secure |
Cross-site cookies are dropped otherwise, and Secure means HTTPS on both sides |
| HTTPS on the API in production | Secure cookies are ignored over plain HTTP except on localhost |
Set up a proper development proxy instead of disabling web security
Most search results tell you to patch the Flutter SDK and add --disable-web-security to the Chrome launch flags. It works, and it is the worst option available. It only fixes your machine, it breaks the moment you upgrade Flutter, it hides real CORS misconfigurations until deployment day, and it means you browse the entire web in an unprotected Chrome profile.
There are two clean alternatives.
Option A: pin the Flutter dev port and allowlist it
By default flutter run -d chrome picks a random port, so your origin changes on every restart and no allowlist can keep up. Pin it:
flutter run -d chrome --web-port=5000 --web-hostname=localhost
Then add http://localhost:5000 to allowedOrigins in Express. This is the simplest correct setup and it forces you to keep the real CORS configuration honest during development.
Option B: serve the Flutter app and the API from one origin
If you cannot touch the API (third-party service, legacy backend, another team), put a small Node proxy in front so the browser only ever sees one origin. No cross-origin request means no CORS at all.
npm install express http-proxy-middleware
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const app = express();
// 1. Proxy every /api call to the real backend
app.use('/api', createProxyMiddleware({
target: 'https://api.thirdparty.com',
changeOrigin: true,
pathRewrite: { '^/api': '' }
}));
// 2. Proxy everything else to the Flutter dev server
app.use('/', createProxyMiddleware({
target: 'http://localhost:5000',
changeOrigin: true,
ws: true
}));
app.listen(8080, () => console.log('Open http://localhost:8080'));
Start the Flutter dev server without opening a browser, then browse to the proxy:
flutter run -d web-server --web-port=5000
In your Dart code, call relative paths so the same code works in dev and in production:
const apiBase = String.fromEnvironment('API_BASE', defaultValue: '/api');
final res = await http.get(Uri.parse('$apiBase/orders'));
Build with flutter build web --dart-define=API_BASE=/api and put the same path rule in Nginx or your CDN in front of the production API. Same-origin everywhere, zero CORS surface.

Special case: images, fonts and CanvasKit
Image.network on Flutter Web is subject to CORS too, and the failure is quieter: you get a grey box or an HttpException in the image stream. If the images live on S3, Cloudinary or another bucket, add Access-Control-Allow-Origin to the bucket CORS policy. If you only need to display the image and not read its pixels, you can also route it through your own domain with the proxy above.
The same applies to custom fonts loaded from a CDN and to CanvasKit assets when you self-host them. If your app renders blank on the web but the network tab shows the wasm file downloading, check the response headers on that file.
A 7-step debugging checklist
- Open DevTools, Network tab, and enable Preserve log. Find the
OPTIONSrow, not the failed one below it. - Check its status code. Anything other than 200 or 204 is your bug, and it is a routing or auth issue, not a headers issue.
- Compare the
Access-Control-Request-Headersin the request withAccess-Control-Allow-Headersin the response. Every requested header must appear in the allowlist. - Compare
Access-Control-Request-MethodwithAccess-Control-Allow-Methods. - Confirm the
Originvalue character for character.http://localhost:5000andhttp://127.0.0.1:5000are two different origins. - If you use cookies, confirm the allow-origin is not
*and thatAccess-Control-Allow-Credentials: trueis present. - Reproduce with curl to prove the server side is fixed before reloading Flutter:
curl -i -X OPTIONS https://api.yourdomain.com/api/orders \ -H "Origin: http://localhost:5000" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: content-type,authorization"
If that curl returns 204 with the right headers, and your app still fails, the problem is on the client: wrong URL, wrong port, a stale service worker, or a proxy in between. Hard-reload with the cache disabled before you keep digging.

What to avoid
- Public CORS proxies such as the well-known
cors-anywherestyle services. Every request, including tokens and personal data, passes through a third party. Fine for a five minute demo, unacceptable for anything real. - Patching the Flutter SDK. Your CI, your teammates and your next SDK upgrade will not have the patch.
- Setting CORS headers in Dart. Response headers can only be set by the server. Nothing you write in a Dio interceptor will ever create an
Access-Control-Allow-Origin. - Leaving
origin: '*'in production. It exposes your API to any site on the internet, and combined with cookies the browser will block it anyway.
FAQ
Why does my Flutter app work on mobile but not on web?
Mobile builds use native sockets with no browser and no same-origin policy. Web builds run inside the browser, which enforces CORS on every cross-origin request. Nothing changed in your Dart code, only the runtime.
Can I fix a CORS error with Dart code only?
Not properly. The permission has to come from the server in the form of response headers. The only client-side workarounds are routing through a proxy you control, or serving the app from the same origin as the API.
Why does my POST fail while GET works fine?
A GET without custom headers is a simple request and skips preflight. A POST with Content-Type: application/json triggers an OPTIONS request, and your Express app probably does not answer OPTIONS on that route.
Is app.use(cors()) enough?
For a token-based API in development, usually yes. As soon as you use cookies, custom headers or need to read custom response headers in Dart, you need the full options object with credentials, allowedHeaders and exposedHeaders.
Why does Chrome say the preflight does not have HTTP ok status?
Your OPTIONS request returned an error code. The two usual suspects are a missing route (404) and an authentication middleware rejecting the credential-free OPTIONS call (401). Let OPTIONS through before auth runs.
My CORS headers are correct but cookies are still not sent
Set withCredentials = true on the Dart browser client or Dio adapter, set SameSite=None; Secure on the cookie, use HTTPS on both sides, and replace the wildcard origin with the exact origin string.
Does the web renderer affect CORS?
No. CanvasKit, Skwasm and HTML output all go through the same browser fetch layer and obey the same rules. Switching renderers will not fix a CORS error, though it can change how image loading failures appear on screen.
Wrapping up
A flutter web CORS error is a conversation between your browser and your Node.js server that your Dart code never participates in. Configure cors() before every other middleware, let OPTIONS through untouched, list every header your client sends, expose the headers you need to read back, and pin your dev port so the origin allowlist actually means something. Do that and the problem stops coming back at every deployment.
Need help shipping a Flutter Web app on top of an existing Node.js backend, or auditing an API before it goes live? Get in touch with the team at Box Software and we will take a look at your setup.
