Notes

Response to preflight request doesn't pass access control check:Redirect is not allowed for a preflight request

CORS Doctor

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 OPTIONS request 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: the redirect option of fetch() 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-Origin as 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:

How one trailing slash turns into Redirect is not allowed for a preflight request Your code sends POST https://api.example.com/items. Step one: the browser sends the preflight, OPTIONS /items. The server answers 308 Permanent Redirect with Location: /items/. Chrome stops with Redirect is not allowed for a preflight request, and the POST is never sent. Step two: compare the two URLs character by character. You call slash items; the route is slash items slash, one character longer. Step three, the fix: call the URL from the Location header. OPTIONS /items/ gets 204 and POST /items/ gets 200, so there is no redirect left for Chrome to refuse. The same failure happens when the Location changes another part of the URL: the scheme from http to https, the host from example.com to www.example.com, or the path to a login page. POST https://api.example.com/items 1 The browser sends the preflight OPTIONS /items THE SERVER ANSWERS 308 Permanent Redirect Location: /items/ Redirect is not allowed Chrome stops. The POST is never sent. 2 Compare the two URLs YOU CALL / i t e m s THE ROUTE / i t e m s / +1 3 Fix: call the URL from Location OPTIONS /items/ 204 POST /items/ 200 No redirect left for Chrome to refuse SAME FAILURE, ANOTHER PART OF THE URL SCHEME http:// → https:// HOST example.com → www.example.com AUTH /items → /login
The preflight goes to the URL in your code. If the server prefers another form of that URL, it answers with a 3xx, and Chrome ends the preflight right there, whatever headers the redirect carries. Here the difference is one trailing slash; in the other common cases it is the scheme, the host, or a login page. The fix is the same shape every time: send the request to the URL from the 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 in MIDDLEWARE, and APPEND_SLASH, which defaults to True, 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 /projects to 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. With CORSMiddleware, the way FastAPI's documentation sets up CORS, the preflight never gets that far: the middleware intercepts every OPTIONS request that carries Origin and Access-Control-Request-Method and 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 the OPTIONS request reaches it, for example when CORSMiddleware is missing, or when CORS is handled in front of the app and OPTIONS is passed through.
  • Laravel behind Apache: the public/.htaccess in 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 by proxy_pass, a request for /api without 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

  1. Read the URL from the message, after "Access to XMLHttpRequest at" or "Access to fetch at". That is the URL your code called.
  2. Send the preflight with curl as shown above, and read the status line and the Location header.
  3. Match the Location to a cause. The same URL with or without a trailing slash: 01. Another host: 02. https instead of http: 03. A login page or an identity provider: 04.
  4. Change the right side. For 01 to 03, put the exact Location URL into your frontend: the base URL in your config or environment, and the path in the call. For 04, make the CORS layer answer OPTIONS before authentication.
  5. Run the same curl command again with the new URL, until the answer is a 2xx without a Location header.

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 Location header. A 200 is as good as a 204; django-cors-headers, for example, answers the preflight with an empty 200.
  • Access-Control-Allow-Origin is 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 same Origin header 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-Origin and Access-Control-Allow-Headers as 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: null on the next request, and the standard removes the Authorization header 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