How to allow CORS on any server
To allow CORS you add one response header, Access-Control-Allow-Origin, and answer OPTIONS requests before they reach your app. That is the whole job. What changes between servers is where the config lives and which gotcha bites. Below is the same minimal setup written out for Nginx, Apache, Go, Spring Boot, Cloudflare Workers, Netlify and Vercel, plus a curl command that proves it worked.
The four headers you are setting
Every config below sends the same thing. Learn these once and the rest is syntax:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Vary: Origin
The first header is what stops the CORS error. The next two are only read during a preflight, which the browser sends as an OPTIONS request before any non-trivial call. Vary: Origin tells caches that the response differs per origin, so a CDN does not serve one site's allow-header to another.
Replace https://app.example.com with the origin of the page making the request: scheme, host and port, no path, no trailing slash. If you want to allow everyone and never send cookies, * works for the first header. If you need cookies or an Authorization header with credentials: "include", * is rejected by the browser and you must echo the exact origin. CORS with cookies and credentials covers that case.
Nginx
location /api/ {
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Vary "Origin" always;
if ($request_method = OPTIONS) {
return 204;
}
proxy_pass http://127.0.0.1:3000;
}
Two things matter here. always makes Nginx attach the headers to 4xx and 5xx responses too; without it a 401 from your API arrives with no CORS headers and the browser reports a CORS error instead of the real status. And do not put a second add_header inside the if block: Nginx replaces inherited headers with the ones declared in the inner block, so an inner add_header silently drops the others.
Apache
This works in the virtual host or in .htaccess, with mod_headers and mod_rewrite enabled:
Header always set Access-Control-Allow-Origin "https://app.example.com"
Header always set Access-Control-Allow-Methods "GET, POST, OPTIONS"
Header always set Access-Control-Allow-Headers "Content-Type, Authorization"
Header always set Vary "Origin"
RewriteEngine On
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^ - [R=204,L]
The rewrite rule short-circuits preflights with an empty 204 so they never hit PHP or your backend. Header always plays the same role as Nginx's always.
Go (net/http)
Wrap your mux in a middleware. No third-party package needed:
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Origin") == "https://app.example.com" {
h := w.Header()
h.Set("Access-Control-Allow-Origin", "https://app.example.com")
h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
h.Add("Vary", "Origin")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// http.ListenAndServe(":3000", cors(mux))
To allow several origins, swap the string comparison for a map lookup and echo r.Header.Get("Origin") back when it matches.
Spring Boot
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET", "POST", "OPTIONS")
.allowedHeaders("Content-Type", "Authorization");
}
}
Spring handles the preflight response for you. If Spring Security is on the classpath, add .cors(Customizer.withDefaults()) to your SecurityFilterChain, otherwise the security filter rejects the OPTIONS request before the mapping above ever runs.
Cloudflare Workers
const ALLOWED = "https://app.example.com";
export default {
async fetch(request, env) {
const origin = request.headers.get("Origin");
const cors = origin === ALLOWED
? {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Vary": "Origin",
}
: {};
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: cors });
}
const upstream = await handle(request, env); // your real handler
const res = new Response(upstream.body, upstream);
for (const [k, v] of Object.entries(cors)) res.headers.set(k, v);
return res;
},
};
The new Response(upstream.body, upstream) copy matters: a Response returned by fetch() has immutable headers, so setting on it directly throws.
Netlify and Vercel (static headers)
Static hosts let you declare headers in a file. Netlify reads _headers from the publish directory:
/api/*
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Vercel reads vercel.json at the project root:
{
"headers": [
{
"source": "/api/(.*)",
"headers": [
{ "key": "Access-Control-Allow-Origin", "value": "https://app.example.com" },
{ "key": "Access-Control-Allow-Methods", "value": "GET, POST, OPTIONS" },
{ "key": "Access-Control-Allow-Headers", "value": "Content-Type, Authorization" }
]
}
]
}
These attach headers to whatever the path serves, including the response to an OPTIONS request. For serverless functions on either platform your function code still has to return early on OPTIONS; the static config only decorates the response it gets. CORS in Next.js shows the Route Handler version.
Express and Django
Both have a standard middleware and their own write-ups here: how to enable CORS in Node.js and Express and CORS in Django with django-cors-headers. The headers are identical; the posts cover each framework's ordering gotcha.
Check it with curl
Do not test in the browser first. Send the preflight yourself and read the headers back:
curl -si -X OPTIONS https://api.example.com/users \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type" | grep -i "^access-control\|^HTTP"
You want a 204 (or 200) and all three Access-Control-* lines. Then repeat with -X GET and no Access-Control-Request-* headers to confirm the real response carries Access-Control-Allow-Origin too. If the preflight passes and the GET does not, you have the Nginx always problem or a framework that only decorates successful responses.
Three mistakes that keep the error alive
Headers missing on error responses. Your API returns 500, the error path skips the CORS middleware, and the console says "blocked by CORS policy" instead of showing the 500. Every config above sets headers unconditionally for this reason.
Origin with a path or trailing slash. https://app.example.com/ does not match https://app.example.com. The browser compares the string byte for byte.
Preflight never reaches the config. An auth layer, a WAF rule or a route that only matches GET answers the OPTIONS with 401 or 404 and the browser stops there. The curl check above catches this immediately.
When you do not control the server
Everything above assumes the API is yours. If it belongs to someone else and sends no CORS headers, no setting on your side will make the browser accept the response. The options are a backend route that calls the API server-side, or a CORS proxy that adds the headers for you. For development, 5 ways to fix CORS in development lists the dev-server proxies. For a hosted proxy with an origin allowlist, the corsproxy.dev API is one request:
const res = await fetch(
"https://corsproxy.dev/proxy?url=https://api.example.com/users",
{ headers: { "X-API-Key": "YOUR_KEY" } }
);
Read when not to use a CORS proxy before putting one in production. Secrets never belong in browser code, proxied or not.