Notes
Response to preflight request doesn't pass access control check:Redirect is not allowed for a preflight request
Your frontend calls an API, nothing reaches the handler, and Chrome's console prints this:
Access to XMLHttpRequest at 'https://api.example.com/items' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: Redirect is not allowed for a preflight request.Before your real request, the browser sent an OPTIONS request to the same URL to ask whether the real one is allowed. That is the preflight. Something on the server side answered it with a redirect: a 301, 302, 303, 307 or 308 status and a Location header pointing somewhere else. Chrome does not follow a redirect on a preflight. In Chromium's source, the preflight loader ends with exactly this error the moment a redirect arrives, without reading any header on it. The preflight fails, and your POST, PUT or DELETE is never sent.
So the fix is usually not more CORS configuration. Your Access-Control-Allow-* headers may be fine. Some layer wants the request at a slightly different URL: with or without a trailing slash, on www, on https, or on a login page. Find that URL, call it directly, and the error goes away together with the redirect.
The message itself will not tell you where the redirect points. The URL after "Access to XMLHttpRequest at" is the one your code called, because the redirect was never followed; a fetch() call prints "Access to fetch at" in the same place. The target sits in the Location header of the OPTIONS response, and the section after the diagram shows how to read it. Other browsers word the failure differently. In the 2024 question linked below, Safari reported the same redirect as "Preflight response is not successful. Status code: 307". Firefox prints "Reason: CORS request external redirect not allowed". MDN describes that message for a redirect to another origin, but Firefox's source logs it for any redirect of a preflight, including one that only adds a slash on the same host; only Firefox's own internal redirects to the same URL and HSTS upgrades get through. MDN's fix is the same as here: use the URL the redirect points to.
An older Chrome wording, Redirect from 'A' to 'B' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource., came from a redirect on a request without a preflight: in the best-known case, from 2017, a jQuery GET redirected to the same URL with a slash added. Current Chromium source no longer has that sentence, and the same situation now ends in the message covered by the note on No 'Access-Control-Allow-Origin' header is present.
What the browser does with a redirect
The rules differ between the preflight and the request that follows it:
- The preflight never follows a redirect. The Fetch Standard builds the preflight as a separate
OPTIONSrequest and fetches it through a step that does not handle redirects. It accepts the answer only if it passes the CORS check and has an ok status, which the standard defines as 200 to 299. A 3xx is not one, so the preflight ends in a network error. No option on your own call changes that: theredirectoption offetch()belongs to your request, and"follow"is already its default. - The actual request can follow one, with conditions. For a request that needs no preflight, or after a preflight that passed, the standard runs the CORS check on the 3xx itself and only then follows it, so the redirect response needs
Access-Control-Allow-Originas well. MDN adds that not all browsers follow redirects after a preflighted request, and quotes errors such as "Request requires preflight, which is disallowed to follow cross-origin redirects".
In short: a redirect in front of a CORS endpoint is fragile even where it works, and on a preflight it never works. The case that comes up most often looks like this:
Location header, so nothing needs to be redirected.Find the redirect with one curl command
curl does not send preflights on its own, so send one by hand, with the headers the browser would use. Take the URL, the method and the header names from your request. Leave out cookies and tokens, because the browser leaves them out of a preflight too.
Terminal
curl -i -X OPTIONS https://api.example.com/items \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type"
On Windows, type curl.exe instead of curl, because in Windows PowerShell 5.1 curl is an alias for Invoke-WebRequest, which takes different parameters. Put the whole command on one line, without the backslashes.
Without -L, curl does not follow redirects: it prints the first answer and stops, which is exactly the answer the browser refused. With this error, the status line is a 3xx and there is a Location header:
The part of the answer that matters
HTTP/1.1 308 Permanent Redirect
Location: https://api.example.com/items/
Compare the Location with the URL in your code, character by character: scheme, host, port, path and the trailing slash. The difference tells you which of the causes below you have. In the browser, the accepted answer on the 2024 question points out that Chrome lists preflights in the Network tab while the All or Other filter is active; the Location response header of that OPTIONS row gives the same answer.
01
A trailing slash your framework adds or removes
This is the cause that comes up most in the questions linked below. Many frameworks treat /items and /items/ as one resource with one canonical form and redirect the other form to it. The preflight goes to whichever form is in your code:
- Django: with
CommonMiddleware, which a new project has inMIDDLEWARE, andAPPEND_SLASH, which defaults toTrue, a URL without a slash that only matches a pattern with one is redirected to the slash version. - Flask: a route declared as
/projects/is canonical with the slash, and Flask redirects/projectsto it. Werkzeug, the routing library underneath, sends that redirect as a 308. - FastAPI: FastAPI inherits from Starlette, whose router answers with a 307 to the other form, adding or removing the slash, when only that form matches a route. The option is
redirect_slashes, on by default. WithCORSMiddleware, the way FastAPI's documentation sets up CORS, the preflight never gets that far: the middleware intercepts everyOPTIONSrequest that carriesOriginandAccess-Control-Request-Methodand answers it itself with a 200 or a 400. The 307 then only hits the real request (see "Keeping the redirect for the real request" further down). The router redirects the preflight only when theOPTIONSrequest reaches it, for example whenCORSMiddlewareis missing, or when CORS is handled in front of the app andOPTIONSis passed through. - Laravel behind Apache: the
public/.htaccessin the Laravel skeleton redirects URLs that end in a slash to the version without it, with a 301, before Laravel or its CORS middleware see the request. The highest-voted answer on the 2019 question below describes that direction:/api/methods/redirected to/api/methods. - nginx: for a
location /api/handled byproxy_pass, a request for/apiwithout the slash gets a 301 to/api/.
The fix is on the calling side: use the canonical form, and take it from the Location header, not from memory.
Frontend
// the route is /items/, so OPTIONS /items got a 308
await fetch('https://api.example.com/items', { method: 'POST', headers, body });
// the exact URL from the Location header
await fetch('https://api.example.com/items/', { method: 'POST', headers, body });
In Django, the order of middleware matters too. The django-cors-headers README asks for CorsMiddleware as high as possible, and before CommonMiddleware in particular. Its source answers a preflight right there with an empty response, for the URLs it is configured to cover, so the preflight never reaches the slash redirect:
Django settings.py, order from the django-cors-headers README
MIDDLEWARE = [
...,
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
...,
]
That fixes the preflight, but the POST that follows still meets the redirect, and Django's own documentation warns that this redirect may lose data submitted in a POST. Order the middleware correctly and still call the canonical URL.
02
Another host: www, the bare domain, or a different name
The redirect can change the host instead of the path. In the 2024 question below, a React app called an Express API deployed on Render. The preflight got a 307, and its Location pointed at a www address that the axios request URL did not have. Adding www to the request URL fixed it. Django can do the same on purpose: with PREPEND_WWW, CommonMiddleware redirects URLs without www. to the same URL with it.
Fix: call the host from the Location header, or change the domain rule so that the API hostname is not redirected at all. A redirect to another host is also a redirect to another origin, which causes more trouble even for the actual request (see the traps further down).
03
http in the request URL, https on the server
A base URL that still says http://, often from an environment file written during local development, meets a server that sends every plain HTTP request to HTTPS. In Django, that is SECURE_SSL_REDIRECT = True: SecurityMiddleware then redirects all non-HTTPS requests to HTTPS. The preflight is the first request to hit that redirect. This version of the error shows up when the page itself runs on http, typically a local dev server. From a page served over https, the browser blocks a fetch() or XMLHttpRequest to an http:// URL as mixed content, and the console shows a mixed-content error instead.
Fix: put https:// in the base URL, in every environment file, not only in the one you are testing.
04
The server sends anonymous requests to a login page
A preflight never carries cookies or other credentials. MDN puts it as "CORS-preflight requests must never include credentials". An app that protects its routes with a session cookie therefore sees the preflight as an anonymous visitor, and a setup that answers anonymous visitors with a redirect to the login page does exactly that. Spring Security's documentation describes the first half: CORS must be processed before Spring Security, because the preflight contains no cookies (no JSESSIONID), so Spring Security decides the user is not authenticated and rejects the request. With form login, that rejection is a redirect to the login page. In curl, you see it as a Location pointing at /login or at an identity provider.
Here, calling another URL does not help. CORS has to answer the preflight before authentication runs. Spring Security's documentation says the easiest way to get CORS handled first is its CorsFilter, set up by providing a CorsConfigurationSource bean:
Spring Security, based on its CORS documentation
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000"));
configuration.setAllowedMethods(Arrays.asList("GET","POST"));
// the header names your request sends; not set by default
configuration.setAllowedHeaders(Arrays.asList("Authorization","Content-Type"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
For other stacks the rule is the same: the CORS layer runs first and answers OPTIONS itself. The note on "It does not have HTTP ok status" shows that order for Express and ASP.NET Core, where the same mistake ends in a 401 instead of a redirect.
The fix, step by step
- Read the URL from the message, after "Access to XMLHttpRequest at" or "Access to fetch at". That is the URL your code called.
- Send the preflight with curl as shown above, and read the status line and the
Locationheader. - Match the
Locationto a cause. The same URL with or without a trailing slash: 01. Another host: 02.httpsinstead ofhttp: 03. A login page or an identity provider: 04. - Change the right side. For 01 to 03, put the exact
LocationURL into your frontend: the base URL in your config or environment, and the path in the call. For 04, make the CORS layer answerOPTIONSbefore authentication. - Run the same curl command again with the new URL, until the answer is a 2xx without a
Locationheader.
How to tell it is fixed
The preflight answer should now look roughly like this:
A preflight answer that passes
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
- The status is between 200 and 299 and there is no
Locationheader. A 200 is as good as a 204; django-cors-headers, for example, answers the preflight with an empty 200. Access-Control-Allow-Originis exactly your page's origin, or*if the request is sent without credentials. Header names are case-insensitive, so lowercase names in curl's output are fine.- The real request is not a 3xx either. Check it with
curl -i, the sameOriginheader and the real method. A POST will really run, so use test data.
Then reload the page with DevTools open on the Network tab. The OPTIONS row should show a 2xx, followed by your real request. If the console now shows a different preflight message, such as "It does not have HTTP ok status" or "Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response", the redirect is gone and the next layer is talking. The first of those has its own note.
Things that look like fixes but are not
- CORS headers on the redirect. Chrome ends the preflight at a redirect without reading its headers. Headers on a 3xx only count for a request that needed no preflight, or for the actual request after a preflight that passed.
- Access-Control-Allow-Origin on the request. In the 2019 question below, the axios defaults set
Access-Control-Allow-OriginandAccess-Control-Allow-Headersas request headers. Both are response headers. On a request they are just headers outside the CORS-safelisted set, so each of them forces a preflight by itself. - Keeping the redirect for the real request. Even where the browser follows it, the Fetch Standard turns a POST into a GET without a body on a 301 or 302, and every method except GET and HEAD into a GET on a 303. Only 307 and 308 keep the method and the body. A redirect to another origin also makes the browser send
Origin: nullon the next request, and the standard removes theAuthorizationheader at that point. - A browser extension or
--disable-web-security. The accepted answers on the two older questions below include these. They change only your own browser, and every user still gets the error.
Check your case
Paste the console message into CORS Doctor and fill in your request. Open CORS Doctor with this note's example filled in: a POST with Authorization and a JSON body, the Chrome message above, and a preflight answered with 308 that even carries the right Access-Control-Allow-Origin. It recognizes the redirect message, flags the 3xx preflight status as a failure, and lists why your request needs a preflight at all (here, the Authorization header and the JSON Content-Type), which tells you whether avoiding the preflight is even an option. Next to the redirect finding it also lists its general one for any preflight without a 2xx, which talks about auth middleware and a 401 or 404. That text fits cause 04; for a slash, www or https redirect, the fix is still the URL from Location. The tool cannot see your Location header, so the curl command above is still what finds the redirect target. The check runs in your browser and does not call your API.
Questions
Why does Postman reach the endpoint when the browser does not?
Postman sends no preflight, and it follows a 3xx on its own unless you switch off its "Automatically follow redirects" setting, so you never see the redirect there. curl without -L prints the 3xx and stops, which makes it the right tool to see what the browser saw, as long as you send the OPTIONS request by hand with the same Origin and Access-Control-Request-* headers.
Why does only one route fail?
Because only that URL differs from its canonical form, or only that route sits behind authentication. The 2019 question linked on this page asks exactly this. Compare the failing URL with its route definition, including the trailing slash, or send the preflight to it with curl and read the Location header.
Can I make the browser follow the redirect on the preflight?
No. The Fetch Standard fetches the preflight without following redirects, and Chromium fails it on the first one. MDN describes a workaround for browsers that refuse redirects after a preflight: send a simple request first, read the final URL from Response.url or XMLHttpRequest.responseURL, then send the real request there. MDN also says this does not work when the preflight is caused by an Authorization header, and that in that case there is no workaround at all without control over the server.
Does it matter whether the server sends 301, 302, 307 or 308?
Not for the preflight: every redirect status fails it. It matters for the real request. According to the Fetch Standard, a 301 or 302 turns a POST into a GET without a body, and a 303 does that to every method except GET and HEAD. A 307 or 308 keeps the method and the body.
Sources
- MDN, "Reason: CORS request external redirect not allowed" (a CORS redirect to another origin; fix: use the new URL from the redirect) and "CORS errors" (these reason messages are Firefox's console messages): developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS/Errors/CORSExternalRedirectNotAllowed, developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS/Errors
- Firefox source,
nsCORSPreflightListener::AsyncOnChannelRedirectinnsCORSListenerProxy.cpp(every redirect of a preflight except an internal same-URI redirect or an HSTS upgrade is blocked and logged asCORSExternalRedirectNotAllowed): github.com/mozilla-firefox/firefox/blob/main/netwerk/protocol/http/nsCORSListenerProxy.cpp - MDN, "Cross-Origin Resource Sharing (CORS)", section "Preflighted requests and redirects" (not all browsers follow redirects after a preflighted request, the quoted error messages, the Response.url and responseURL workaround and its limit with Authorization), plus "Simple requests" and "CORS-preflight requests must never include credentials": developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS#preflighted_requests_and_redirects
- MDN, "CORS-safelisted request header" (Access-Control-Allow-Origin is not one of them): developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header
- MDN, "Mixed content" (fetch() and XMLHttpRequest requests to http from an https page are blockable content, and browsers block them): developer.mozilla.org/en-US/docs/Web/Security/Mixed_content
- WHATWG Fetch Standard, "CORS-preflight fetch" (a new OPTIONS request fetched with HTTP-network-or-cache fetch; success needs a CORS check and an ok status, otherwise a network error): fetch.spec.whatwg.org/#cors-preflight-fetch
- WHATWG Fetch Standard, "ok status" (200 to 299) and "redirect status" (301, 302, 303, 307, 308): fetch.spec.whatwg.org/#ok-status, fetch.spec.whatwg.org/#redirect-status
- WHATWG Fetch Standard, "HTTP fetch" (the CORS check runs on a response before its redirect is followed; redirect modes) and "HTTP-redirect fetch" (301 or 302 on POST and 303 on other methods become GET without a body; the Authorization header is removed when a redirect leads to another origin): fetch.spec.whatwg.org/#http-fetch, fetch.spec.whatwg.org/#http-redirect-fetch
- WHATWG Fetch Standard, "redirect-taint" and "serializing a request origin" (after a redirect across origins the Origin is serialized as null) and the request's redirect mode (default "follow"): fetch.spec.whatwg.org/#concept-request-tainted-origin, fetch.spec.whatwg.org/#serializing-a-request-origin, fetch.spec.whatwg.org/#concept-request-redirect-mode
- Chromium source,
PreflightLoader::HandleRedirect(any redirect on a preflight ends it withkPreflightDisallowedRedirect): source.chromium.org/chromium/chromium/src/+/main:services/network/cors/preflight_controller.cc - Chromium source,
cors_error_string.cc(the "Access to ... at" prefix with the requested URL, "(redirected from" only after a followed redirect, and the text "Redirect is not allowed for a preflight request."): source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/loader/cors/cors_error_string.cc - Chromium source,
CorsURLLoader::OnReceiveRedirect(the CORS check on a 3xx of the actual request, before the redirect is followed): source.chromium.org/chromium/chromium/src/+/main:services/network/cors/cors_url_loader.cc - Django, settings
APPEND_SLASH(default True, redirect may lose POST data),PREPEND_WWWandSECURE_SSL_REDIRECT: docs.djangoproject.com/en/stable/ref/settings/#append-slash, #prepend-www, #secure-ssl-redirect - Django, "Middleware":
CommonMiddleware(URL rewriting with APPEND_SLASH and PREPEND_WWW): docs.djangoproject.com/en/stable/ref/middleware/#module-django.middleware.common; the defaultMIDDLEWAREof a new project: github.com/django/django/blob/main/django/conf/project_template/project_name/settings.py-tpl - django-cors-headers, README (CorsMiddleware before CommonMiddleware) and
check_preflightin its middleware source: github.com/adamchainz/django-cors-headers, github.com/adamchainz/django-cors-headers/blob/main/src/corsheaders/middleware.py - Flask, "Quickstart: Unique URLs / Redirection Behavior": flask.palletsprojects.com/en/stable/quickstart/#unique-urls-redirection-behavior; Werkzeug
RequestRedirect(code 308): github.com/pallets/werkzeug/blob/main/src/werkzeug/routing/exceptions.py - FastAPI, "First Steps" (FastAPI inherits directly from Starlette): fastapi.tiangolo.com/tutorial/first-steps/; Starlette
Router(redirect_slashes=True, adds or strips the slash) andRedirectResponse(default 307): github.com/encode/starlette/blob/master/starlette/routing.py, github.com/encode/starlette/blob/master/starlette/responses.py; FastAPI, "CORS (Cross-Origin Resource Sharing)" (CORSMiddleware intercepts a preflight and answers it with a 200 or 400) and the StarletteCORSMiddlewaresource that returns the preflight response before the app runs: fastapi.tiangolo.com/tutorial/cors/, github.com/encode/starlette/blob/master/starlette/middleware/cors.py - Laravel skeleton,
public/.htaccess("Redirect Trailing Slashes If Not A Folder", R=301): github.com/laravel/laravel/blob/12.x/public/.htaccess - nginx,
locationdirective (a prefix location ending in a slash with proxy_pass answers the URI without the slash with a 301): nginx.org/en/docs/http/ngx_http_core_module.html#location - Spring Security, "CORS" (process CORS before Spring Security because the preflight has no cookies; CorsConfigurationSource bean): docs.spring.io/spring-security/reference/servlet/integrations/cors.html; "Form Login" (an unauthenticated request is redirected to the login page): docs.spring.io/spring-security/reference/servlet/authentication/passwords/form.html;
CorsConfiguration.setAllowedHeaders(not set by default): docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/cors/CorsConfiguration.html - RFC 9110, section 5.1 "Field Names" (case-insensitive): rfc-editor.org/rfc/rfc9110#section-5.1
- curl, "curl.1 the man page" (-i, -X, -H, and -L to follow redirects): curl.se/docs/manpage.html; Microsoft Learn, "curl on Windows" (curl is included with Windows; in Windows PowerShell 5.1 the curl alias maps to Invoke-WebRequest, call curl.exe instead): learn.microsoft.com/en-us/windows/curl/
- Postman Docs, "My request is redirected to a GET request" (Postman automatically follows 3xx responses; the "Automatically follow redirects" setting turns it off): learning.postman.com/help/resolve-issues/sending-requests/my-request-is-redirected-to-a-get-request
- Stack Overflow, "Redirect from ... has been blocked by CORS policy" for a jQuery GET redirected to the same URL with a trailing slash (2017, about 1.9 million views; accepted answer: a browser extension): stackoverflow.com/questions/46522749
- Stack Overflow, "Redirect is not allowed for a preflight request" on one route, Laravel and Vue with Access-Control-Allow-Origin set as an axios request header; highest-voted answer: the trailing slash: stackoverflow.com/questions/54212220
- Stack Overflow, "Redirect is not allowed for a preflight request" with Express on Render, Safari's "Preflight response is not successful. Status code: 307", fixed by adding www; accepted answer on reading the preflight and its Location header in the Network tab: stackoverflow.com/questions/78385480