API Security: A Practical CORS Configuration Guide
A practical, secure CORS configuration guide for APIs: concepts, pitfalls, and ready-to-use snippets for Express, NGINX, Spring, ASP.NET Core, and AWS.
Image used for representation purposes only.
Why CORS matters for API security
Cross-Origin Resource Sharing (CORS) is a browser-enforced protocol that governs which web origins can read responses from your API. It exists to relax the Same-Origin Policy safely, not to authenticate users or to protect your backend from direct access by non-browsers. Misconfiguring CORS can expose sensitive data to hostile websites running in your users’ browsers. Configuring it well ensures only the web apps you intend can programmatically read your API responses.
Key mindset shifts:
- CORS is enforced by browsers, not by servers. Attackers can still call your API directly (curl, Postman, scripts). Use authentication and authorization regardless of CORS.
- CORS is about response sharing. Your API can receive a cross-origin request, but without correct CORS headers the browser will block the calling page from reading the response.
- CORS configuration must align with your trust model: which sites (origins) should be able to read your API? Under what methods/headers? With credentials?
How CORS works (in 90 seconds)
- Origin: scheme + host + port (e.g., https://app.example.com:443 ).
- Simple request: GET/HEAD/POST with only simple headers (Accept, Accept-Language, Content-Language, Content-Type with value application/x-www-form-urlencoded, multipart/form-data, or text/plain). The browser sends the request; if the response carries a valid
Access-Control-Allow-Origin(ACAO) value, the browser exposes it to the page. - Preflighted request: For non-simple methods (PUT, PATCH, DELETE), non-simple headers (Authorization, X-*, etc.), or JSON POSTs, the browser sends an
OPTIONSpreflight first withOrigin,Access-Control-Request-Method, andAccess-Control-Request-Headers. The server must respond withAccess-Control-Allow-Origin,Access-Control-Allow-Methods, and optionallyAccess-Control-Allow-HeadersandAccess-Control-Max-Age. - Credentials: If the frontend includes cookies or client certificates (
fetch(..., { credentials: "include" })), the response must setAccess-Control-Allow-Credentials: trueand must not use*for ACAO.
Decide your CORS policy before you configure
Answer these questions up front:
- Which exact origins should be allowed? Prefer an allowlist of exact, HTTPS origins, e.g., https://app.example.com and https://staging.example.com .
- Will the browser send cookies? If yes, you must use
Access-Control-Allow-Credentials: trueand set cross-site cookies withSameSite=None; Secure. - Which HTTP methods and custom headers are truly required? Only allow those.
- How long can preflight results be cached safely? Set
Access-Control-Max-Ageaccordingly. - How will you handle local development? Consider a
.env-driven allowlist or a dev-only proxy.
Security principles:
- Deny by default. Start with no CORS, then open narrowly.
- Never reflect the request’s
Originblindly without checking it’s on your allowlist. - Avoid
*with credentials or broadAccess-Control-Allow-Methods: *andAccess-Control-Allow-Headers: *. - Set
Vary: Originwhen returning per-origin ACAO so caches don’t mix responses between callers.
Safe header set (reference)
Minimum for credentialed cross-site reads from a single allowed origin:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Typical preflight response:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 3600
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
Note: If you do not use credentials, you may use Access-Control-Allow-Origin: *, but only when you truly intend the response to be readable by any site.
Configuration recipes
Node.js (Express) with strict allowlist
Install the cors middleware or implement your own check to avoid reflection bugs.
// npm i cors
const express = require('express');
const cors = require('cors');
const app = express();
const allowlist = new Set([
'https://app.example.com',
'https://staging.example.com'
]);
const corsOptionsDelegate = (req, cb) => {
const origin = req.header('Origin');
if (origin && allowlist.has(origin)) {
cb(null, {
origin: origin, // echo only vetted origins
credentials: true, // allow cookies if you use them
methods: ['GET','POST','PUT','DELETE'],
allowedHeaders: ['Authorization','Content-Type'],
maxAge: 3600
});
} else {
cb(null, { origin: false }); // no CORS headers
}
};
app.use(cors(corsOptionsDelegate));
app.use((req, res, next) => {
// Ensure caches separate per origin
if (req.header('Origin')) res.set('Vary', 'Origin');
next();
});
NGINX as a reverse proxy
Use a map to validate origins, then set headers only when matched.
map $http_origin $cors_origin {
default '';
~^https://app\.example\.com$ $http_origin;
~^https://staging\.example\.com$ $http_origin;
}
server {
listen 443 ssl;
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,DELETE" always;
add_header Access-Control-Allow-Headers "Authorization,Content-Type" always;
add_header Access-Control-Max-Age 3600 always;
add_header Vary "Origin, Access-Control-Request-Method, Access-Control-Request-Headers" always;
return 204;
}
proxy_pass http://backend;
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Vary Origin always;
}
}
Spring Boot (Java)
// @Bean configuration
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"https://app.example.com",
"https://staging.example.com"
));
config.setAllowedMethods(List.of("GET","POST","PUT","DELETE"));
config.setAllowedHeaders(List.of("Authorization","Content-Type"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
ASP.NET Core
// Program.cs
var builder = WebApplication.CreateBuilder(args);
var allowedOrigins = new [] {
"https://app.example.com",
"https://staging.example.com"
};
builder.Services.AddCors(options =>
{
options.AddPolicy("StrictCors", policy => policy
.WithOrigins(allowedOrigins)
.WithMethods("GET","POST","PUT","DELETE")
.WithHeaders("Authorization","Content-Type")
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromHours(1))
);
});
var app = builder.Build();
app.UseCors("StrictCors");
FastAPI (Python)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
origins = [
"https://app.example.com",
"https://staging.example.com",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["GET","POST","PUT","DELETE"],
allow_headers=["Authorization","Content-Type"],
max_age=3600
)
AWS API Gateway (HTTP APIs)
In the console or IaC, configure CORS:
- Allow origins: [https://app.example.com, https://staging.example.com]
- Allow credentials: true
- Allow methods: GET, POST, PUT, DELETE
- Allow headers: Authorization, Content-Type
- Max age: 3600
Remember that if you enable credentials, * is invalid for allowed origins.
Credentials, cookies, and tokens
- Cookies: Cross-site cookies must be set with
SameSite=None; Secure. The frontend must callfetch(url, { credentials: 'include' })to send them. Your API must setAccess-Control-Allow-Credentials: trueand a non-*ACAO. - Bearer tokens: If you use
Authorization: Bearer ..., it triggers a preflight. That is fine; ensureAccess-Control-Allow-HeadersincludesAuthorization. - Avoid mixing session cookies and permissive ACAO. If any untrusted origin can read your responses, your users’ sessions can be abused via a malicious site.
Caching and the Vary header
When you return per-origin Access-Control-Allow-Origin, set Vary: Origin. For preflights, add Vary: Access-Control-Request-Method, Access-Control-Request-Headers. This prevents shared caches from serving a response with the wrong CORS policy to another origin. If responses are sensitive, prefer Cache-Control: private, no-store.
Private Network Access considerations
Some browsers perform an extra preflight when a public site calls a resource on a private network (e.g., http://192.168.1.10). If you intentionally support this scenario, you may need to answer with Access-Control-Allow-Private-Network: true on the preflight. Treat this as a deliberate, narrowly scoped decision and test across target browsers.
Testing and debugging CORS
- Browser DevTools: Look at the Network tab. For a failed CORS, the response might be 200 OK but the console shows a CORS error because the browser blocked access.
- curl can’t reproduce browser CORS errors; it doesn’t enforce CORS. It’s still useful to inspect headers:
curl -i -X OPTIONS https://api.example.com/resource \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: authorization, content-type"
- JavaScript quick check:
await fetch('https://api.example.com/resource', {
method: 'PUT',
headers: { 'Authorization': 'Bearer X', 'Content-Type': 'application/json' },
body: JSON.stringify({hello: 'world'}),
credentials: 'include'
});
- Server logs: Ensure your logs include
OPTIONSrequests. Many CORS issues are simply missing routes or middleware for preflights.
Common misconfigurations (and how to fix them)
-
Reflecting any Origin back (e.g.,
ACAO: <value of Origin>with no allowlist)- Risk: Any website can read your users’ data.
- Fix: Compare against a strict allowlist; reject or omit ACAO if not matched.
-
Using
ACAO: *withAccess-Control-Allow-Credentials: true- Risk: Browsers will reject this, breaking your app. Some proxies or mis-parsers may introduce ambiguities.
- Fix: Use exact origins when credentials are allowed.
-
Allowing too many methods/headers
- Risk: Expands attack surface and complicates audits.
- Fix: Start minimal; add as features require.
-
Missing
Vary: Origin- Risk: Cache poisoning or policy confusion across clients.
- Fix: Always set
Vary: Originwhen ACAO varies.
-
Assuming CORS equals CSRF protection
- Risk: CSRF remains possible if you rely on cookies and do not implement CSRF tokens or SameSite properly.
- Fix: For cookie-based auth, use CSRF tokens and set
SameSite=LaxorStrictwhere feasible. For cross-site flows that need cookies,SameSite=None; Secureplus CSRF defenses on state-changing endpoints.
-
Trusting
Refererinstead ofOrigin- Risk:
Referercan be absent or truncated;Originis more reliable for CORS decisions. Note:Origin: nullcan appear (file://, sandboxed iframes). Treatnullas untrusted.
- Risk:
-
Forgetting development setups
- Risk: Hard-coding localhost and shipping it to prod, or leaving
*enabled. - Fix: Drive origins from environment, with separate prod/stage/dev lists.
- Risk: Hard-coding localhost and shipping it to prod, or leaving
-
Omitting preflight handler entirely
- Risk: Browser retries with opaque errors; you chase ghosts.
- Fix: Ensure
OPTIONSroutes return quickly with the right headers and 204.
Production-ready checklist
- Explicit allowlist of HTTPS origins; no wildcards with credentials
-
Access-Control-Allow-Methodsrestricted to what you use -
Access-Control-Allow-Headersincludes only necessary custom headers - Preflights answered on all routes with 204 and
Access-Control-Max-Ageset -
Vary: Origin(and for preflight, alsoVary: Access-Control-Request-*) - Cookies, if used, set with
SameSiteandSecure; CSRF protections in place - No blind reflection of
Origin;Origin: nullis rejected - Local dev origins handled via env or dev-only proxy
- Logging for
OPTIONSand CORS headers enabled; synthetic monitors exercise preflights - Automated tests to prevent regression to
*or over-broad methods/headers
Example policy templates
Public, read-only API (no credentials):
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET
Access-Control-Max-Age: 86400
Partner-only API with credentials:
Access-Control-Allow-Origin: https://partner.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Authorization, Content-Type
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
Admin UI calling same-origin API (no cross-origin calls)
- Prefer to avoid CORS entirely. Serve UI and API from the same origin when possible.
Related security headers to know (context only)
- Cross-Origin-Resource-Policy (CORP): Controls whether other sites can embed your resources.
- Cross-Origin-Opener-Policy (COOP) and Cross-Origin-Embedder-Policy (COEP): Enable isolation for powerful features (e.g., SharedArrayBuffer). Not a substitute for CORS, but often configured alongside.
- Content-Security-Policy (CSP): Limits where your frontend can connect (
connect-src) and can help restrict unexpected cross-origin requests from the page itself.
Closing thoughts
CORS is simple in concept but unforgiving in detail. Start with a crisp trust model, encode it as a narrow allowlist, and test preflights as first-class endpoints. With the patterns and snippets above, you can deliver a secure, fast developer experience without sacrificing safety.
Related Posts
Standardizing REST API Responses: A Practical Guide with Examples
A practical guide to standardizing REST API responses—status codes, envelopes, errors, pagination, headers, and examples to build resilient, consistent APIs.
Implementing ETags in REST APIs: Caching and Concurrency, Step by Step
A practical, end-to-end tutorial for implementing ETags in REST APIs with Express, FastAPI, and Spring Boot, plus caching and concurrency tips.
Build an AI Recipe Generation API: End-to-End Tutorial with JSON Schema, Validation, and Caching
Build a production-ready AI recipe generation API with JSON schema, prompting, validation, caching, and code in Node.js and Python.