Why You’re Seeing “Blocked by CORS policy” and What It Really Means
If you’ve ever tried to call an API from a browser and got slapped with “Access to fetch at … has been blocked by CORS policy,” you’ve run into the browser’s cross-origin protections. CORS (Cross-Origin Resource Sharing) is a security layer that prevents a page on one origin (scheme + host + port) from freely reading resources from another origin unless the server explicitly says it’s allowed.
The single most important header behind CORS is Access-Control-Allow-Origin. Get it right, and your API calls work seamlessly. Get it wrong, and the browser refuses to deliver the response to your JavaScript—even if the server responded with 200 OK.
This guide gives you a step-by-step workflow to diagnose and fix CORS errors, then shows how to configure correct CORS headers across popular stacks, with secure patterns you can apply in production.
CORS in 3 Minutes: The Essentials
- The browser enforces CORS. Servers simply respond with headers. Your server has to opt-in to being accessed from other origins.
- The client sends an Origin header with cross-origin requests: Origin: https://app.example.com.
- The server must respond with:
- Access-Control-Allow-Origin: the allowed origin (or *).
- Optionally Access-Control-Allow-Credentials: true when you need cookies or Authorization alongside credentials: 'include'.
- On preflight (OPTIONS) requests, you may also need:
- Access-Control-Allow-Methods: GET, POST, PUT, DELETE …
- Access-Control-Allow-Headers: X-Custom-Header, Authorization …
- Access-Control-Max-Age: 600 (to cache preflight results)
- To let your JS read custom response headers, add:
- Access-Control-Expose-Headers: X-Request-Id, Link
What triggers a preflight? Non-simple requests—those not limited to GET/HEAD/POST with simple headers and simple content types (application/x-www-form-urlencoded, multipart/form-data, text/plain)—require a preflight OPTIONS request. Custom headers like Authorization or Content-Type: application/json will also trigger preflight.
Important constraints:
- If you send credentials (cookies or Authorization) from the browser, you cannot use Access-Control-Allow-Origin: *. You must echo the actual origin.
- Caches and CDNs need Vary: Origin whenever you return different CORS headers per origin.
The Fastest Diagnosis Workflow
Follow this sequence to spot and fix CORS issues systematically.
1) Read the exact browser error and inspect the Network panel
Typical Chrome error: “Access to fetch at ‘https://api.example.com/data’ from origin ‘https://app.example.com’ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.”
Action:
- Open DevTools > Network.
- Click the failing request.
- Under Headers:
- Note the Request URL, Method, Origin, and any Access-Control-Request-Headers on preflight.
- Check the Response Headers. Is Access-Control-Allow-Origin present and correct? Are other headers (Allow-Methods/Allow-Headers) present for preflight?
2) Verify the server responds and can set headers
Sometimes you’re debugging the wrong thing: the server might be erroring out or redirecting.
Use curl to simulate a browser’s cross-origin request:
- Simple request:
curl -i https://api.example.com/data \
-H "Origin: https://app.example.com"
- Preflight:
curl -i -X OPTIONS https://api.example.com/data \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Authorization, Content-Type"
Check that:
- You get a 200/204 response (or at least not a 500).
- Response includes Access-Control-Allow-Origin and matches your Origin.
- For preflight, response includes Access-Control-Allow-Methods with POST, Access-Control-Allow-Headers with Authorization and Content-Type, and possibly Access-Control-Max-Age.
3) Identify if it’s simple or preflighted
- If you use fetch with Content-Type: application/json or send Authorization, you’ll get a preflight.
- If it’s a GET with no custom headers, it might be a simple request.
Knowing this determines which headers your server must provide and whether you need to handle OPTIONS.
4) Fix headers precisely
- On preflight: include ACAO (Access-Control-Allow-Origin), ACAM (…-Methods), and ACAH (…-Headers).
- On the actual response (GET/POST/etc.): you must again include ACAO (and ACAC if credentials needed). Many teams forget to set headers on the actual response, only setting them on OPTIONS.
5) Watch for cache and redirect traps
- If your API or CDN caches responses, return Vary: Origin.
- CORS headers can be lost on redirects (301/302). Ensure the final destination sends CORS headers or avoid cross-origin redirects.
Correct Header Recipes
Here are minimal responses for different scenarios.
- Public, no credentials, simple request:
Access-Control-Allow-Origin: *
- Specific site, no credentials:
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
- Specific site with credentials (cookies or Authorization with credentials: 'include'):
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
- Preflight for POST with custom headers:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-Id
Access-Control-Max-Age: 600
Vary: Origin, Access-Control-Request-Headers, Access-Control-Request-Method
Note: For Access-Control-Allow-Headers, modern browsers support * for non-credentialed requests. For broad compatibility and clarity, explicitly list needed headers.
Configuring CORS Across Popular Stacks
Node.js with Express: the fast, safe way
Use the cors middleware; it handles most edge cases.
- Basic, allow one origin:
import express from 'express';
import cors from 'cors';
const app = express();
app.use(cors({
origin: 'https://app.example.com'
}));
app.get('/data', (req, res) => {
res.json({ ok: true });
});
app.listen(3000);
- Multiple origins (whitelist) and credentials:
const whitelist = ['https://app.example.com', 'https://admin.example.com'];
app.use(cors({
origin(origin, callback) {
// allow requests with no origin (like curl or mobile apps)
if (!origin) return callback(null, true);
if (whitelist.includes(origin)) return callback(null, true);
return callback(new Error('Not allowed by CORS'));
},
credentials: true
}));
// Also handle preflight success status to avoid issues with legacy clients:
app.options('*', cors());
- Manual control without middleware:
import express from 'express';
const app = express();
const whitelist = new Set(['https://app.example.com']);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && whitelist.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
res.setHeader('Access-Control-Allow-Credentials', 'true'); // if needed
}
// Preflight handling
if (req.method === 'OPTIONS') {
const reqMethod = req.headers['access-control-request-method'];
const reqHeaders = req.headers['access-control-request-headers'];
if (reqMethod) {
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
}
if (reqHeaders) {
res.setHeader('Access-Control-Allow-Headers', reqHeaders);
}
res.setHeader('Access-Control-Max-Age', '600');
return res.sendStatus(204);
}
next();
});
app.get('/data', (_, res) => res.json({ ok: true }));
app.listen(3000);
Actionable tip: If you return different origins per request, always send Vary: Origin so CDNs don’t mix responses.
Nginx: add headers and handle OPTIONS
For a single allowed origin and simple setup:
server {
listen 443 ssl;
server_name api.example.com;
location / {
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
add_header 'Vary' 'Origin' always;
proxy_pass http://upstream_api;
}
# Preflight
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
add_header 'Access-Control-Allow-Methods' 'GET,POST,PUT,PATCH,DELETE,OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization,Content-Type,X-Request-Id' always;
add_header 'Access-Control-Max-Age' '600' always;
add_header 'Vary' 'Origin, Access-Control-Request-Headers, Access-Control-Request-Method' always;
return 204;
}
}
Dynamic origin with whitelist:
map $http_origin $cors_origin {
default "";
"~^https?://(app|admin)\.example\.com$" $http_origin;
}
server {
location / {
if ($cors_origin != "") {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Vary' 'Origin' always;
add_header 'Access-Control-Allow-Credentials' 'true' always; # if needed
}
proxy_pass http://upstream_api;
}
if ($request_method = OPTIONS) {
if ($cors_origin != "") {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Credentials' 'true' always; # if needed
}
add_header 'Access-Control-Allow-Methods' 'GET,POST,PUT,PATCH,DELETE,OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization,Content-Type,X-Request-Id' always;
add_header 'Access-Control-Max-Age' '600' always;
add_header 'Vary' 'Origin, Access-Control-Request-Headers, Access-Control-Request-Method' always;
return 204;
}
}
Gotcha: Nginx’s add_header applies only to successful responses unless you use always. Include always so headers are present on errors and OPTIONS, too.
Apache (httpd) with mod_headers
.htaccess approach:
<IfModule mod_headers.c>
SetEnvIf Origin "https?://(app|admin)\.example\.com$" ORIGIN_OK=$0
Header always set Access-Control-Allow-Origin "%{ORIGIN_OK}e" env=ORIGIN_OK
Header always set Vary "Origin"
# If you need credentials:
Header always set Access-Control-Allow-Credentials "true" env=ORIGIN_OK
# Handle preflight
<If "%{REQUEST_METHOD} == 'OPTIONS'">
Header always set Access-Control-Allow-Methods "GET,POST,PUT,PATCH,DELETE,OPTIONS"
Header always set Access-Control-Allow-Headers "Authorization,Content-Type,X-Request-Id"
Header always set Access-Control-Max-Age "600"
Require all granted
</If>
</IfModule>
Make sure mod_headers and mod_rewrite are enabled, and that OPTIONS requests are not blocked by auth.
Spring Boot (Java)
- Quick annotation:
@RestController
@CrossOrigin(origins = "https://app.example.com", allowCredentials = "true")
public class ApiController {
@GetMapping("/data")
public Map<String, Object> data() { return Map.of("ok", true); }
}
- Global configuration:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("https://app.example.com", "https://admin.example.com")
.allowedMethods("GET","POST","PUT","PATCH","DELETE","OPTIONS")
.allowedHeaders("Authorization","Content-Type","X-Request-Id")
.allowCredentials(true)
.maxAge(600);
}
}
For Spring WebFlux, use a CorsConfigurationSource or WebFilter with the same properties.
ASP.NET Core
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options =>
{
options.AddPolicy("AppPolicy", policy =>
{
policy.WithOrigins("https://app.example.com", "https://admin.example.com")
.WithMethods("GET","POST","PUT","PATCH","DELETE","OPTIONS")
.WithHeaders("Authorization","Content-Type","X-Request-Id")
.AllowCredentials(); // remove for public APIs
});
});
var app = builder.Build();
app.UseCors("AppPolicy");
app.MapGet("/data", () => Results.Json(new { ok = true }));
app.Run();
If you need to expose custom response headers to the browser:
policy.WithExposedHeaders("X-Request-Id", "Link");
AWS S3 and CloudFront
- S3 bucket CORS (console or XML):
<CORSConfiguration>
<CORSRule>
<AllowedOrigin>https://app.example.com</AllowedOrigin>
<AllowedMethod>GET</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
<ExposeHeader>ETag</ExposeHeader>
<MaxAgeSeconds>600</MaxAgeSeconds>
</CORSRule>
</CORSConfiguration>
- CloudFront:
- Forward the Origin header to the origin (or set “Cache based on Selected Headers” to Whitelist: Origin).
- Add a Cache Policy or Behavior that includes Origin in the cache key.
- Optionally add a Response Header Policy to inject CORS headers.
- Ensure Vary: Origin is preserved or added.
API Gateway/Lambda
- API Gateway (HTTP/REST API): turn on CORS in the console; it auto-creates OPTIONS and returns headers.
- Lambda handlers must include headers on both preflight and actual responses:
export const handler = async (event) => {
const origin = event.headers.origin;
const allowed = ['https://app.example.com'];
const headers = {};
if (allowed.includes(origin)) {
headers['Access-Control-Allow-Origin'] = origin;
headers['Vary'] = 'Origin';
headers['Access-Control-Allow-Credentials'] = 'true';
}
if (event.requestContext.http.method === 'OPTIONS') {
headers['Access-Control-Allow-Methods'] = 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
headers['Access-Control-Allow-Headers'] = 'Authorization,Content-Type,X-Request-Id';
headers['Access-Control-Max-Age'] = '600';
return { statusCode: 204, headers };
}
return { statusCode: 200, headers, body: JSON.stringify({ ok: true }) };
};
Credentials, Cookies, and Security
- When you use cookies or credentials: 'include' (fetch) or withCredentials: true (Axios), the server must:
- Set Access-Control-Allow-Origin to the exact origin (no *).
- Send Access-Control-Allow-Credentials: true.
- Include Vary: Origin if dynamic.
- Cookies for cross-site requests must be set with SameSite=None; Secure, or browsers won’t send them.
- For bearer tokens via Authorization header, you don’t need Allow-Credentials unless using credentials mode. But if you call fetch with credentials: 'include', the restriction applies.
- Don’t blindly reflect any Origin header. Use a whitelist to prevent malicious sites from reading your API responses with a logged-in user’s session.
Example: fetch with credentials and cookies:
fetch('https://api.example.com/user', {
method: 'GET',
credentials: 'include'
}).then(r => r.json());
Axios:
axios.get('https://api.example.com/user', { withCredentials: true });
Server must respond with:
- Access-Control-Allow-Origin: https://app.example.com
- Access-Control-Allow-Credentials: true
- Vary: Origin
Preflight Performance and Stability
- Browsers cache preflight results, so set Access-Control-Max-Age to a sensible value (e.g., 600 seconds). Safari applies stricter caps (commonly 600s or less), and some enterprise environments disable long caching.
- Don’t over-optimize away preflights by removing needed headers; keep API design clean and predictable.
- Include Vary: Access-Control-Request-Headers and Vary: Access-Control-Request-Method on preflight responses for correct caching behavior on CDNs.
Common Pitfalls and How to Avoid Them
- Only setting CORS on OPTIONS: You must also set ACAO (and ACAC if needed) on the actual response (GET/POST/etc.).
- Missing headers on error responses: Add headers on all status codes, not just 200. In Nginx, use always; in app code, set headers in a global middleware and error handlers.
- Redirects drop headers: Avoid cross-origin redirects or ensure the final endpoint sets CORS headers too.
- Not including Vary: Origin when dynamically allowing multiple origins. This causes cache poisoning/mismatched responses via CDNs.
- Access-Control-Allow-Origin set to your own API domain: It must match the calling site’s origin (e.g., https://app.example.com), not your API’s own domain.
- Access-Control-Allow-Headers missing required items: If the browser sends Access-Control-Request-Headers: Authorization, Content-Type, your preflight must include both in Access-Control-Allow-Headers.
- OPTIONS blocked by auth or firewall: Ensure your server and WAF allow OPTIONS and don’t require auth for preflight.
- Misunderstanding dev proxies: Local tooling (Vite, webpack devServer, CRA) can proxy /api to avoid CORS in dev. If the proxy is off, CORS errors appear. Verify if your dev setup uses a proxy and configure the target properly.
Testing CORS Properly
- Simulate requests with curl:
curl -i https://api.example.com/data -H "Origin: https://app.example.com"
- Test preflight:
curl -i -X OPTIONS https://api.example.com/data \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: Authorization, Content-Type"
-
Verify actual response includes Access-Control-Allow-Origin, not just the preflight.
-
Check browser DevTools:
- Network > filter by the endpoint.
- Look for an OPTIONS call before your POST/PUT.
- Confirm the response headers are present on both requests.
-
CDN behavior:
- Inspect response headers via curl with different Origin values.
- Confirm Vary: Origin and that the Response differs per origin as expected.
Practical Patterns for Real Projects
Pattern 1: Public read-only API with no credentials
- Use Access-Control-Allow-Origin: *.
- Do not allow credentials.
- Preflight can be simplified; allow common methods but keep the surface minimal.
Pattern 2: Private API for your SPA with cookies
- Lock down to a whitelist of your app origins.
- Use Access-Control-Allow-Credentials: true, with specific Access-Control-Allow-Origin value.
- Set cookies with SameSite=None; Secure.
- Enforce TLS everywhere.
- Add Vary: Origin and audit CDN caching.
Pattern 3: Multi-tenant dashboard across subdomains
- Allow dynamic set of subdomains via a regex/whitelist.
- Echo the incoming Origin only if it matches.
- Add Vary: Origin.
- Consider issuing tokens instead of cookies to keep CORS simpler and avoid SameSite issues.
Pattern 4: Reverse proxy in front of multiple services
- Terminate CORS at the edge (Nginx/CloudFront) to centralize policy.
- Pass through to upstreams without them worrying about CORS.
- Ensure OPTIONS passthrough or handle OPTIONS at the proxy for speed.
Client-Side Examples and Gotchas
- fetch with custom headers (triggers preflight):
await fetch('https://api.example.com/items', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Request-Id': 'abc-123'
},
body: JSON.stringify({ name: 'Widget' })
});
Ensure the server’s preflight includes:
-
Access-Control-Allow-Methods: POST
-
Access-Control-Allow-Headers: Content-Type, X-Request-Id
-
Access-Control-Allow-Origin (and -Credentials if applicable)
-
Reading custom response headers:
const res = await fetch('https://api.example.com/export');
console.log(res.headers.get('X-Request-Id')); // null unless exposed
Server must set:
Access-Control-Expose-Headers: X-Request-Id
- Axios defaults and credentials:
const api = axios.create({
baseURL: 'https://api.example.com',
withCredentials: true // requires server to set ACAO to a specific origin and ACAC: true
});
Security Checklist for CORS
- Never use Access-Control-Allow-Origin: * with credentials.
- Prefer a whitelist. Log and deny unknown origins.
- Add Vary: Origin whenever ACAO is dynamic.
- Validate Access-Control-Request-Headers if you reflect them; better, provide an explicit allow-list.
- Return CORS headers on errors and OPTIONS.
- Avoid exposing sensitive response headers; if you must, explicitly use Access-Control-Expose-Headers.
- Keep your preflight allow list tight—don’t allow methods/headers you don’t need.
Step-by-Step Fix Example
Scenario: Your React app at https://app.example.com makes POST https://api.example.com/login with Content-Type: application/json and receives:
- Browser error: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.
- Network tab shows an OPTIONS preflight returning 200 but no CORS headers.
Fix plan:
- Handle OPTIONS on the API and include:
- Access-Control-Allow-Origin: https://app.example.com
- Access-Control-Allow-Methods: POST
- Access-Control-Allow-Headers: Content-Type
- Access-Control-Max-Age: 600
- Vary: Origin, Access-Control-Request-Headers, Access-Control-Request-Method
- On the POST /login response, also include:
- Access-Control-Allow-Origin: https://app.example.com
- If using cookies for session: Access-Control-Allow-Credentials: true and cookie set with SameSite=None; Secure
- If you deploy behind a CDN, add Vary: Origin. Ensure the CDN forwards Origin to the origin server.
- Retest using curl for both OPTIONS and POST, then verify in DevTools.
When CORS Isn’t the Real Problem
Sometimes the browser’s message is misleading because the underlying request failed before CORS headers were set:
- The server crashed or returned 500. Fix the error and ensure your error handler attaches CORS headers.
- TLS/cert mismatch on the API causes the request to fail. Resolve certificate issues.
- Network or WAF blocked OPTIONS. Allow it at the edge.
- Cross-origin redirect to a third domain that doesn’t send CORS headers. Eliminate the redirect or configure that endpoint.
Final Takeaways
- CORS is a contract: the browser brings Origin; your server answers with explicit permissions.
- Access-Control-Allow-Origin is necessary on both preflight and actual responses. With credentials, never use *.
- Always consider caching: add Vary: Origin, and include Access-Control-Max-Age to tame preflights.
- Centralize and standardize CORS wherever possible—through middleware or an edge proxy—with a clear whitelist.
- Test with curl and DevTools to validate both preflight and actual responses.
Do this, and CORS errors will go from cryptic blockers to a solved, predictable part of your API integration.