CORS — Configuration, Preflight Requests, and Credentials

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
Note: 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.
Tip: In production, always specify an explicit origin allowlist rather than 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.
Warning: When using 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.

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())

🧠 Test Yourself

Angular sends a DELETE /api/tasks/42 request with an Authorization header. Before the DELETE is sent, what does the browser send first, and why?