Cross-Origin Resource Sharing (CORS) is the browser mechanism that controls which web pages can make HTTP requests to your API. Without CORS headers, a browser blocks any request from a different origin — domain, protocol, or port — as a security measure. In a MEAN Stack application, Angular runs on port 4200 and Express on port 3000 during development — they are different origins, so CORS must be configured on Express. Understanding CORS deeply — the same-origin policy, simple vs non-simple requests, preflight, credentials — prevents the most frustrating category of development errors and security misconfigurations.
CORS Key Concepts
| Concept | Meaning |
|---|---|
| Same-origin | Same protocol + domain + port — requests allowed without CORS |
| Cross-origin | Different protocol, domain, OR port — browser checks CORS headers |
| Simple request | GET/POST/HEAD with simple headers — sent directly, CORS checked on response |
| Non-simple (preflighted) | PUT/PATCH/DELETE or custom headers — browser sends OPTIONS first |
| Preflight | Browser’s automatic OPTIONS request to check if actual request is allowed |
| Credentials | Cookies, HTTP auth, TLS client certs — require explicit opt-in |
Access-Control-Allow-Origin |
Which origins are allowed — specific origin or * |
Access-Control-Allow-Methods |
Which HTTP methods are allowed from cross-origin |
Access-Control-Allow-Headers |
Which request headers are allowed from cross-origin |
Access-Control-Allow-Credentials |
Whether cookies/auth can be sent cross-origin |
Access-Control-Max-Age |
How long the browser caches the preflight result (seconds) |
Access-Control-Expose-Headers |
Response headers the browser is allowed to read |
Simple vs Preflighted Requests
| Request Type | Conditions | CORS Flow |
|---|---|---|
| Simple | GET, POST, HEAD with basic headers (Content-Type: text/plain, form, json) | Request sent directly; CORS headers on response checked |
| Preflighted | PUT, PATCH, DELETE; custom headers like Authorization; Content-Type: application/json | OPTIONS preflight first; if approved, actual request sent |
| Credentialed | Any request with credentials: 'include' |
Wildcard * not allowed; specific origin required |
Content-Type: application/json triggers a preflight request. This means every POST request from Angular’s HttpClient with a JSON body sends an OPTIONS request first. Your Express CORS configuration must handle the OPTIONS method — the cors() middleware does this automatically. If you have an authentication middleware that rejects unauthenticated requests before CORS runs, it must be positioned after CORS, not before it, or the preflight OPTIONS request (which carries no auth token) will be rejected.origin: '*'. A wildcard allows any website to call your API and read the response. For APIs that serve authenticated user data, always use origin: ['https://yourapp.com', 'https://admin.yourapp.com']. Use an environment variable: origin: process.env.ALLOWED_ORIGINS.split(',') so you can configure different origins for staging and production without code changes.credentials: true in the CORS configuration (required for cookie-based auth), you cannot use origin: '*' — the browser rejects this combination. You must specify the exact origin. This is a browser security requirement, not a limitation of the cors library. If you need to support credentials from multiple origins, use a dynamic origin function that validates against an allowlist and returns the matching origin.Complete CORS Configuration
// npm install cors
const cors = require('cors');
// ── Option 1: Simple — single allowed origin (most common) ───────────────
app.use(cors({
origin: process.env.FRONTEND_URL || 'http://localhost:4200',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
exposedHeaders: ['X-Total-Count', 'X-RateLimit-Remaining'],
credentials: true, // allow cookies if using cookie-based auth
maxAge: 600, // cache preflight for 10 minutes
}));
// ── Option 2: Multiple allowed origins ───────────────────────────────────
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || 'http://localhost:4200')
.split(',')
.map(o => o.trim());
app.use(cors({
origin: (requestOrigin, callback) => {
// Allow requests with no origin (curl, Postman, server-to-server)
if (!requestOrigin) return callback(null, true);
if (ALLOWED_ORIGINS.includes(requestOrigin)) {
callback(null, requestOrigin); // reflect the origin back
} else {
callback(new Error(`CORS: origin ${requestOrigin} not allowed`));
}
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400, // 24 hours
}));
// ── Option 3: Different CORS for API vs public assets ────────────────────
const apiCors = cors({
origin: process.env.FRONTEND_URL,
credentials: true,
allowedHeaders: ['Content-Type', 'Authorization'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
});
const publicCors = cors({ origin: '*' }); // public endpoints — no credentials
app.use('/api', apiCors);
app.use('/public', publicCors);
// ── Handling preflight manually (alternative to cors middleware) ──────────
app.options('*', cors()); // enable pre-flight for all routes
// ── CORS for specific routes only ────────────────────────────────────────
const restrictedCors = cors({ origin: 'https://admin.example.com' });
app.get('/api/admin/stats', restrictedCors, adminController.getStats);
// ── Debugging CORS issues in Express ─────────────────────────────────────
app.use((req, res, next) => {
if (process.env.DEBUG_CORS === 'true') {
console.log('CORS Debug:', {
origin: req.headers.origin,
method: req.method,
path: req.path,
headers: req.headers,
});
}
next();
});
Angular HttpClient and CORS
// Angular service — CORS-aware HTTP configuration
// frontend/src/app/core/services/api.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { environment } from '../../../environments/environment';
@Injectable({ providedIn: 'root' })
export class ApiService {
private baseUrl = environment.apiUrl; // 'http://localhost:3000/api/v1'
constructor(private http: HttpClient) {}
// Default: Angular does NOT send cookies cross-origin
get<T>(path: string) {
return this.http.get<T>(`${this.baseUrl}${path}`);
}
// For cookie-based auth (session cookies) — withCredentials: true
getWithCookies<T>(path: string) {
return this.http.get<T>(`${this.baseUrl}${path}`, {
withCredentials: true // sends cookies; requires credentials: true on server
});
}
post<T>(path: string, body: unknown) {
// Content-Type: application/json triggers preflight OPTIONS
// The cors() middleware on Express handles this automatically
return this.http.post<T>(`${this.baseUrl}${path}`, body);
}
}
// For JWT-based auth (most common in MEAN Stack):
// withCredentials is NOT needed — JWT is sent via Authorization header
// The Authorization header triggers a preflight, handled by cors() middleware
How It Works
Step 1 — The Browser Enforces CORS, Not the Server
CORS is a browser security policy — the server does not block cross-origin requests, the browser does. When a cross-origin request arrives at your Express server, the server processes it normally. What changes is whether the browser allows the JavaScript code to read the response. Without the correct CORS headers in the response, the browser receives the response but throws an error in the JavaScript console and does not allow your Angular code to access the data.
Step 2 — Preflight Is a Safety Check by the Browser
For “non-simple” requests (PUT, PATCH, DELETE, or requests with Authorization headers), the browser sends a preliminary OPTIONS request to the same URL asking: “Am I allowed to send the real request?” The server must respond with headers confirming the method and headers are permitted. Only then does the browser send the actual request. The cors() middleware handles this OPTIONS response automatically.
Step 3 — credentials: true Enables Cookie Sharing
By default, the browser does not send cookies or authentication headers with cross-origin requests. Setting credentials: true in the cors configuration sends Access-Control-Allow-Credentials: true in the response, telling the browser it is safe to include cookies. Angular must also opt in by setting withCredentials: true on its HTTP calls. Both sides must agree — one side alone is not enough.
Step 4 — exposedHeaders Makes Custom Response Headers Readable
By default, only a small set of “simple” response headers (Content-Type, Content-Length, etc.) are accessible to JavaScript. Custom headers like X-Total-Count (for pagination totals) or X-RateLimit-Remaining are blocked unless you explicitly list them in exposedHeaders. Angular’s HTTP interceptors and service methods can only read these headers if the server exposes them via the CORS configuration.
Step 5 — maxAge Reduces Preflight Round-Trips
The browser sends a preflight OPTIONS request before every non-simple cross-origin request. With maxAge: 86400, the browser caches the preflight result for 24 hours. This means only the first request of each type triggers a preflight — subsequent requests go directly without the extra round-trip. This halves the number of HTTP requests your Angular application makes for PUT, PATCH, and DELETE operations.
Real-World Example: Environment-Aware CORS
// config/cors.config.js
const cors = require('cors');
function createCorsMiddleware() {
const env = process.env.NODE_ENV || 'development';
const allowedOrigins = {
development: ['http://localhost:4200', 'http://localhost:3001'],
test: ['http://localhost:4200'],
production: (process.env.ALLOWED_ORIGINS || '').split(',').map(o => o.trim()),
};
const origins = allowedOrigins[env] || allowedOrigins.development;
return cors({
origin: (requestOrigin, callback) => {
if (!requestOrigin || origins.includes(requestOrigin)) {
return callback(null, requestOrigin || '*');
}
console.warn(`[CORS] Blocked request from: ${requestOrigin}`);
callback(new Error(`Origin ${requestOrigin} not allowed by CORS policy`));
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID', 'X-API-Key'],
exposedHeaders: ['X-Total-Count', 'X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset'],
credentials: true,
maxAge: env === 'production' ? 86400 : 0,
});
}
module.exports = createCorsMiddleware;
// app.js
const createCorsMiddleware = require('./config/cors.config');
app.use(createCorsMiddleware());
app.options('*', createCorsMiddleware()); // handle preflight for all routes
Common Mistakes
Mistake 1 — Placing authentication middleware before CORS
❌ Wrong — OPTIONS preflight has no auth token, gets rejected by auth middleware:
app.use(authenticate); // runs first — OPTIONS preflight fails (no JWT)
app.use(cors()); // never reached for preflighted requests
✅ Correct — CORS must run before authentication:
app.use(cors()); // handles OPTIONS preflight before auth runs
app.use(authenticate); // runs only after CORS approves the request
Mistake 2 — Using origin: ‘*’ with credentials: true
❌ Wrong — browser rejects this combination:
app.use(cors({ origin: '*', credentials: true }));
// Browser error: "The value of the 'Access-Control-Allow-Origin' header
// must not be the wildcard '*' when the request's credentials mode is 'include'"
✅ Correct — specify exact origin with credentials:
app.use(cors({ origin: 'http://localhost:4200', credentials: true }));
Mistake 3 — Forgetting to expose custom response headers
❌ Wrong — Angular cannot read X-Total-Count header:
app.use(cors({ origin: 'http://localhost:4200' }));
res.set('X-Total-Count', total);
// Angular: response.headers.get('X-Total-Count') === null
✅ Correct — expose custom headers explicitly:
app.use(cors({
origin: 'http://localhost:4200',
exposedHeaders: ['X-Total-Count'], // now readable by Angular
}));
Quick Reference
| Scenario | Configuration |
|---|---|
| Single dev origin | origin: 'http://localhost:4200' |
| Multiple origins | origin: (origin, cb) => cb(null, whitelist.includes(origin)) |
| Allow all (public API) | origin: '*' (no credentials) |
| With cookies | credentials: true + specific origin |
| Cache preflight | maxAge: 86400 |
| Expose custom headers | exposedHeaders: ['X-Total-Count'] |
| Allow custom request headers | allowedHeaders: ['Authorization', 'X-API-Key'] |
| Handle preflight | app.options('*', cors()) |