CORS error after deploying frontend and backend separately
THE SHORT ANSWER
Once your frontend and API are deployed to different domains, the browser treats every API call as cross-origin and blocks the response unless the API sends an Access-Control-Allow-Origin header naming the frontend's exact origin. It worked locally because the Vite dev proxy, or a single localhost origin, hid the problem. The fix goes on the backend: allow the deployed frontend's origin, with scheme and no trailing slash, answer OPTIONS preflight requests, and never combine credentials with a * origin.
Why did CORS break only after deploying?
An origin is the scheme, host and port together. https://app.example.com and https://api.example.com are different origins, and so are two subdomains on any hosting platform. When a page on one origin calls an API on another, the browser sends the request, then checks the response for an Access-Control-Allow-Origin header that allows the page's origin. No header, or the wrong origin, and your JavaScript gets an error instead of the response.
Locally it often never comes up. Vite's server.proxy forwards /api to your backend, so the browser only ever talks to one origin, and Vite's docs say that option applies to the dev server only. After the build there is no proxy, the frontend calls the API's real URL, and CORS starts applying.
CORS is enforced by the browser, not the server. The same request from curl or Postman works, which is why the API can look fine when you test it by hand.
What does each error message mean?
Chrome prints all of these as “Access to fetch at ... has been blocked by CORS policy”, followed by the reason:
| The reason Chrome gives | What it means |
|---|---|
No 'Access-Control-Allow-Origin' header is present on the requested resource. | The API sent no CORS headers. Either CORS isn't set up, or the request failed before the CORS code ran: a 500, a 502 from a crashed app, or a 404 on the wrong URL. |
Response to preflight request doesn't pass access control check | The browser's OPTIONS request got no CORS headers or a non-2xx status. The OPTIONS route isn't handled, or auth middleware rejected it. |
The value of the 'Access-Control-Allow-Origin' header ... must not be the wildcard '*' when the request's credentials mode is 'include'. | You send cookies (credentials: 'include', or withCredentials in Axios) and the API allows *. Name the origin instead. |
The Access-Control-Allow-Origin header has a value that is not equal to the supplied origin | The API allows a different origin: http instead of https, a trailing slash, or www against no www. |
Check the first row carefully. If the API crashed, the error page it returns has no CORS headers either, so a server bug shows up in the browser as a CORS error. Open the Network tab and look at the real status code of the failed request, then check the API's logs, before you change any CORS settings.
Why is there an OPTIONS request?
For anything beyond a simple request, the browser first sends an OPTIONS request, the preflight, to ask whether the real one is allowed. A request is simple only if it uses GET, HEAD or POST, and its Content-Type is application/x-www-form-urlencoded, multipart/form-data or text/plain. So a POST with a JSON body, a PUT or DELETE, or any request carrying an Authorization header gets a preflight.
The API must answer that OPTIONS request with a 2xx status and the CORS headers, before any login check. The browser never sends your auth token with the preflight, so middleware that requires one returns 401 and the real request is never sent.
How do I fix it in Express?
Use the cors package and list your frontend's origins. When it runs as app-level middleware, its docs say, “pre-flight requests are already handled for all routes”:
import express from 'express'
import cors from 'cors'
const app = express()
app.use(cors({
origin: ['https://app.example.com', 'http://localhost:5173'],
credentials: true, // only if you send cookies
}))
app.use(express.json())
app.use('/api', apiRouter)Put it before your routes and before any auth middleware. If you copied app.options('*', cors()) from an older example, Express 5 rejects the bare * path, and with app.use(cors()) you don't need that line at all.
How do I fix it in Django?
Install django-cors-headers and put its middleware as high as possible. Its README says it must come before anything that can generate a response, “such as Django's CommonMiddleware or Whitenoise's WhiteNoiseMiddleware”:
INSTALLED_APPS = [
# ...
"corsheaders",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
# ...
"django.middleware.common.CommonMiddleware",
]
CORS_ALLOWED_ORIGINS = [
"https://app.example.com", # scheme + host, no trailing slash
]
CORS_ALLOW_CREDENTIALS = True # only if you send cookiesAn origin with a path, including a trailing slash like https://app.example.com/, fails the package's system check with error E014. If you also send cookies, the frontend's origin must be in CSRF_TRUSTED_ORIGINS for POST requests to pass Django's CSRF check.
How do I fix it in FastAPI?
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True, # only if you send cookies
allow_methods=["*"],
allow_headers=["*"],
)allow_methods defaults to ["GET"], so without it a POST fails its preflight. FastAPI's docs also say none of allow_origins, allow_methods and allow_headers can be ["*"] when allow_credentials is True; when that happens the browser rejects the response.
And in Next.js route handlers?
If your API is Next.js route handlers on a different domain from the frontend, set the headers in next.config for every API path. Next.js answers OPTIONS automatically for a route that doesn't define it:
// next.config.ts
export default {
async headers() {
return [
{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Origin', value: 'https://app.example.com' },
{ key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE,OPTIONS' },
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
],
},
]
},
}CORS passes but cookies still aren't sent?
Cross-site cookies need three things together: credentials: 'include' on the fetch, Access-Control-Allow-Credentials: true with a named origin on the API, and the cookie set with SameSite=None; Secure. MDN notes that SameSite=None requires Secure, so this only works over HTTPS. Django's session cookie defaults to SameSite=Lax, which browsers don't send on cross-site fetches.
Browsers keep tightening third-party cookies, so the more reliable fix is to avoid needing them: put the API on a subdomain of the frontend's domain, use token auth in an Authorization header, or serve the frontend and API from one origin.
What happens on EthioDeploy?
A frontend and an API deployed as two projects get two different addresses, so they are two origins. Allow the frontend's address in the API's CORS settings, and when you add a custom domain, add that origin too. Keeping the allowed origins in an environment variable lets you change them without a code change.
On the frontend side, a Vite app reads the API URL at build time, so set it as a VITE_ variable and redeploy after you change it. See Vite env variables undefined in production.
Questions
CORS is enforced by browsers, not servers. Postman and curl don't check the Access-Control-Allow-Origin header, so the same request succeeds there and is blocked in the browser until the API allows your frontend's origin.
No. The API has to send the CORS headers. In development a proxy like Vite's server.proxy avoids the cross-origin call, but it only runs in the dev server.
For a public API that uses no cookies, it is common. It can't be used with credentials, and for an API behind a login it's better to list the exact frontend origins.
A POST with a JSON body triggers a preflight OPTIONS request, while a plain GET doesn't. If the API doesn't answer OPTIONS with the CORS headers, or requires auth on it, only those requests fail.
No. An origin is scheme, host and port only, such as https://app.example.com. A trailing slash makes it a different string, so the browser doesn't match it, and django-cors-headers rejects it with error E014.
Sources
Checked on November 7, 2026:
Push a repo. Get a URL.
Start freeJoin the EthioDeploy Telegram
Release notes as they ship, plus deploy help from the team and other developers.