Security Headers
HTTP security headers are the cheapest defense you can deploy — one block of middleware turns dozens of browser-side risks into errors. Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options, Referrer-Policy and Permissions-Policy are the modern starter kit.
CSP, HSTS, COOP/COEP, Permissions-Policy
EXAMPLE
// 1) Express middleware — helmet sets sensible defaults
import express from 'express';
import helmet from 'helmet';
const app = express();
app.use(helmet());
// One line wires up: CSP, HSTS, X-Content-Type-Options, X-DNS-Prefetch-Control,
// X-Download-Options, X-Frame-Options, Referrer-Policy, and more.
// Override what you actually need
app.use(helmet({
contentSecurityPolicy: {
useDefaults: true,
directives: {
'default-src': ["'self'"],
'script-src': ["'self'", "'nonce-${RES_NONCE}'", 'https://cdn.example.com'],
'style-src': ["'self'", "'unsafe-inline'"], // tighten later
'img-src': ["'self'", 'data:', 'https://cdn.example.com'],
'font-src': ["'self'", 'https://fonts.gstatic.com'],
'connect-src': ["'self'", 'https://api.example.com'],
'frame-ancestors': ["'none'"],
'base-uri': ["'self'"],
'form-action': ["'self'"],
'object-src': ["'none'"],
'upgrade-insecure-requests': [],
},
},
crossOriginEmbedderPolicy: false, // enable only if you need it (worker isolation, SharedArrayBuffer)
}));
// 2) Strict-Transport-Security — force HTTPS
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload',
);
// max-age in seconds (1 year). preload after you've submitted to https://hstspreload.org.
// CAUTION: long max-age + wrong cert breaks everyone. Roll out with a short max-age first.
// 3) Content-Security-Policy — script execution allowlist
// • script-src 'self' 'nonce-...' — disallow inline scripts unless they carry the nonce
// • object-src 'none' — kill <object>, <embed>, <applet>
// • frame-ancestors 'none' — equivalent of X-Frame-Options: DENY
// • upgrade-insecure-requests — auto-rewrite http: to https:
//
// Inline scripts in templates must use the per-request nonce:
// <script nonce="\${nonce}">window.config = …;</script>
import crypto from 'node:crypto';
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
next();
});
// 4) X-Content-Type-Options: nosniff — stop MIME-type sniffing
res.setHeader('X-Content-Type-Options', 'nosniff');
// 5) Referrer-Policy — limit referrer leak
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Good default: full URL same-origin, only the origin cross-origin, nothing on HTTP downgrade.
// 6) Permissions-Policy — opt into / out of browser features
res.setHeader(
'Permissions-Policy',
[
'camera=()',
'microphone=()',
'geolocation=(self)',
'payment=(self)',
'usb=()',
'autoplay=(self)',
'interest-cohort=()', // disable FLoC
].join(', '),
);
// 7) X-Frame-Options: DENY — legacy clickjacking defense
res.setHeader('X-Frame-Options', 'DENY');
// CSP frame-ancestors is more granular; ship both for older browsers.
// 8) Cross-Origin-Opener-Policy / Cross-Origin-Embedder-Policy — origin isolation
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
// COOP keeps pop-up attacks (window.opener tampering) from working.
// COEP gates loading of cross-origin resources unless they opt in.
// Needed for SharedArrayBuffer / cross-origin isolated APIs.
// 9) Cache-Control on auth/PII responses
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
// Stops shared proxies from caching user-specific responses.
// 10) CORS — narrow allowlist, never wildcard with credentials
const ALLOWED = new Set([
'https://app.example.com',
'https://admin.example.com',
]);
app.use((req, res, next) => {
const origin = req.get('origin');
if (origin && ALLOWED.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
}
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-CSRF-Token');
res.setHeader('Access-Control-Max-Age', '600');
return res.sendStatus(204);
}
next();
});
// 11) Static-site / CDN — set headers there too
// Cloudflare / Fastly / nginx — apply the same set at the edge so it covers static assets.
// Example nginx:
// add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
// add_header X-Content-Type-Options nosniff always;
// add_header Referrer-Policy "strict-origin-when-cross-origin" always;
// add_header Content-Security-Policy "default-src 'self'; ..." always;
// 12) Report-only mode for rollout
res.setHeader(
'Content-Security-Policy-Report-Only',
"default-src 'self'; script-src 'self'; report-to csp-endpoint",
);
res.setHeader('Report-To', JSON.stringify({
group: 'csp-endpoint',
max_age: 10886400,
endpoints: [{ url: 'https://api.example.com/csp-report' }],
}));
// Read reports for a week or two, fix the violations, THEN switch to enforce.
// 13) Validate from outside
// • https://securityheaders.com — grade your endpoints
// • https://csp-evaluator.withgoogle.com — CSP common-mistake checker
// • https://hstspreload.org — only after you're sure about your HSTS rollout
// • Browser devtools → Network → Headers — verify on every endpoint
// 14) CI guard — fail the build if headers regress
import request from 'supertest';
import { test, expect } from 'vitest';
test('security headers present on every endpoint', async () => {
for (const path of ['/', '/login', '/api/health']) {
const r = await request(app).get(path);
expect(r.headers['strict-transport-security']).toMatch(/max-age=/);
expect(r.headers['x-content-type-options']).toBe('nosniff');
expect(r.headers['content-security-policy']).toMatch(/script-src/);
expect(r.headers['referrer-policy']).toBeTruthy();
expect(r.headers['permissions-policy']).toMatch(/camera=\(\)/);
}
});
// 15) Common bugs
// • CSP allows 'unsafe-inline' on script-src → XSS becomes easy to weaponise
// • HSTS with includeSubDomains + a subdomain without HTTPS → outage
// • Wildcard CORS with Allow-Credentials → standards-violation; browsers ignore it (good) but it signals bad config
// • Missing Vary: Origin → CDN serves the wrong CORS headers to the wrong origin
// • X-XSS-Protection still on → it's deprecated; rely on CSP, drop the header
// • COEP: require-corp without sending CORP headers from CDN → assets fail to load
Why it matters
Ship a baseline header set on day one — HSTS, CSP, nosniff, Referrer-Policy, Permissions-Policy — and use Content-Security-Policy-Report-Only for a week before enforcement so you find the inline scripts and third-party loads that need fixing. Once enforced, it’s the highest-leverage defense per line of config in your whole stack.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// Modern defaults Strict-Transport-Security: max-age=63072000; includeSubDomains; preload Content-Security-Policy: default-src 'self'; … X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=() Cross-Origin-Opener-Policy: same-originTry it Yourself »
Exercise
Strict transport security header name.
: max-age=63072000; includeSubDomains; preload
Hyphenated PascalCase.
Discussion
Loading…