CORS Preflight Request Failed: Why OPTIONS Is Blocked and How to Fix It
Quick answer
💡The browser sends an OPTIONS preflight request before every cross-origin fetch that uses non-simple headers like Authorization or Content-Type: application/json. Your server must respond to OPTIONS with Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers — otherwise the browser blocks the real request entirely. If your server returns 404 or 405 for OPTIONS, the preflight fails before your POST or PUT ever reaches the route handler.
Error symptoms
- ✕
Browser console shows: Access to fetch at 'https://api.example.com' from origin 'https://app.example.com' has been blocked by CORS policy - ✕
DevTools Network tab shows an OPTIONS request with a 404, 405, or 501 status - ✕
The actual POST or PUT request never appears in the Network tab — only the OPTIONS - ✕
Error message: Response to preflight request doesn't pass access control check - ✕
Request works fine in Postman or curl but fails in the browser - ✕
Safari reports the error differently from Chrome, making it harder to reproduce cross-browser
Common causes
- •Server route handler does not define an OPTIONS route, so the framework returns 404 or 405
- •nginx or another reverse proxy terminates OPTIONS requests before they reach the application
- •CORS middleware is loaded after authentication middleware, which rejects OPTIONS with 401
- •Access-Control-Allow-Origin is set to wildcard (*) but credentials: 'include' is also set — these are incompatible
- •Access-Control-Allow-Headers does not list a custom header like X-Request-ID that the client sends
- •Firebase Hosting, Netlify, or Vercel rewrites are handling the route without CORS headers configured
When it happens
- •First time deploying a frontend app to a different domain from the backend API
- •Adding an Authorization header to an existing fetch call that previously used no headers
- •Switching from a same-origin proxy (Next.js API routes) to direct API calls
- •Migrating a backend to a new subdomain while the frontend stays on the old one
- •Adding a CDN or reverse proxy layer that intercepts OPTIONS before the origin server
Examples and fixes
A common setup where the Express router handles GET and POST but silently drops OPTIONS, returning 404.
Express server not handling OPTIONS
❌ Wrong
const express = require('express');
const app = express();
// Auth middleware runs first — rejects OPTIONS with 401
app.use(requireAuth);
app.post('/api/data', async (req, res) => {
const result = await db.query('SELECT * FROM records');
res.json(result);
});
app.listen(3000);✅ Fixed
const express = require('express');
const cors = require('cors');
const app = express();
const corsOptions = {
origin: 'https://app.example.com',
credentials: true,
allowedHeaders: ['Authorization', 'Content-Type', 'X-Request-ID'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
maxAge: 600
};
// CORS must come before auth so OPTIONS passes without a token
app.use(cors(corsOptions));
app.options('*', cors(corsOptions));
app.use(requireAuth);
app.post('/api/data', async (req, res) => {
const result = await db.query('SELECT * FROM records');
res.json(result);
});
app.listen(3000);The broken version runs authentication middleware before CORS middleware. When the browser sends an OPTIONS preflight, the auth middleware rejects it with 401 before any CORS headers are added. The browser sees the 401 and treats the preflight as failed. The fix registers cors() before requireAuth so that OPTIONS requests receive correct CORS headers and a 204 status without touching the auth layer. The explicit app.options('*', cors()) line ensures OPTIONS is handled at all routes, not just the ones with explicit handlers. The maxAge: 600 setting tells Chrome to cache the preflight result for 10 minutes, which reduces the number of OPTIONS round trips.
An nginx reverse proxy that does not forward OPTIONS to the upstream Node.js server.
nginx blocking OPTIONS before the app
❌ Wrong
# nginx.conf — no OPTIONS handling
server {
listen 443 ssl;
server_name api.example.com;
location /api/ {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
}
}✅ Fixed
# nginx.conf — handle OPTIONS at the proxy level
server {
listen 443 ssl;
server_name api.example.com;
location /api/ {
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin 'https://app.example.com' always;
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header Access-Control-Allow-Headers 'Authorization, Content-Type, X-Request-ID' always;
add_header Access-Control-Allow-Credentials 'true' always;
add_header Access-Control-Max-Age 600 always;
add_header Content-Length 0;
return 204;
}
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
}
}When nginx sits in front of a Node.js server, it proxies all requests including OPTIONS by default. If the upstream app does not handle OPTIONS, nginx forwards it and gets back a 404, which the browser interprets as preflight failure. The fix handles OPTIONS directly in nginx before the request reaches the app, returning a 204 with all required CORS headers. Using the 'always' flag ensures headers are added even when nginx itself generates the response code. The add_header Access-Control-Allow-Credentials line is required whenever the frontend uses credentials: 'include' in its fetch calls. Without it, even a successful preflight will block credentialed requests.
Why browsers send OPTIONS preflight
The browser's CORS specification divides requests into two categories: simple requests and preflighted requests. A simple request uses GET, HEAD, or POST with only a handful of allowed headers like Content-Type: text/plain or application/x-www-form-urlencoded. Any request that falls outside those narrow limits triggers an automatic OPTIONS preflight. When you add an Authorization header to carry a JWT token, or set Content-Type to application/json to send a structured body, the browser cannot assume the server has opted in to receiving cross-origin data in that form. So it sends a preliminary OPTIONS request first, asking the server whether it permits the method and headers the real request will use.
The OPTIONS request carries Access-Control-Request-Method and Access-Control-Request-Headers headers that describe what the real request will look like. The server must respond with Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers — and those response headers must explicitly include everything the client is requesting. If the server returns 404 because no OPTIONS route exists, or returns 405 Method Not Allowed, or returns a 401 because authentication middleware runs before CORS middleware, the browser treats the preflight as failed and blocks the real request entirely. The actual POST or PUT never leaves the browser.
A subtle point that catches many developers: setting Access-Control-Allow-Origin: * is not compatible with credentials: 'include' in the fetch call. When you send cookies or authorization headers in a credentialed request, the CORS spec requires the server to reflect the exact origin rather than using a wildcard. This means your server must read the Origin header from the incoming request and echo it back in Access-Control-Allow-Origin if it is on your allowed list. Wildcards work fine for public APIs that do not use cookies or session credentials, but the moment you need cookies or Authorization headers on both sides, you must switch to explicit origin matching.
Another frequently missed detail is Access-Control-Allow-Headers. Developers often configure Access-Control-Allow-Methods correctly but forget that every non-simple header must be explicitly listed. If your frontend sends an X-Correlation-ID header for distributed tracing and your server does not include that header name in Access-Control-Allow-Headers, the preflight fails. Safari is stricter than Chrome in enforcing this — Chrome sometimes accepts wildcard headers in Access-Control-Allow-Headers while Safari rejects them, which explains why a fix that works on Chrome still breaks on Safari.
Diagnosing preflight failures in DevTools
Open the browser DevTools and go to the Network tab before triggering the failing request. Filter the request list by typing 'OPTIONS' in the filter bar — this isolates the preflight from the actual request. Click on the OPTIONS request and examine four things in order: the status code, the Request Headers panel, the Response Headers panel, and the Preview tab.
A status code of 404 means no route handles OPTIONS on the server. A 405 means a route exists but OPTIONS is not listed in the allowed methods. A 401 or 403 means authentication middleware is rejecting the preflight before CORS headers can be applied. A 200 or 204 with missing headers in the Response Headers panel means the handler ran but did not write the required CORS headers.
In the Response Headers panel, look specifically for these headers: Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, and if using credentials, Access-Control-Allow-Credentials. If any of these are missing or have incorrect values, the browser will block the request. Compare what the Response Headers show against what the Request Headers section shows for Access-Control-Request-Method and Access-Control-Request-Headers — those tell you exactly what the server needed to allow.
If the OPTIONS request does not appear in the Network tab at all, check whether you have a browser extension blocking it, or whether the request is actually same-origin and no preflight is needed. Use the Console tab and look for the red CORS error message — Chrome includes the specific header that was missing or mismatched.
For server-side investigation, add temporary request logging that prints the method, path, and all headers for every incoming request. You will often find that OPTIONS requests are reaching a different service than you expect, or being intercepted by a load balancer. The /tools/cors-tester tool on ToolDock lets you send a real OPTIONS request from an external origin and inspect all response headers in one view, which is faster than setting up curl with the right headers manually. The /tools/http-request-builder tool also lets you craft and replay the exact OPTIONS request your browser is sending to confirm the server responds correctly.
Fixing the server to pass preflight
The safest fix strategy is to handle CORS as the very first middleware in your application stack, before authentication, before body parsing, and before any custom middleware that might return early responses. In Express, install the cors npm package and call app.use(cors(options)) at the top of your middleware chain. Also call app.options('*', cors(options)) explicitly — the cors package does not automatically handle OPTIONS routes unless you tell it to.
When configuring the cors options object, specify origin as a function that validates against an allowlist rather than a static string. This allows you to support multiple environments. Set credentials: true if your frontend uses cookies or Authorization headers with credentials: 'include'. List every header your frontend sends in allowedHeaders, including custom headers like X-Request-ID, X-Correlation-ID, or any analytics headers injected by third-party libraries. Set maxAge to 600 to tell Chrome to cache the preflight result for 10 minutes, reducing round trips.
For nginx configurations, the pattern is to intercept OPTIONS before proxying to the upstream application. Add an if block that checks $request_method = 'OPTIONS' and returns 204 with all required headers. Use the always flag on add_header directives so the headers appear even when nginx generates the response itself rather than forwarding it from upstream. After applying nginx config changes, always reload with nginx -s reload rather than a full restart to avoid downtime.
For Firebase Hosting, add a headers section in firebase.json that includes Access-Control-Allow-Origin and related headers on the glob pattern that matches your API routes. For Vercel, add a headers array in vercel.json with the source matching your API route pattern. Netlify uses _headers file at the project root. Each hosting platform has its own syntax but the required HTTP headers are the same across all of them.
After applying any fix, always test by actually loading the browser, opening DevTools, and triggering the request — do not rely only on curl or Postman, because those tools bypass the browser's CORS enforcement entirely. Use the /tools/cors-tester on ToolDock to verify the response headers look correct from an external origin perspective.
Preflight edge cases that trip up teams
The Access-Control-Max-Age header controls how long the browser caches a preflight result. Chrome's default is 5 seconds when no header is present, and the maximum it respects is 7200 seconds. Firefox has different limits. When you see preflight requests happening on every API call in production even though the server is correctly configured, it usually means Access-Control-Max-Age is missing or set to a very short value. Setting it to 600 seconds in your CORS middleware is a simple optimization that removes preflight overhead from repeated calls.
Wildcard handling in Access-Control-Allow-Headers differs between browsers. Chrome accepts the asterisk (*) to mean all headers, but Safari still requires each header to be named explicitly. If you list Access-Control-Allow-Headers: * and your CORS check passes in Chrome but fails in Safari, add each header explicitly: Authorization, Content-Type, X-Request-ID, and any other headers your application sends.
Credentialed requests interact with redirects in a surprising way. If your server returns a 301 or 302 redirect in response to the preflight, the browser does not follow the redirect — it treats the preflight as failed. This commonly happens when an API endpoint forces HTTP to HTTPS, or when a trailing-slash redirect fires on an OPTIONS request. Make sure your server handles OPTIONS directly at the canonical URL without issuing redirects.
Some reverse proxies strip certain headers before forwarding to the upstream application. HAProxy, AWS API Gateway, and older nginx configurations are known to drop headers starting with X- by default. If your CORS middleware on the Node.js app is reading the Origin header to determine which origin to reflect, but the proxy has removed Origin before forwarding, the middleware cannot echo the origin correctly and will likely return a wildcard or nothing. Verify that your proxy is configured to pass the Origin header upstream with proxy_set_header Origin $http_origin in nginx or equivalent.
Content Security Policy headers can also interfere in ways that look like CORS failures. If your server has a strict CSP that does not list connect-src for the API domain, the browser will block the request before even attempting the preflight. Check the console for CSP-related errors alongside CORS errors.
Mistakes developers make with CORS setup
The most common mistake is placing the CORS middleware after authentication middleware. When authentication middleware rejects an OPTIONS preflight with 401, the browser cannot distinguish between a rejected preflight and a legitimately blocked request — it just sees that the OPTIONS failed and blocks the real call. OPTIONS preflights are sent without credentials (no cookies, no Authorization header) by the browser, so authentication middleware should always whitelist OPTIONS or run after CORS handling.
Setting Access-Control-Allow-Origin: * and simultaneously setting Access-Control-Allow-Credentials: true in the response is a configuration that browsers refuse to honor. Chrome and Firefox will both log an error explaining that credentials are not supported with wildcard origins. You must use the actual origin string like https://app.example.com. This origin must match the Origin header from the request exactly, including protocol, host, and port.
Forgetting to handle the OPTIONS method in AWS Lambda functions is another trap. If you deploy an API on AWS API Gateway with Lambda integration, the Lambda function must return the CORS headers on OPTIONS responses, and the API Gateway method configuration must also have OPTIONS enabled for the route. Both layers must be configured — API Gateway can handle OPTIONS itself if you enable CORS in the console, but if you configure it manually, you need to set up both the method and the integration response.
Testing CORS fixes using curl with -H 'Origin: https://app.example.com' sends the header and shows whether the server responds with the right headers, but it does not actually enforce the CORS rules the way a browser does. A curl test that passes does not guarantee the browser will accept the response. Always do a final test in the actual browser with DevTools open to watch the OPTIONS request and the subsequent real request side by side.
Mounting the cors() middleware on only some routes but not others creates hard-to-debug inconsistencies. If the cors middleware is on /api/data but not on /api/users, the preflight for /api/users fails silently. Apply CORS globally at the top of the Express app or explicitly on every route group.
Building a robust CORS configuration
Maintain an explicit allowlist of origins in an environment variable rather than hardcoding domains in code. In development the list might include http://localhost:3000 and http://localhost:5173. In staging it includes the staging domain. In production it includes only the production frontend origin. Reading from an environment variable means you can update the allowlist without a code deployment, and you avoid accidentally shipping development origins to production.
Log every CORS rejection on the server side. When the cors middleware in Express blocks an origin, it calls the callback with an error, but this error is silent by default. Override the origin callback to log rejected origins with the request timestamp and path — this data is invaluable when a new frontend deployment starts failing because someone forgot to add the new CDN domain to the allowlist.
Prefer handling CORS at the application layer rather than exclusively at the infrastructure layer. nginx and load balancers can handle the simple cases, but application-level CORS middleware is easier to test, easier to version control, and easier to update without infrastructure changes. The application also has access to the per-route context needed for fine-grained control — some routes may need credentials while others serve public data and can use a wildcard.
Use the /tools/http-request-builder tool to craft your OPTIONS request with the exact headers your frontend will send, and confirm the server response before writing code. This takes 30 seconds and saves hours of debugging. Then use /tools/cors-tester to verify the live API endpoint returns correct headers from the origin you deploy to.
Document the CORS configuration in your project's API documentation so that future developers know which origins are allowed, what credentials policy applies, and where the configuration lives. When rotating frontend domains or migrating APIs, the CORS configuration is one of the first things to update, and finding it buried in middleware code without documentation costs teams real debugging time.
Quick fix checklist
- ✓Open DevTools Network tab and filter by OPTIONS — confirm the preflight request is appearing and check its status code
- ✓Verify the OPTIONS response includes Access-Control-Allow-Origin with the exact origin value (not wildcard if using credentials)
- ✓Check that Access-Control-Allow-Headers lists every custom header your fetch call sends
- ✓Confirm CORS middleware is registered before authentication middleware in the Express app
- ✓Add app.options('*', cors(options)) to explicitly handle OPTIONS at all routes
- ✓If using credentials: 'include', set Access-Control-Allow-Credentials: true and remove any wildcard origins
- ✓For nginx, add an if block that returns 204 with CORS headers when request_method is OPTIONS
- ✓Test the fix in the actual browser with DevTools open, not just in curl or Postman
Related guides
Frequently asked questions
Why does my API work in Postman but fail in the browser?
Postman and curl bypass CORS enforcement because they are not browsers. Browsers enforce the CORS specification by sending an OPTIONS preflight and checking the server's response headers before allowing the real request. If your server does not respond to OPTIONS with the correct Access-Control-Allow-Origin and related headers, the browser blocks the request — but Postman will make the same call without issue because it does not apply cross-origin restrictions.
What does 'Response to preflight does not pass access control check' mean?
This Chrome error means the browser sent an OPTIONS preflight and the server's response was missing or had incorrect CORS headers. The most common causes are a missing Access-Control-Allow-Origin header, a header value that does not match the request's Origin, a missing Access-Control-Allow-Headers entry for a custom header you are sending, or the server returning 404 or 405 for the OPTIONS method instead of a 204.
Can I just set Access-Control-Allow-Origin: * to fix everything?
Wildcard origin works for public APIs that do not require credentials, but it does not work when you set credentials: 'include' in your fetch call or send cookies. The browser will reject the response with an error explaining that wildcard origins and credentials are incompatible. For authenticated APIs you must reflect the exact origin of the request, e.g., Access-Control-Allow-Origin: https://app.example.com.
Why is the preflight being sent on every request even after I set Access-Control-Max-Age?
Make sure the Access-Control-Max-Age header is present in the actual preflight response, not just in the regular response. Chrome's maximum cacheable preflight duration is 7200 seconds regardless of what value you set. Also verify that your response is being cached — navigating or clearing cache in DevTools resets the preflight cache. Firefox and Safari have different maximum values, so setting 600 seconds is a safe cross-browser choice.
My CORS headers look correct but Safari still fails. Why?
Safari is stricter about wildcard handling in Access-Control-Allow-Headers. Chrome often accepts Access-Control-Allow-Headers: * but Safari requires each header to be listed explicitly. Write out every header name your frontend sends: Authorization, Content-Type, X-Request-ID, or any custom header. Safari also sometimes ignores cached preflight results more aggressively, so the effective max-age can be shorter than configured.
How do I handle CORS on Vercel serverless API routes?
In Vercel, add a headers array to vercel.json with the source matching your API path pattern and the required CORS headers as key-value pairs. For dynamic origins, handle CORS inside the API route handler by reading req.headers.origin, checking it against your allowlist, and setting the response headers before returning any data. Also handle the OPTIONS method explicitly in the route to return 204 with no body.
Does CORS apply to requests between two backend servers?
No. CORS is a browser security mechanism. When a Node.js server, Python script, or any non-browser client makes an HTTP request, CORS headers are ignored entirely. CORS only applies when a browser script makes a cross-origin request. Backend-to-backend calls can use any URL without CORS restrictions, though other authentication and firewall restrictions still apply.
Why does my preflight return 200 but the real request still fails?
A successful preflight only means the OPTIONS response had correct CORS headers. The real request (POST, PUT, etc.) can still fail if the actual response does not include Access-Control-Allow-Origin. CORS headers must appear in every response, not just the preflight. Your server middleware must add CORS headers to non-OPTIONS responses too, which most cors packages do automatically if configured correctly.
All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.