Access-Control-Allow-Origin is the response header that tells a browser which other website may read the response. It's the header nearly every CORS error is about. Here are the values it accepts, the two rules that trip everyone up, and working setups for the common servers.
| Value | Meaning |
|---|---|
https://app.example.com | Only this exact origin: scheme, host and port. No path, no trailing slash. |
* | Any origin, but only for requests without cookies or HTTP auth. |
null | Matches sandboxed iframes and file:// pages. Don't use it: anyone can create a null origin. |
Not accepted: lists (https://a.com, https://b.com), wildcards inside a name (https://*.example.com), or a value with a path. Browsers reject all of them.
Since the header holds one origin, the server picks it per request: read the request's Origin header, check it against an allow-list, and echo it back when it matches.
const ALLOWED = new Set(["https://app.example.com", "https://admin.example.com", "http://localhost:3000"]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (ALLOWED.has(origin)) res.set("Access-Control-Allow-Origin", origin);
res.append("Vary", "Origin"); // caches must keep one copy per origin
next();
});
Two things matter here. Compare exact strings: checks like origin.endsWith("example.com") also match evilexample.com. And always send Vary: Origin, or a CDN may serve one origin's answer to another and you'll see "not equal to the supplied origin" at random.
If the browser sends cookies or HTTP auth (fetch(url, { credentials: "include" }), or withCredentials: true in axios), the response needs the exact origin and a second header:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
* is refused here on purpose, so that no site can read another site's logged-in pages with your visitors' cookies. Never "fix" this by echoing back every origin you receive; that does exactly what the rule prevents.
import cors from "cors";
app.use(cors({ origin: ["https://app.example.com", "http://localhost:3000"], credentials: true }));
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
"http://localhost:3000" $http_origin;
}
server {
location /api/ {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Vary Origin always;
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Vary Origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
return 204;
}
}
}
always makes nginx add the header to error responses too, so a 500 doesn't show up as a CORS error. And note the repetition inside the if block: nginx inherits add_header lines from the outer level only when the inner level has none, so every header the preflight needs, Vary included, must be listed again there.
SetEnvIf Origin "^(https://(app|admin)\.example\.com)$" CORS_OK=$1
Header always set Access-Control-Allow-Origin "%{CORS_OK}e" env=CORS_OK
Header always merge Vary Origin
from flask_cors import CORS
CORS(app, origins=["https://app.example.com"], supports_credentials=True)
# pip install django-cors-headers; add "corsheaders" to INSTALLED_APPS and
# "corsheaders.middleware.CorsMiddleware" near the top of MIDDLEWARE
CORS_ALLOWED_ORIGINS = ["https://app.example.com"]
CORS_ALLOW_CREDENTIALS = True
@Configuration
public class Cors implements WebMvcConfigurer {
public void addCorsMappings(CorsRegistry r) {
r.addMapping("/api/**").allowedOrigins("https://app.example.com").allowCredentials(true);
}
}
builder.Services.AddCors(o => o.AddPolicy("app", p =>
p.WithOrigins("https://app.example.com").AllowAnyHeader().AllowAnyMethod().AllowCredentials()));
app.UseCors("app"); // before UseAuthorization
For files on Amazon S3 or Google Cloud Storage, CORS is a bucket setting (a JSON rule list), not a header you add in code.
What your code sets and what reaches the browser can differ: proxies strip headers, and error pages skip middleware. Check the real response:
curl -si -H "Origin: https://app.example.com" https://api.example.com/users | grep -i "access-control\|vary"
Or paste the URL into the CORS tester, which sends a real cross-origin request from your browser and says which header is missing. Preflight requests have their own rules: preflight requests explained.
Waiting on someone else's server? Add Access-Control-Allow-Origin and the other CORS headers to one site's responses in your own browser, so you can keep building while the real fix is made. It says so plainly when a failed preflight can't be fixed that way. No account, no tracking.
More on this topic: What is CORS? Cross-Origin Resource Sharing, in plain words · How to disable CORS in Chrome (and the safer way to test) · How to disable CORS in Firefox (and why content.cors.disable makes it worse) · TypeError: Failed to fetch — what it actually means, and how to tell which cause