Security headers, CORS, and TLS posture
Security headers are the browser-side perimeter: CSP cuts XSS, HSTS kills SSL-strip, nosniff stops MIME guessing. CORS is the server opting in to cross-origin reads — reflecting the Origin with credentials hands any site your users' cookies. Terminate TLS 1.2+ with a full chain.
A pentester opens your API with a one-line report: “any-origin CORS with credentials.” You shrug — CORS, that’s the thing you turned on so the SPA would stop erroring, right? Then they show the proof of concept. They host a page on evil.example, the victim (logged into your app) visits it, and the page does fetch('https://api.yourapp.com/me', { credentials: 'include' }). The browser attaches the victim’s session cookie, your server answers Access-Control-Allow-Origin: evil.example plus Access-Control-Allow-Credentials: true, and the attacker’s JavaScript reads the full profile JSON — email, tokens, billing. No XSS, no stolen password. You “fixed” a CORS error months ago by echoing back whatever Origin showed up, and that one line turned every logged-in user into a drive-by data leak.
Security response headers: each one blocks a named attack
A security response header is a directive you send on every response that reconfigures the browser’s own defenses. It costs nothing at runtime and is the cheapest layer you own, but each header is paired with a specific attack — set the wrong one and you get a false sense of safety. When you review a new deployment, these six headers should be the first thing you check on the response.
Content-Security-Policyis the heavy hitter. It tells the browser which sources may load and execute resources, so an injected<script>from an XSS payload simply doesn’t run.script-src 'self'means “only scripts served from my own origin execute”; inline<script>blocks andonclick=handlers are refused. The proper way to allow a known inline block is a per-response nonce (script-src 'nonce-r4nd0m', matched by<script nonce="r4nd0m">) or a content hash. The trap: adding'unsafe-inline'to make a legacy widget work re-enables every inline script — which defeats the whole point, because the XSS payload is inline script. A strict CSP is the single biggest XSS reducer you can deploy, but it is also the most likely to break a page, so roll it out withContent-Security-Policy-Report-Onlyfirst and watch the violation reports.Strict-Transport-Security(HSTS) tells the browser “for the next N seconds, never speak HTTP to this host — upgrade to HTTPS before sending anything.” That closes the SSL-strip window where a network attacker downgrades the first plaintext request and MITMs the session.max-age=31536000is one year;includeSubDomainsextends it to every subdomain;preload(after submitting to the browser preload list) means the very first request is already protected, with no trust-on-first-use gap.X-Content-Type-Options: nosniffstops the browser from second-guessing yourContent-Type. Without it, a browser may “sniff” a user-uploaded file you served astext/plainand decide it’s actually HTML or JavaScript, executing it. One header, MIME-confusion class gone.- Clickjacking — your page loaded invisibly inside an attacker’s
<iframe>over a decoy UI — is blocked byContent-Security-Policy: frame-ancestors 'none'(the modern control) or the olderX-Frame-Options: DENY. Sendframe-ancestors; keepX-Frame-Optionsonly for ancient clients. Referrer-Policy: strict-origin-when-cross-origintrims theRefererheader so you don’t leak full URLs (with tokens in the path or query) to third-party origins.
import http from "node:http";
http.createServer((req, res) => {
// raw: explicit, dependency-free, easy to get subtly wrong at scale
res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; frame-ancestors 'none'");
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
res.end("ok");
}).listen(3000);import express from "express";
import helmet from "helmet";
const app = express();
// helmet sets a sane bundle (CSP, HSTS, nosniff, frame-ancestors, referrer) in one line.
// You still own the CSP — its default is strict and will block inline scripts until you add a nonce.
app.use(helmet());The tradeoff is concentrated in CSP. helmet() hands you most headers for free, but the strict CSP it ships will break inline scripts and any third-party widget that injects script — so the work is not “turn it on,” it’s “enumerate your real sources, add nonces or hashes, run report-only, then enforce.” Skipping that rollout is why teams reach for 'unsafe-inline' and quietly throw the protection away.
CORS: the server opting in to cross-origin reads
The most misunderstood thing about CORS is that it is not a feature you enable to make requests work — it is the browser’s relaxation of a default prohibition. The Same-Origin Policy is the baseline: a page on https://app.com may send a request to https://api.com, but the browser will not let the page read the response unless the responding server explicitly says so. CORS is that “explicitly says so.”
For a simple request (a plain GET/POST with only safe headers), the browser sends it and then checks the response: if Access-Control-Allow-Origin matches the page’s origin, the JavaScript may read the body; if not, the request still hit your server but the browser hides the response from the script.
For a non-simple request — a PUT/DELETE, a JSON Content-Type, or a custom header like Authorization — the browser first sends a preflight: an OPTIONS request carrying Access-Control-Request-Method and Access-Control-Request-Headers. Your server must answer with Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers that cover what was asked. Only if the preflight passes does the browser send the real request — and only then expose its response.
▸Why this works
CORS is enforced by the browser, not by your server, and that single fact resolves most confusion. CORS is not an authorization or access-control mechanism for your API. A locked-down Access-Control-Allow-Origin does nothing to stop curl, a mobile app, a server-to-server call, or any non-browser client — they ignore CORS entirely and read whatever your endpoint returns. CORS protects one specific thing: it stops a malicious web page running in a victim’s browser from reading your responses with that victim’s ambient credentials. Real API protection is still authentication, authorization, and rate limiting on the server. If you ever think “CORS will keep attackers out of this endpoint,” stop — it won’t.
The credentials trap: how a CORS “fix” becomes account takeover
Here is the war story in mechanism form. The spec forbids combining Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true — the browser flatly rejects that pairing, because a wildcard plus cookies would mean any site could make authenticated reads. So a wildcard can never carry credentials, which is safe by design.
The danger is the “fix” teams reach for when the SPA needs cookies and the wildcard stops working. They make the server reflect the request Origin back — cors({ origin: true }), or hand-rolled res.setHeader('Access-Control-Allow-Origin', req.headers.origin) — together with Access-Control-Allow-Credentials: true. Now the server says “yes” to whatever origin asks, with credentials. That is functionally identical to the forbidden wildcard-plus-credentials, except the spec can’t stop it because each individual response looks origin-specific.
// ❌ THE TRAP: reflect any origin, with credentials. Every site is now allowed.
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin ?? "*"); // echoes evil.example too
res.setHeader("Access-Control-Allow-Credentials", "true");
next();
});
// ✅ THE FIX: validate Origin against an explicit allow-list before reflecting.
const ALLOWED = new Set(["https://app.yourapp.com", "https://admin.yourapp.com"]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin); // reflect ONLY allow-listed origins
res.setHeader("Vary", "Origin"); // cache correctly per-origin
res.setHeader("Access-Control-Allow-Credentials", "true");
}
// no Origin header, or not allow-listed → emit nothing; browser blocks the read
next();
});The exploit flow is exactly the hook: attacker hosts evil.example, a logged-in victim visits, the page does fetch(api, { credentials: 'include' }), the browser attaches the session cookie, your reflect-everything server returns Allow-Origin: evil.example + Allow-Credentials: true, and the attacker’s script reads the authenticated response — profile, tokens, anything that endpoint returns. The fix is non-negotiable: never reflect an arbitrary origin with credentials. Validate against an allow-list, emit Vary: Origin so a shared cache doesn’t serve one origin’s header to another, and reflect only when the origin is known. (SameSite cookies — covered with session auth — are a second, partial line of defense here, but the CORS allow-list is the one you control directly.)
TLS posture: terminate it correctly, or the headers don’t matter
Every header above assumes the connection is actually private. TLS posture is getting that termination right.
- Minimum version TLS 1.2, prefer 1.3. TLS 1.0/1.1 are broken and must be off. Beyond security, 1.3 is faster: its handshake is 1-RTT versus 1.2’s 2-RTT, and 1.3 supports 0-RTT resumption — measurable latency on every new connection. Disable legacy ciphers and insecure renegotiation.
- Serve the complete certificate chain. Your leaf cert must be sent with its intermediate(s). A missing intermediate is the classic “works in my browser, breaks in production” trap: browsers often cache intermediates or fetch them via the AIA extension and paper over the gap, but many API clients, mobile SDKs, and older stacks do not — they fail the handshake outright. So it passes your manual check and breaks for a third of your integrations.
- OCSP stapling lets your server attach a fresh, signed revocation proof to the handshake, so clients don’t make a separate (slow, privacy-leaking, sometimes-failing) call to the CA.
- Where to terminate. Terminating at a proxy or load balancer (nginx, ALB, Cloudflare) is the common, operationally simpler choice. Terminating in Node directly (
https.createServerwithminVersion: 'TLSv1.2') is fine for self-contained services. Either way, when a proxy terminates and forwards plaintext to Node, only trustX-Forwarded-Protofrom that known proxy — setapp.set('trust proxy', ...)to a specific hop, never blindly, or a client can spoofX-Forwarded-Proto: httpsand defeat your “redirect to HTTPS” logic.
import https from "node:https";
import { readFileSync } from "node:fs";
https.createServer(
{
key: readFileSync("privkey.pem"),
// fullchain = leaf + intermediates. Serving only the leaf is the missing-chain bug.
cert: readFileSync("fullchain.pem"),
minVersion: "TLSv1.2", // floor; 1.3 negotiated when both sides support it
},
(req, res) => res.end("secure"),
).listen(443);Your API serves a public, unauthenticated feed AND a first-party SPA that needs cookie-based session reads from app.yourapp.com. How do you configure CORS?
A teammate says 'our CORS config locks the API down so only our SPA can call it.' Why is this wrong?
- 01Why is reflecting req.headers.origin back into Access-Control-Allow-Origin together with Access-Control-Allow-Credentials: true an account-takeover vulnerability, and what is the fix?
- 02Why does CORS NOT protect your API from attackers, and why does a missing intermediate certificate 'work in my browser' but break for many clients?
Security headers are the browser-side perimeter and the cheapest layer you own, each paired with a named attack: Content-Security-Policy with script-src 'self' plus nonces or hashes is the single biggest XSS reducer (and 'unsafe-inline' throws that protection away), Strict-Transport-Security with max-age=31536000; includeSubDomains; preload kills SSL-strip, X-Content-Type-Options: nosniff stops MIME sniffing, frame-ancestors/X-Frame-Options stop clickjacking, and Referrer-Policy stops URL leakage — helmet() ships the bundle but the strict CSP needs a report-only rollout. CORS is the most misunderstood of the lot: the Same-Origin Policy is the default, and CORS is how a server opts in to letting a page read a cross-origin response, enforced by the browser, not the server — so it never protects your API from non-browser clients. The fatal mistake is reflecting an arbitrary Origin back with Access-Control-Allow-Credentials: true, which lets any site read your users’ authenticated responses; validate against an allow-list, send Vary: Origin, and reflect only known origins. Finally, none of it matters without a private channel: terminate TLS 1.2+ (prefer the 1-RTT 1.3 handshake), disable legacy ciphers and renegotiation, staple OCSP, serve the full certificate chain so non-browser clients don’t fail the handshake, and trust X-Forwarded-Proto only from a known proxy. Now when you see cors({ origin: true }) in a codebase or 'unsafe-inline' in a CSP, you know exactly why those two lines undo the entire protection they pretend to add.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.