TL;DR: CORS Errors Are Fixed on the Server (Response Headers)
Seeing a red error in the console and landed here from search? We'll skip the theory lecture and go straight to diagnosis. Three-line summary: CORS is a browser security policy that blocks requests sent to a different origin (protocol + domain + port). The key point: the headers that lift this block must be sent by the server (or Nginx in front of it), not the frontend. In other words, most CORS errors are a backend configuration problem. If the error comes from Jupyter Notebook, JupyterLab, or Colab, jump to the Jupyter section below for the allow_origin config.
Start by Dissecting the Error Message
This is the most common message.
Access to fetch at 'https://api.myapp.com/users' from origin 'https://myapp.com'
has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.Read it phrase by phrase and the diagnosis is done.
Access to fetch at 'https://api.myapp.com/users'→ the target server the request was sent tofrom origin 'https://myapp.com'→ the origin (frontend) that sent the requestNo 'Access-Control-Allow-Origin' header is present→ the root cause: the allow header is missing from the server response
The last line is the conclusion. The server did not send Access-Control-Allow-Origin, so the place to fix is the backend. Note that even when this error appears, server logs often show the request arrived normally and returned 200. The response did arrive, but the browser discarded it instead of handing it to JS because the header was missing.
5-Minute Diagnosis: Pinpoint Responsibility with the Network Tab
Open DevTools → Network tab and click the blocked request. Use this checklist to decide in under 5 minutes.
① Do you see a preflight (OPTIONS) row?
If an OPTIONS method row for the same URL appears first in the request list, a preflight occurred. Preflight is automatically attached under these conditions:
PUT,DELETE,PATCHmethodsContent-Type: application/json- Adding a custom header such as
Authorization
GET/POST (form-encoded) with none of the above is a simple request and goes through without OPTIONS.
② Is Access-Control-Allow-Origin in the Response Headers?
③ What is the status?
| OPTIONS status | ACAO header | Verdict |
|---|---|---|
| No OPTIONS + no ACAO on the actual request response | Missing | Server issue — add header config |
| OPTIONS fails with 404/500 | - | Server issue — OPTIONS route not handled |
| OPTIONS 200/204 but no ACAO | Missing | Server issue — preflight response headers missing |
ACAO is * while using credentials | * | Pitfall (see trap ① below) |
| All headers look fine but still blocked | Present | Suspect frontend credentials / origin typo |
Bottom line: if the header is missing, the problem is on the server (or the proxy in front of it). Changing fetch options on the frontend will not fix it.
Cause Table by Console Error Message
Look up the exact English text from your console in the table below and it tells you what to fix.
| Console error text | Cause | What to fix |
|---|---|---|
No 'Access-Control-Allow-Origin' header is present on the requested resource | No allow header in the response | Add CORS headers on the server (or the proxy in front of it) |
Response to preflight request doesn't pass access control check | The OPTIONS response is 4xx/5xx or lacks CORS headers | Handle OPTIONS before the auth filter and answer 204 with CORS headers |
Method PUT is not allowed by Access-Control-Allow-Methods in preflight response | The method isn't in the allowed list | Add it to Access-Control-Allow-Methods |
Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response | The header isn't in the allowed list | Add it to Access-Control-Allow-Headers |
... must not be the wildcard '*' when the request's credentials mode is 'include' | * combined with cookie auth | Name the origin (or reflect it after an allow-list check) + Access-Control-Allow-Credentials: true |
The 'Access-Control-Allow-Origin' header contains multiple values '...', but only one is allowed | The proxy and the backend both add the header | Add CORS headers in one layer only |
Redirect is not allowed for a preflight request | The preflight was redirected with 301/302 (trailing slash, http→https, etc.) | Call the final URL directly, with no redirect |
Redirects cause trouble for the actual request too. The 3xx response also needs CORS headers, and they are easy to lose when a proxy or framework generates the redirect. Where possible, have the frontend call the final URL directly.
Check the Origin against an allow-list, reflect it, and add Vary: Origin
If you allow several origins and also use cookies, compare the request's Origin with an allow-list and echo it back only on a match.
const allowList = new Set(['https://app.com', 'https://admin.app.com']);
function setCors(req, res) {
const origin = req.headers.origin;
if (allowList.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin); // not '*'
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
res.setHeader('Vary', 'Origin'); // the response varies by origin, so include Origin in the cache key
}Without Vary: Origin, a CDN or proxy can serve a response meant for one origin to another, which is cache poisoning.
Copy-Paste Fix Code by Server
Express (cors middleware)
const cors = require('cors');
app.use(cors({
origin: 'https://myapp.com', // when
credentials: true, // / when
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 7200, // cache preflight for 2 hours (Chromium cap)
}));The cors middleware automatically handles OPTIONS preflight with a 204 response. To allow multiple origins, pass an array or a function to origin.
Spring Boot
To open a specific controller only, use the annotation:
@CrossOrigin(origins = "https://myapp.com", allowCredentials = "true")
@RestController
public class UserController { ... }For a clean global setup, the Bean approach is recommended.
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://myapp.com"));
config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
config.setAllowedHeaders(List.of("Content-Type","Authorization"));
config.setAllowCredentials(true);
config.setMaxAge(7200L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}If you use Spring Security, add http.cors(Customizer.withDefaults()) so the Bean above actually applies. Otherwise the filter chain will block OPTIONS first.
Nginx
location /api/ {
add_header Access-Control-Allow-Origin "https://myapp.com" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Max-Age 7200 always;
if ($request_method = OPTIONS) {
return 204; # preflight 204 when reply
}
proxy_pass http://backend;
}Two things matter. Without the always flag, headers are not attached to 4xx/5xx responses, so CORS fires again in error cases. And OPTIONS should be cut off immediately with 204 and no body so it never reaches the backend.
FastAPI (CORSMiddleware)
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.com", "https://admin.app.com"], # use an explicit list with credentials
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Content-Type", "Authorization"],
max_age=600,
)Don't combine allow_credentials=True with allow_origins=["*"]. With that combination Starlette (which FastAPI is built on) doesn't raise an error; it reflects the request's Origin on credentialed requests and preflights, which ends up allowing credentialed requests from every site. The FastAPI docs also say to list the allowed origins explicitly when you use credentials.
When You Get a CORS Error in Jupyter Notebook, JupyterLab, or Colab (allow_origin Config)
First, figure out which side is the browser request. Calling an external API with Python requests from a notebook cell is a request sent by the kernel (server), not the browser, so CORS does not apply. You only get a CORS error in these two cases:
- Browser → external API: a
%%javascriptcell, ipywidgets/JupyterLab extension, or HTML inside the notebook calls another domain's API withfetch→ the place to fix is that API server. Use the Express/Spring/Nginx code above to allow the Jupyter origin (e.g.http://localhost:8888). For causes by exact console error text, see the cause table above. - External web app → Jupyter server: a React app (
http://localhost:3000), a Google Colab local runtime, etc. calls Jupyter's REST API/WebSocket → the place to fix is the Jupyter server config. If the server log showsBlocking Cross Origin API request for /api/... Origin: ..., Host: ..., this is your case.
JupyterLab 3+ / Notebook 7 (Jupyter Server)
JupyterLab (since 3.0) and Notebook (since 7) run on Jupyter Server (jupyter_server). So the server option prefix is ServerApp and the config file is jupyter_server_config.py.
# Generate the config file: ~/.jupyter/jupyter_server_config.py
jupyter server --generate-config# ~/.jupyter/jupyter_server_config.py
c.ServerApp.allow_origin = "http://localhost:3000" # exactly one allowed origin
c.ServerApp.allow_credentials = True # only for requests with cookies/auth
# Need several origins? Use a regex (ignored if allow_origin is set)
# Always anchor it with ^ and $
# c.ServerApp.allow_origin_pat = r"^https://(app|admin)\.example\.com$"For a one-off run, command-line flags work too.
jupyter lab --ServerApp.allow_origin='http://localhost:3000' --ServerApp.allow_credentials=TrueClassic Notebook (6.x and earlier)
The classic notebook uses the NotebookApp prefix and a different config file.
jupyter notebook --generate-config # ~/.jupyter/jupyter_notebook_config.py# ~/.jupyter/jupyter_notebook_config.py
c.NotebookApp.allow_origin = "http://localhost:3000"
c.NotebookApp.allow_credentials = True
# c.NotebookApp.allow_origin_pat = r"^https://app\.example\.com$"On Notebook 7, server options given with the old NotebookApp prefix are passed to ServerApp by the compatibility layer (notebook_shim) with a "has moved from NotebookApp to ServerApp" warning. For new setups, use ServerApp.
Google Colab Local Runtime
Colab's official Local runtimes doc starts a Jupyter runtime with this command, which explicitly trusts the Colab frontend origin:
jupyter notebook \
--NotebookApp.allow_origin='https://colab.research.google.com' \
--port=8888 \
--NotebookApp.port_retries=0 \
--NotebookApp.allow_credentials=TrueOnce the server starts, paste the printed http://localhost:8888/?token=... URL into the dialog from Colab's "Connect" button → "Connect to local runtime...". On Notebook 7, these NotebookApp flags are forwarded to ServerApp via notebook_shim (with a warning). Colab also offers a Docker runtime image; in that case, connect with the URL the container prints.
Tip: If nothing changed after editing the config, check ① that you used the right prefix (
ServerAppvsNotebookApp) and config file for your version, ② that you restarted the server, and ③ whether the error text containsNo 'Access-Control-Allow-Private-Network' header was present in the preflight response. If it is ③, the problem is not allow_origin but Chrome's Private Network Access restriction on public sites callinglocalhost(Colab issue googlecolab/colabtools#5506).
⚠️ Security: never use
allow_origin = '*'in production. A Jupyter server grants arbitrary code execution to whoever connects.*allows any website, which opens a path for requests to your notebook API through the user's browser. Specify allowed origins exactly, and anchorallow_origin_patwith^...$. In jupyter-server 2.17.0 and earlier, an unanchored pattern could let a domain liketrusted.example.com.evil.compass (advisory GHSA-24qx-w28j-9m6p, patched in 2.18.0). Do not disable token/password auth, and put the server behind an HTTPS reverse proxy when exposing it. Kernels talk over WebSocket, so the proxy needs WebSocket upgrade settings (see Nginx Reverse Proxy Config: Copy-Paste Examples for Routing API, Admin, and Frontend on One Domain).
Headers You Must Include on the Preflight Response
OPTIONS preflight is the step that asks "is it OK to send the actual request?" The browser decides whether to proceed based on this response.
Access-Control-Allow-Methods: allowed methods. Without it, PUT/DELETE get blockedAccess-Control-Allow-Headers: allowed custom headers. IfAuthorizationis missing, token requests are blockedAccess-Control-Max-Age: how long to cache the preflight result, in seconds. Reduces OPTIONS firing before every request. Browsers cap it: Chromium honors at most 7200 seconds (2 hours) and Firefox 86400 seconds (24 hours); larger values are clamped to the cap (MDN)
OPTIONS does not receive data, so 204 with no body is the standard.
Two Common Pitfalls
① credentials: 'include' + wildcard (*) is forbidden
This combination blows up often with cookie-based auth. If the frontend sends:
fetch('https://api.myapp.com/me', { credentials: 'include' });the moment the server returns Access-Control-Allow-Origin: *, you get this error.
The value of the 'Access-Control-Allow-Origin' header in the response
must not be the wildcard '*' when the request's credentials mode is 'include'.Fix: specify a concrete origin instead of a wildcard, and pair it with Access-Control-Allow-Credentials: true. Same as the Express/Spring examples above. The cookie itself also needs SameSite=None; Secure to be sent cross-site, so handle those as a set.
A note from production: CORS issues exploded once we split frontend and API with a BFF and microservices. In my case, what ate the most time was not the CORS code but Spring Security filters blocking OPTIONS with 401 first. If OPTIONS is 401 in the Network tab, suspect the auth filter, not CORS config.
② Bypass locally with a dev proxy (not a production fix)
During local development, you can make the browser think it is the same origin via a proxy and bypass CORS entirely.
Vite (vite.config.js):
export default {
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
};webpack devServer:
devServer: {
proxy: { '/api': 'http://localhost:8080' },
}The dev server forwards requests starting with /api to the backend, so from the browser's perspective it is same-origin. This does not apply to a production build. In production you must solve it with the server-side CORS config above.
Conclusion: Diagnosis → Fix Cheatsheet
- Read the error message —
No Access-Control-Allow-Origin= confirmed server problem - Network tab — judge frontend vs server from the OPTIONS row / ACAO header / status
- Add headers on the server — Express
cors(), SpringCorsConfigurationSource, Nginxadd_header ... always - Handle preflight — OPTIONS returns 204, with Methods/Headers/Max-Age as a set
- If you use credentials — no wildcard; concrete origin + Allow-Credentials true
- Proxy locally, server config in production — never mix these up
One last action item. Do not spray Access-Control-Allow-Origin: * in production. The error disappears immediately, but you are opening authenticated APIs to any site. Explicitly whitelist allowed origins.
FAQ
Q. The server log shows 200, so why does the frontend still get a CORS error?
A. The response arrived, but the browser blocked it just before handing it to JS because the Access-Control-Allow-Origin header was missing. The server can be working fine and you still get the error if only the header is absent. Add CORS response headers on the server.
Q. The OPTIONS request fails with 401/404. What should I do? A. You are blocked at the preflight stage. A Spring Security (or similar) auth filter is treating OPTIONS as an authenticated request, or the router has no OPTIONS handler. Exempt OPTIONS from auth and return 204.
Q. The local Vite proxy works, but CORS blows up again after deploy. A. The dev proxy only runs on the development server and is not included in the build output. In production you must set CORS headers directly on the backend or Nginx.
Sources and checks vary by article. Only articles with a recorded check date, scope and sources below were checked, and only within that scope. For other articles, verify against the current docs for your version before applying anything, and email us about errors. Editorial policy · email us
This article has no recorded human check (date and scope) yet.
Comments
Be the first to comment.