Episode 6 — Scaling Reliability Microservices Web3 / 6.8 — Production Hardening
6.8.b — CORS and Secure Headers
In one sentence: CORS controls which origins can call your API from a browser, and secure HTTP headers (Helmet.js, CSP, HSTS) form an invisible shield that blocks entire classes of cross-site attacks before your application code even runs.
Navigation: ← 6.8.a — Rate Limiting · 6.8.c — DDoS Protection →
1. The Same-Origin Policy
Browsers enforce the Same-Origin Policy (SOP) — JavaScript on one origin cannot read responses from a different origin. An origin is the combination of protocol + host + port.
Same origin:
https://app.example.com/page1 → https://app.example.com/api ✅ Same origin
Different origin (any one part differs):
https://app.example.com → https://api.example.com ✗ Different host
https://app.example.com → http://app.example.com ✗ Different protocol
https://app.example.com → https://app.example.com:8080 ✗ Different port
Why SOP exists: Without it, any website you visit could use your browser to make authenticated requests to your bank, read the response, and steal your data. SOP prevents this by default.
2. What Is CORS?
Cross-Origin Resource Sharing (CORS) is a mechanism that lets servers opt in to allowing cross-origin requests. The server sends special response headers that tell the browser "yes, this origin is allowed to read my responses."
CORS Flow (Simple Request):
Browser (https://app.com) Server (https://api.com)
│ │
│ GET /data │
│ Origin: https://app.com │
│ ─────────────────────────────────────→ │
│ │
│ 200 OK │
│ Access-Control-Allow-Origin: │
│ https://app.com │
│ ←───────────────────────────────────── │
│ │
Browser checks: Origin matches │
Allow-Origin header? YES → show data │
Preflight Requests
For "non-simple" requests (PUT, DELETE, custom headers, JSON content-type), the browser sends an OPTIONS preflight first:
CORS Preflight Flow:
Browser (https://app.com) Server (https://api.com)
│ │
│ OPTIONS /data │ ← Preflight
│ Origin: https://app.com │
│ Access-Control-Request-Method: PUT │
│ Access-Control-Request-Headers: │
│ Content-Type, Authorization │
│ ─────────────────────────────────────→ │
│ │
│ 204 No Content │
│ Access-Control-Allow-Origin: │
│ https://app.com │
│ Access-Control-Allow-Methods: │
│ GET, POST, PUT, DELETE │
│ Access-Control-Allow-Headers: │
│ Content-Type, Authorization │
│ Access-Control-Max-Age: 86400 │
│ ←───────────────────────────────────── │
│ │
│ PUT /data │ ← Actual request
│ Origin: https://app.com │
│ Content-Type: application/json │
│ ─────────────────────────────────────→ │
│ │
│ 200 OK │
│ Access-Control-Allow-Origin: │
│ https://app.com │
│ ←───────────────────────────────────── │
3. Configuring CORS in Express
Basic Setup with the cors Package
import express from 'express';
import cors from 'cors';
const app = express();
// DANGER: Allow ALL origins (only for development!)
app.use(cors());
// This is equivalent to:
// Access-Control-Allow-Origin: *
Production CORS Configuration
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com',
'https://staging.example.com',
];
const corsOptions = {
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, Postman, server-to-server)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed by CORS`));
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
exposedHeaders: ['RateLimit-Limit', 'RateLimit-Remaining', 'RateLimit-Reset'],
credentials: true, // Allow cookies and auth headers
maxAge: 86400, // Cache preflight for 24 hours
optionsSuccessStatus: 204, // Some legacy browsers choke on 204
};
app.use(cors(corsOptions));
Environment-Based CORS
function getCorsOrigins() {
if (process.env.NODE_ENV === 'development') {
return [
'http://localhost:3000',
'http://localhost:3001',
'http://localhost:5173', // Vite dev server
];
}
return [
process.env.FRONTEND_URL, // https://app.example.com
process.env.ADMIN_URL, // https://admin.example.com
].filter(Boolean);
}
app.use(cors({
origin: getCorsOrigins(),
credentials: true,
}));
4. Common CORS Mistakes
| Mistake | Why It's Dangerous | Fix |
|---|---|---|
Access-Control-Allow-Origin: * in production | Any website can call your API | Whitelist specific origins |
* with credentials: true | Browsers block this combo (spec violation) | List explicit origins when using credentials |
Reflecting the Origin header back without validation | Attacker origin gets allowed | Validate against a whitelist |
Forgetting exposedHeaders | Frontend JS cannot read custom response headers | Expose RateLimit-* and other custom headers |
| Missing preflight handling | PUT/DELETE/PATCH requests fail | The cors middleware handles this automatically |
| Hardcoded origins | Breaks when you add new frontends | Use env variables |
// BAD: Reflecting origin without validation (allows any origin)
app.use((req, res, next) => {
res.set('Access-Control-Allow-Origin', req.headers.origin); // NEVER do this
next();
});
// GOOD: Validate against whitelist
app.use(cors({
origin: (origin, cb) => {
if (!origin || allowedOrigins.includes(origin)) {
cb(null, true);
} else {
cb(new Error('Not allowed'));
}
},
}));
5. Credentials and CORS
When your API uses cookies or HTTP authentication, CORS has special rules:
// Server: must explicitly allow credentials
app.use(cors({
origin: 'https://app.example.com', // MUST be specific (not *)
credentials: true, // Allow cookies
}));
// Client: must opt in to sending credentials
fetch('https://api.example.com/data', {
credentials: 'include', // Send cookies cross-origin
});
Rules when credentials: true:
Access-Control-Allow-Origincannot be*— must be a specific origin.Access-Control-Allow-Headerscannot be*.Access-Control-Allow-Methodscannot be*.- The browser will refuse to expose the response if any of these rules are violated.
6. Helmet.js — Secure HTTP Headers
Helmet sets multiple security-related HTTP headers with a single middleware call. It is the easiest security win for any Express app.
import helmet from 'helmet';
// Apply all defaults (recommended starting point)
app.use(helmet());
What helmet() sets by default:
Content-Security-Policy: default-src 'self'; ...
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
X-Content-Type-Options: nosniff
X-DNS-Prefetch-Control: off
X-Download-Options: noopen
X-Frame-Options: SAMEORIGIN
X-Permitted-Cross-Domain-Policies: none
X-XSS-Protection: 0
Referrer-Policy: no-referrer
Strict-Transport-Security: max-age=15552000; includeSubDomains
7. Security Headers Deep Dive
7.1 Content-Security-Policy (CSP)
Controls which resources the browser is allowed to load. The most powerful header against XSS.
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"], // Only allow own origin
scriptSrc: ["'self'", "https://cdn.example.com"], // Scripts from self + CDN
styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles
imgSrc: ["'self'", "data:", "https://images.example.com"],
connectSrc: ["'self'", "https://api.example.com"], // Fetch/XHR targets
fontSrc: ["'self'", "https://fonts.googleapis.com"],
objectSrc: ["'none'"], // Block plugins
frameSrc: ["'none'"], // Block iframes
upgradeInsecureRequests: [], // Force HTTPS
},
}));
// Result header:
// Content-Security-Policy: default-src 'self'; script-src 'self'
// https://cdn.example.com; style-src 'self' 'unsafe-inline'; ...
7.2 X-Content-Type-Options
Prevents the browser from MIME-sniffing a response away from the declared content type.
app.use(helmet.noSniff());
// X-Content-Type-Options: nosniff
Without this, a browser might interpret a text file as JavaScript and execute it.
7.3 X-Frame-Options
Prevents your pages from being embedded in an iframe on another site (clickjacking protection).
app.use(helmet.frameguard({ action: 'deny' }));
// X-Frame-Options: DENY
// Or allow same-origin iframes only
app.use(helmet.frameguard({ action: 'sameorigin' }));
// X-Frame-Options: SAMEORIGIN
7.4 Strict-Transport-Security (HSTS)
Tells browsers to only connect via HTTPS — even if the user types http://.
app.use(helmet.hsts({
maxAge: 31536000, // 1 year in seconds
includeSubDomains: true, // Apply to all subdomains
preload: true, // Allow inclusion in browser preload lists
}));
// Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Warning: Once HSTS is active, users cannot bypass certificate errors. Only enable when your HTTPS setup is solid.
7.5 X-XSS-Protection
Legacy header. Modern browsers have deprecated it in favor of CSP. Helmet sets it to 0 to prevent edge-case issues with older implementations.
app.use(helmet.xssFilter());
// X-XSS-Protection: 0
7.6 Referrer-Policy
Controls how much referrer information is sent with navigation and requests.
app.use(helmet.referrerPolicy({ policy: 'strict-origin-when-cross-origin' }));
// Referrer-Policy: strict-origin-when-cross-origin
8. Cookie Security
Cookies require their own hardening:
import session from 'express-session';
app.use(session({
secret: process.env.SESSION_SECRET,
name: '__session', // Custom name (don't reveal "connect.sid")
cookie: {
httpOnly: true, // JS cannot access (prevents XSS cookie theft)
secure: true, // Only sent over HTTPS
sameSite: 'strict', // Only sent on same-site requests
maxAge: 24 * 60 * 60 * 1000, // 24 hours
domain: '.example.com', // Limit to your domain
path: '/',
},
resave: false,
saveUninitialized: false,
}));
Cookie Attributes Explained
| Attribute | What It Does | Attack It Prevents |
|---|---|---|
HttpOnly | Blocks document.cookie access from JS | XSS cookie theft |
Secure | Cookie only sent over HTTPS | Network sniffing |
SameSite=Strict | Cookie never sent on cross-site requests | CSRF |
SameSite=Lax | Sent on top-level navigations, not subrequests | CSRF (less strict) |
SameSite=None | Always sent (requires Secure) | No protection (needed for cross-site auth) |
Domain | Limits which domains receive the cookie | Cookie leakage to subdomains |
Path | Limits which paths receive the cookie | Unnecessary cookie exposure |
9. HTTPS Enforcement
Redirect HTTP to HTTPS
// Middleware to redirect HTTP → HTTPS
function enforceHttps(req, res, next) {
// Check X-Forwarded-Proto (behind load balancer)
if (req.headers['x-forwarded-proto'] !== 'https' &&
process.env.NODE_ENV === 'production') {
return res.redirect(301, `https://${req.hostname}${req.url}`);
}
next();
}
app.use(enforceHttps);
In AWS (ALB + Route 53)
Client → HTTP://example.com
│
▼
┌──────────────┐
│ ALB Listener │
│ Port 80 │
│ Rule: redirect│ ──→ HTTPS://example.com (301)
│ to HTTPS │
└──────────────┘
Client → HTTPS://example.com
│
▼
┌──────────────┐
│ ALB Listener │
│ Port 443 │
│ SSL cert from │
│ ACM │
└───────┬──────┘
│
▼
┌──────────────┐
│ Express App │
└──────────────┘
10. Complete Security Headers Configuration
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
const app = express();
// --- Trust proxy (required behind ALB/CloudFront) ---
app.set('trust proxy', 1);
// --- HTTPS enforcement ---
if (process.env.NODE_ENV === 'production') {
app.use((req, res, next) => {
if (req.headers['x-forwarded-proto'] !== 'https') {
return res.redirect(301, `https://${req.hostname}${req.url}`);
}
next();
});
}
// --- Helmet (all secure headers) ---
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", process.env.API_URL].filter(Boolean),
fontSrc: ["'self'", "https://fonts.gstatic.com"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
frameguard: { action: 'deny' },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
// --- CORS ---
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
.split(',')
.filter(Boolean);
app.use(cors({
origin: (origin, cb) => {
if (!origin || allowedOrigins.includes(origin)) {
cb(null, true);
} else {
cb(new Error('CORS not allowed'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key', 'X-Request-Id'],
exposedHeaders: ['RateLimit-Limit', 'RateLimit-Remaining', 'RateLimit-Reset'],
maxAge: 86400,
}));
// --- Remove Express fingerprint ---
app.disable('x-powered-by');
// --- Body parser limits ---
app.use(express.json({ limit: '1mb' })); // Prevent huge payloads
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
// --- Your routes ---
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(3000, () => {
console.log('Hardened server running on :3000');
});
11. Key Takeaways
- SOP protects users by default — CORS is how the server explicitly allows cross-origin access.
- Never use
Access-Control-Allow-Origin: *in production — whitelist specific origins. - Preflight (OPTIONS) fires for non-simple requests — your server must handle it.
- Helmet.js is a one-liner that adds CSP, HSTS, X-Frame-Options, and more.
- CSP is the most powerful anti-XSS header — control exactly what the browser can load.
- Cookie security requires three flags:
HttpOnly,Secure,SameSite. - HSTS locks users to HTTPS — enable it when your certificate infrastructure is ready.
Explain-It Challenge
- A junior dev says "I'll just set
cors({ origin: '*' })and it works." Explain why this is dangerous and what to do instead. - Your frontend at
https://app.example.comcannot read theX-Request-Idheader from your API response. Why? - A security audit flags that your cookies lack
SameSite. Explain what attack this enables.
Navigation: ← 6.8.a — Rate Limiting · 6.8.c — DDoS Protection →