Understanding CORS
Cross-Origin Resource Sharing (CORS) is a browser security mechanism that restricts how resources on a web page can be requested from another domain. By default, browsers enforce the Same-Origin Policy, which prevents JavaScript on one origin from making requests to a different origin. CORS is the controlled mechanism for relaxing this restriction when needed.
An origin is defined by the combination of protocol, hostname, and port. So https://app.example.com and https://api.example.com are different origins, even though they share the same parent domain. http://example.com and https://example.com are also different origins because the protocol differs.
How CORS Works
- Simple requests: For GET, HEAD, or POST requests with simple headers, the browser sends the request directly and checks the Access-Control-Allow-Origin header in the response.
- Preflight requests: For requests with custom headers, non-simple methods (PUT, DELETE), or Content-Type other than form-data/text/plain, the browser first sends an OPTIONS preflight request to ask the server what is allowed.
- Credentialed requests: Requests that include cookies or authorization headers require the server to explicitly allow credentials with Access-Control-Allow-Credentials: true.
Implementing CORS Securely
import { Request, Response, NextFunction } from "express";
// Secure CORS configuration
const ALLOWED_ORIGINS = new Set([
"https://app.mycompany.com",
"https://admin.mycompany.com",
"https://staging.mycompany.com",
]);
// In development, also allow localhost
if (process.env.NODE_ENV === "development") {
ALLOWED_ORIGINS.add("http://localhost:3000");
ALLOWED_ORIGINS.add("http://localhost:5173");
}
function corsMiddleware(req: Request, res: Response, next: NextFunction) {
const origin = req.headers.origin;
// Only set CORS headers if origin is in the allowlist
if (origin && ALLOWED_ORIGINS.has(origin)) {
// Reflect the allowed origin (not wildcard when using credentials)
res.setHeader("Access-Control-Allow-Origin", origin);
// Allow cookies and authorization headers
res.setHeader("Access-Control-Allow-Credentials", "true");
// Allowed HTTP methods
res.setHeader(
"Access-Control-Allow-Methods",
"GET, POST, PUT, PATCH, DELETE, OPTIONS"
);
// Allowed request headers
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Request-ID"
);
// How long the preflight response can be cached (in seconds)
res.setHeader("Access-Control-Max-Age", "86400"); // 24 hours
// Which response headers the client can access
res.setHeader(
"Access-Control-Expose-Headers",
"X-Request-ID, X-RateLimit-Remaining"
);
// IMPORTANT: Vary by Origin so CDNs cache correctly
res.setHeader("Vary", "Origin");
}
// Handle preflight requests
if (req.method === "OPTIONS") {
return res.status(204).end();
}
next();
}
app.use(corsMiddleware);
Security Warning: CORS Mistakes
- Never use Access-Control-Allow-Origin: * with credentials: Browsers reject this combination, but even without credentials, wildcard CORS exposes your API to any website.
- Never reflect the Origin header blindly: Setting Access-Control-Allow-Origin to whatever origin the request sends is equivalent to a wildcard and defeats CORS entirely.
- Validate origin strictly: Do not use regex or substring matching that could be bypassed (e.g., evil-mycompany.com matching mycompany.com).
- Do not over-expose headers: Only expose response headers that the client actually needs via Access-Control-Expose-Headers.
CORS for Different Architectures
// Pattern 1: API gateway handles CORS for all microservices
// This centralizes CORS configuration and avoids per-service config
// Pattern 2: Per-route CORS (different policies for different endpoints)
function routeSpecificCors(allowedOrigins: string[]) {
const originSet = new Set(allowedOrigins);
return (req: Request, res: Response, next: NextFunction) => {
const origin = req.headers.origin;
if (origin && originSet.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
}
if (req.method === "OPTIONS") {
res.setHeader("Access-Control-Allow-Methods", "GET, POST");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Max-Age", "86400");
return res.status(204).end();
}
next();
};
}
// Public API: wider access
app.use("/api/public", routeSpecificCors([
"https://app.mycompany.com",
"https://partner1.com",
"https://partner2.com",
]));
// Admin API: restricted access
app.use("/api/admin", routeSpecificCors([
"https://admin.mycompany.com",
]));
// Pattern 3: Proxy approach (avoid CORS entirely)
// In Next.js, use API routes as a proxy
// /app/api/external/route.ts
export async function GET(request: Request) {
// This runs server-side, so no CORS restrictions
const data = await fetch("https://external-api.com/data", {
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
});
return Response.json(await data.json());
// Client calls /api/external instead of external-api.com directly
// No CORS headers needed since it is same-origin
}
CORS Best Practices
- Use explicit origin allowlists: Never use wildcards or reflect origins blindly.
- Cache preflight responses: Set Access-Control-Max-Age to reduce preflight requests.
- Use a proxy for third-party APIs: Make API calls server-side to avoid CORS issues entirely.
- Set the Vary header: Always include Vary: Origin when the CORS response depends on the origin.
- Minimize exposed headers: Only expose what the client needs in Access-Control-Expose-Headers.
- Test preflight behavior: Verify that OPTIONS requests are handled correctly and cached properly.