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

MistakeWhy It's DangerousFix
Access-Control-Allow-Origin: * in productionAny website can call your APIWhitelist specific origins
* with credentials: trueBrowsers block this combo (spec violation)List explicit origins when using credentials
Reflecting the Origin header back without validationAttacker origin gets allowedValidate against a whitelist
Forgetting exposedHeadersFrontend JS cannot read custom response headersExpose RateLimit-* and other custom headers
Missing preflight handlingPUT/DELETE/PATCH requests failThe cors middleware handles this automatically
Hardcoded originsBreaks when you add new frontendsUse 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:

  1. Access-Control-Allow-Origin cannot be * — must be a specific origin.
  2. Access-Control-Allow-Headers cannot be *.
  3. Access-Control-Allow-Methods cannot be *.
  4. 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

AttributeWhat It DoesAttack It Prevents
HttpOnlyBlocks document.cookie access from JSXSS cookie theft
SecureCookie only sent over HTTPSNetwork sniffing
SameSite=StrictCookie never sent on cross-site requestsCSRF
SameSite=LaxSent on top-level navigations, not subrequestsCSRF (less strict)
SameSite=NoneAlways sent (requires Secure)No protection (needed for cross-site auth)
DomainLimits which domains receive the cookieCookie leakage to subdomains
PathLimits which paths receive the cookieUnnecessary 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

  1. SOP protects users by default — CORS is how the server explicitly allows cross-origin access.
  2. Never use Access-Control-Allow-Origin: * in production — whitelist specific origins.
  3. Preflight (OPTIONS) fires for non-simple requests — your server must handle it.
  4. Helmet.js is a one-liner that adds CSP, HSTS, X-Frame-Options, and more.
  5. CSP is the most powerful anti-XSS header — control exactly what the browser can load.
  6. Cookie security requires three flags: HttpOnly, Secure, SameSite.
  7. HSTS locks users to HTTPS — enable it when your certificate infrastructure is ready.

Explain-It Challenge

  1. A junior dev says "I'll just set cors({ origin: '*' }) and it works." Explain why this is dangerous and what to do instead.
  2. Your frontend at https://app.example.com cannot read the X-Request-Id header from your API response. Why?
  3. 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 →