How to Fix 'No Access-Control-Allow-Origin Header' CORS Errors (2026)
This guide is for anyone looking at "has been blocked by CORS policy" in the browser console and wanting the fix rather than a lecture. By the end you will be able to tell which of the dozen CORS failures you have from the exact console message, reproduce it with curl, and apply a tested fix in Express, FastAPI, nginx, AWS API Gateway, CloudFront, S3 or a Vite dev proxy. I tested the Express config on Express 5.3.0 with cors 2.8.6, the FastAPI behaviour on FastAPI 0.162.2 and Starlette 1.10.0, and the nginx config on nginx 1.27.5, all locally; the AWS settings are taken from the current AWS docs and the Terraform AWS provider v6.72.0. You need curl, access to the server or gateway config, and five minutes of patience. If you want the theory first (why a preflight happens, why CORS does not protect your server), read CORS explained.
Step 1: Read the exact error in the browser console
JavaScript only gets a generic TypeError: Failed to fetch. MDN's CORS errors page is explicit that the details are not exposed to code and live only in the console. So open DevTools, go to the Console, and copy the whole line. Chrome builds the message from a fixed set of strings in Chromium's cors_error_string.cc, which means each wording maps to one cause:
Access to fetch at 'https://api.example.com/v1/me' from origin 'https://app.example.com'
has been blocked by CORS policy: <one of the reasons below>
No 'Access-Control-Allow-Origin' header is present on the requested resource.
-> server sent no CORS headers (or the request crashed before middleware ran)
Response to preflight request doesn't pass access control check: ...
-> the OPTIONS request failed; the real request was never sent
... It does not have HTTP ok status.
-> preflight returned 401/403/404/405/500 instead of 2xx
... Redirect is not allowed for a preflight request.
-> http->https, trailing slash or login redirect on OPTIONS
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'.
-> credentials: 'include' with Allow-Origin: *
The 'Access-Control-Allow-Origin' header contains multiple values '...', but only one is allowed.
-> two layers (app + proxy) both add the header
Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.
-> add the header name explicitly
Method PATCH is not allowed by Access-Control-Allow-Methods in preflight response.
-> add the method
Firefox words them differently ("Reason: CORS header 'Access-Control-Allow-Origin' missing", "Reason: Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*'", "Reason: Multiple CORS header 'Access-Control-Allow-Origin' not allowed"), but MDN's errors page lists every one and the mapping is the same.
Then open the Network tab, enable "Preserve log", and look for two rows: the OPTIONS preflight and the real request. If the preflight row is red, everything in steps 3 to 5 is about the preflight.
Step 2: Reproduce it with curl
Never debug CORS by refreshing the page. Send the same headers the browser sends and look at what comes back:
# Preflight: what the browser sends before PUT/PATCH/DELETE, JSON bodies or custom headers
curl -si -X OPTIONS https://api.example.com/v1/me \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: PUT' \
-H 'Access-Control-Request-Headers: authorization,content-type' \
| grep -iE '^(HTTP|access-control|vary|location)'
# Real request: check the CORS headers are also on the actual response
curl -si https://api.example.com/v1/me \
-H 'Origin: https://app.example.com' \
-H 'Authorization: Bearer test' \
| grep -iE '^(HTTP|access-control|vary)'
A healthy preflight from my local Express 5.3.0 test (the Step 4 config, sent with Origin: http://localhost:5173) looks like this:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Vary: Origin
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Max-Age: 7200
Access-Control-Expose-Headers: X-Request-Id
Check four things: a 2xx status, no Location header, an Access-Control-Allow-Origin that is exactly your page's origin (scheme, host and port), and every method and header you asked for. If the curl output is fine but the browser still fails, something between you and the server differs: a CDN cache, a different hostname, or a service worker.
Step 3: Fix preflight OPTIONS failures (401, 403, 404, 405, redirects)
The preflight carries no cookies and no Authorization header, per the Fetch Standard, and needs a status from 200 to 299. That produces three classic failures:
- 401 or 403: authentication middleware runs before CORS and rejects the credential-free
OPTIONS. Put CORS middleware first, or exemptOPTIONSfrom auth. - 404 or 405: the router has no
OPTIONSroute and no CORS middleware answers it. - 301, 302 or 308: an HTTP to HTTPS redirect, a trailing-slash redirect, or a login redirect. Call the final URL directly from the frontend.
Make the preflight short-circuit before anything else in the stack. The framework sections below all do that.
Step 4: Fix CORS in Express 5
Use the cors package with an explicit allowlist. Its README documents that origin: true reflects any request origin, so do not use that with credentials. app.use(cors(...)) handles preflights for every route, so you do not need a separate app.options line. If you do add one, note that Express 5 requires named wildcards: the migration guide says to use /*splat, or /{*splat} to also match the root, instead of *.
// server.js (express@5.3.0, cors@2.8.6)
const express = require('express');
const cors = require('cors');
const app = express();
// Exact origins only: scheme + host + port. Load from config per environment.
const ALLOWED = new Set(['https://app.example.com', 'http://localhost:5173']);
app.use(cors({
origin(origin, cb) {
// No Origin header (curl, server-to-server): no CORS headers needed.
if (!origin) return cb(null, false);
cb(null, ALLOWED.has(origin) ? origin : false);
},
credentials: true, // only if you use cookies
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['X-Request-Id'],
maxAge: 7200, // Chromium caps at 7200 s
}));
// Mount auth AFTER cors so OPTIONS never hits it.
// app.use(requireAuth);
app.get('/api/me', (req, res) => res.json({ ok: true }));
app.listen(3999);
The cors package sends Vary: Origin for you when it echoes a specific origin, which you can see in the Step 2 output. An origin not in the set gets a normal response with no CORS headers, which is correct: the browser blocks the read.
Step 5: Fix CORS in FastAPI
FastAPI uses Starlette's CORSMiddleware. The FastAPI CORS docs list the defaults: allow_methods=['GET'], allow_headers=[], allow_credentials=False, max_age=600. Those defaults explain most "FastAPI CORS not working" reports, because a PUT with JSON needs more than GET and the safelisted headers.
# main.py (fastapi==0.162.2, starlette==1.10.0)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com", "http://localhost:5173"],
# Optional: preview deploys. fullmatch() is used, but anchor anyway.
allow_origin_regex=r"^https://pr-\d+\.preview\.example\.com$",
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
expose_headers=["X-Request-Id"],
max_age=7200,
)
Do not "fix" it with allow_origins=["*"] plus allow_credentials=True. I tested that combination: Starlette's middleware responds to a request from https://evil.example carrying a cookie with access-control-allow-origin: https://evil.example and access-control-allow-credentials: true. The error disappears because every origin is now trusted with your users' sessions.
Step 6: Fix CORS in nginx
nginx has two traps. Per the headers module docs, add_header only applies to 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308 unless you add always, so your 401 and 500 responses lose their CORS headers and the browser reports a CORS error instead of the real one. And add_header directives are inherited only if the current level defines none, so one add_header inside a location silently drops the ones from server. nginx 1.29.3 added add_header_inherit merge to change that; on older versions, repeat the headers.
# Tested with nginx 1.27.5 (nginx -t ok, curl verified)
http {
# Allowlist: empty string means no header is sent.
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
"~^https://pr-[0-9]+\.preview\.example\.com$" $http_origin;
}
server {
listen 443 ssl;
server_name api.example.com;
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Access-Control-Max-Age 7200 always;
add_header Vary Origin always;
return 204;
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Vary Origin always;
proxy_pass http://app_upstream;
}
}
}
In my local test a preflight from https://pr-42.preview.example.com returned 204 with all six headers, and a request from https://evil.example returned no Access-Control-Allow-Origin at all (nginx skips headers whose value is empty). If the upstream app also sets CORS headers, remove them there, or you will get the "multiple values" error. The nginx reverse proxy guide covers the rest of the server block.
Step 7: Fix CORS in AWS API Gateway
HTTP APIs have built-in CORS. The HTTP API CORS docs say API Gateway then answers preflights itself and ignores CORS headers returned by your integration, so pick one place. If you have an authorizer on the $default route, add an unauthenticated OPTIONS /{proxy+} route, or preflights get rejected.
# Terraform, hashicorp/aws v6.72.0
resource "aws_apigatewayv2_api" "api" {
name = "orders-api"
protocol_type = "HTTP"
cors_configuration {
allow_origins = ["https://app.example.com"]
allow_methods = ["GET", "POST", "PUT", "DELETE"]
allow_headers = ["authorization", "content-type"]
expose_headers = ["x-request-id"]
allow_credentials = true
max_age = 7200
}
}
REST APIs are different. The REST API CORS docs say that with a Lambda proxy or HTTP proxy integration your backend is responsible for returning Access-Control-Allow-Origin and the other headers, because there is no integration response to map. Errors that API Gateway generates itself (authorizer rejections, throttling, missing routes) bypass your integration entirely; those are gateway responses, and they need their own headers:
resource "aws_api_gateway_gateway_response" "cors_4xx" {
rest_api_id = aws_api_gateway_rest_api.api.id
response_type = "DEFAULT_4XX"
response_parameters = {
"gatewayresponse.header.Access-Control-Allow-Origin" = "'https://app.example.com'"
"gatewayresponse.header.Vary" = "'Origin'"
}
}
Step 8: Fix CORS on CloudFront and S3
For an S3 bucket, the CORS rules live on the bucket. The S3 CORS docs allow up to 100 rules, AllowedMethods limited to GET, PUT, POST, DELETE and HEAD, and one * per origin string. For browser uploads with presigned URLs you usually need PUT and to expose ETag:
[
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET", "PUT", "HEAD"],
"AllowedHeaders": ["content-type", "x-amz-*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]
When CloudFront sits in front, the common failure is that CloudFront does not forward Origin, so S3 never adds CORS headers, or it caches one response for every origin. AWS's CORS caching guidance says to forward Origin, Access-Control-Request-Headers and Access-Control-Request-Method for S3 if you cache OPTIONS. The managed origin request policy CORS-S3Origin (ID 88a5eaf4-2fd4-4709-b370-b4c650ea3fcf) does that, per the managed origin request policies page. Alternatively, let CloudFront add the headers with a response headers policy; the managed response headers policies SimpleCORS and CORS-With-Preflight (ID 5cc3b908-e619-4b99-88e5-2cf7f45965bd) send Access-Control-Allow-Origin: *, so they suit public assets only, not credentialed APIs.
# Inside aws_cloudfront_distribution
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD", "OPTIONS"]
target_origin_id = "s3-assets"
viewer_protocol_policy = "redirect-to-https"
cache_policy_id = aws_cloudfront_cache_policy.assets.id # include Origin in the key
origin_request_policy_id = "88a5eaf4-2fd4-4709-b370-b4c650ea3fcf" # CORS-S3Origin
}
After changing either side, invalidate the cache, or you will keep testing yesterday's headers. The S3 and CloudFront static site guide shows the full distribution.
Step 9: Use a dev proxy instead of CORS on localhost
In development, the cleanest fix is often no CORS at all: serve the API through the dev server so the browser sees one origin. Vite's server options document server.proxy:
// vite.config.js (vite 8.3.7)
import { defineConfig } from 'vite';
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
});
The frontend then calls /api/me, not http://localhost:8000/api/me. Do not set Vite's server.cors to true to make errors go away; the docs warn that it lets any website access your dev server. And if a public HTTPS page calls a device or service on localhost or your LAN, the blocker in Chrome 142 and later may be the Local Network Access permission, not CORS; see the Local Network Access guidance.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| "No 'Access-Control-Allow-Origin' header" only on errors | Proxy adds headers to 2xx only, or the app crashed before middleware | nginx add_header ... always; CORS middleware first; API Gateway gateway responses |
| "It does not have HTTP ok status" | Auth or router rejects credential-free OPTIONS |
Exempt OPTIONS from auth; return 204 |
| "Redirect is not allowed for a preflight request" | HTTP to HTTPS, trailing slash or login redirect | Call the final URL exactly |
| "must not be the wildcard '*' when ... credentials mode is 'include'" | credentials: 'include' with Allow-Origin: * |
Echo the exact origin, add Allow-Credentials: true and Vary: Origin |
| "contains multiple values" | App and proxy both add the header | Configure CORS in one layer only |
| "Request header field authorization is not allowed" | Authorization missing from allow-headers (* never covers it) |
List Authorization explicitly |
| Works in curl, fails in browser; works for one origin, not another | CDN caching without Origin in the cache key |
Forward Origin, add Vary: Origin, invalidate |
| Cookies not sent even though CORS passes | Third-party cookie blocking or SameSite |
Put app and API on the same site (subdomains), or use bearer tokens; cross-site cookies need SameSite=None; Secure and may still be blocked |
Response header reads as null in JS |
Header not exposed | Add it to Access-Control-Expose-Headers |
Public site cannot reach localhost or a LAN device |
Chrome Local Network Access permission | Serve over HTTPS, request the permission, use targetAddressSpace |
FAQ
Why does Postman or curl work but the browser shows a CORS error?
CORS is enforced only by browsers. curl and Postman never check Access-Control-Allow-Origin, so the same request succeeds there. Reproduce the browser's behaviour by sending the Origin and Access-Control-Request-* headers yourself, as in Step 2.
Can I fix a CORS error from the frontend?
Not for a server you do not control. The fix is response headers from the server, or a proxy on your own origin (a dev proxy locally, a backend route or gateway in production). Setting mode: 'no-cors' in fetch() just gives you an opaque response you cannot read.
Is it safe to use Access-Control-Allow-Origin *?
Yes, for public, unauthenticated resources such as fonts, public JSON or open APIs. It is not safe as a shortcut for an authenticated API, and browsers reject it for credentialed requests anyway. Never replace it with "reflect whatever origin arrives", which is worse.
Why is my preflight sent on every request?
Without Access-Control-Max-Age the browser caches preflights for only 5 seconds, per MDN. Set it to 7200 (Chromium's cap); Firefox allows up to 86,400. Keeping requests simple (no custom headers, form content types) avoids preflights entirely, but that is rarely worth contorting an API for.
When a CORS ticket lands on me, I check two things before touching any config: the curl preflight from Step 2 against the production hostname, and whether more than one layer (app, ingress, CDN) is adding headers. Most incidents are one of those, and the fix is to move CORS to a single layer that sees every response, errors included, with an allowlist kept in deployment config. Then I add a CI check that sends a fake Origin and fails if it is echoed back. The official references are the Fetch Standard, MDN's CORS guide, the FastAPI CORS docs, the Express cors README and the API Gateway HTTP API CORS docs. For the reasoning behind each rule, see CORS explained.
Related: How to Fix 502 Bad Gateway and 504 Gateway Timeout Errors, How to Set Up Nginx as a Reverse Proxy With Let's Encrypt and How to Host a Static Website on S3 and CloudFront with HTTPS.
Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Undo Almost Anything in Git: Reset, Revert, Reflog (2026)
A step-by-step guide to undoing Git mistakes in 2026: git restore for files, amend, reset --soft, --mixed and --hard, reflog recovery, reverting pushed and merge commits, force-with-lease, aborting merges, and removing secrets with git filter-repo 2.47.
13 minOct 20, 2026How to Migrate From ingress-nginx to Gateway API: Step-by-Step (2026)
A tested, step-by-step ingress-nginx to Gateway API migration: inventory annotations, install Gateway API v1.6.3 and Envoy Gateway v1.9.2, convert with ingress2gateway v1.2.0, handle TLS, rewrites and canaries, read route status conditions, cut over with DNS and roll back.
13 minNov 10, 2026How to Set Up Actions Runner Controller (ARC) on Kubernetes (2026)
Step-by-step ARC 0.15.1 setup for GitHub Actions self-hosted runners on Kubernetes: GitHub App auth, pinned Helm charts, runner scale sets, runner groups, Docker-in-Docker vs kubernetes container mode, scale to zero, metrics, and fixes for jobs stuck in queued.
13 min