Ch. 6 · Node.js

Node.js HTTP Security Headers

Add CSP, nosniff, Referrer-Policy and HSTS to every Node.js response from one function, error pages included, and roll out CSP safely.

~7 min readintermediateupdated Oct 19, 2026

Security headers are instructions from your server to the browser: don’t guess this file’s type, don’t let other sites frame this page, only run scripts from these origins, only ever talk to me over HTTPS. None of them fixes a vulnerability in your code. What they do is shrink what an attacker can achieve when a bug slips through, which is why reviewers and penetration testers check for them, and why they come up in backend interviews.

Node’s http module sends none of them. I started a bare http.createServer((req, res) => res.end('hi')) on Node 22.23 and printed its response headers: apart from Date, Connection, Keep-Alive and Content-Length, there was nothing. Express adds only X-Powered-By: Express, which advertises your stack and is better removed with app.disable('x-powered-by'). Everything else is up to you.

The headers worth sending, and what each one stops

Content-Security-Policy lists where the page may load scripts, styles, images, frames and other resources from. If an attacker manages to inject <script> into a page through a cross-site scripting bug, a policy like script-src 'self' stops the browser from running it, because inline scripts aren’t allowed unless you opt in. It’s the most powerful header here and the only one that takes real work. Two directives are cheap and always worth having: object-src 'none' (no plugins) and base-uri 'none' (an injected <base> tag can’t redirect your relative script URLs).

frame-ancestors 'none', a CSP directive, says no site may put this page in an iframe. That defeats clickjacking, where a hostile page overlays your “Delete account” button with something innocent-looking. The older X-Frame-Options: DENY does the same job; frame-ancestors supersedes it, though some teams send both for old browsers.

X-Content-Type-Options: nosniff tells the browser to trust your Content-Type and not guess. Without it, a user-uploaded “image” that actually contains HTML or JavaScript can sometimes be interpreted as such. It only works if your Content-Type headers are right, so set them deliberately.

Referrer-Policy: strict-origin-when-cross-origin controls how much of the current URL is sent in the Referer header when a user follows a link. Full URLs often contain things you don’t want third parties to see: password reset tokens, search terms, internal ids. With this value, other sites receive only your origin. It’s the modern browser default, but stating it protects users of older browsers and documents the intent.

Strict-Transport-Security (HSTS), for example max-age=63072000; includeSubDomains, tells the browser to use HTTPS for your domain for the next two years, even if the user types http:// or clicks an old link. That closes the window where a first plain-HTTP request can be intercepted on hostile Wi-Fi. Browsers ignore HSTS received over plain HTTP, and once a browser has seen it you can’t easily take it back, so start with a short max-age (a day) while you confirm every subdomain serves HTTPS, then raise it. includeSubDomains will break any subdomain that’s still HTTP-only.

Cross-Origin-Opener-Policy: same-origin separates your window from pages it opens or that open it, which blocks a class of cross-window attacks. Permissions-Policy switches off browser features you don’t use, such as camera=(), microphone=(), geolocation=(), so injected code can’t request them.

You may still see X-XSS-Protection in old checklists. Modern browsers removed the filter it controlled, and the current advice is to omit it or send 0.

Setting them in one place

The design decision that matters most is where the headers get set. If each route sets its own, the 404 page, the error handler and the static file route will eventually be missed, and those are exactly the pages scanners find. Put it in one function that runs for every request before any route logic:

import http from 'node:http';

const CSP = [
  "default-src 'self'",
  "script-src 'self'",
  "img-src 'self' data:",
  "object-src 'none'",
  "base-uri 'none'",
  "frame-ancestors 'none'",
].join('; ');

function securityHeaders(req, res) {
  res.setHeader('Content-Security-Policy', CSP);
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
  res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
  res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
  const https = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https';
  if (https) res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
}

const server = http.createServer((req, res) => {
  securityHeaders(req, res);
  if (req.url !== '/') {
    res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
    return res.end('not found');
  }
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  res.end('<h1>Account</h1>');
});
JavaScript

res.setHeader stores headers on the response without sending anything. res.writeHead later merges its own headers with the stored ones and sends them all. That’s what lets a central function and a route cooperate. Fetching / with X-Forwarded-Proto: https, and then /missing, gave:

-- GET / via proxy: 200
content-security-policy: default-src 'self'; script-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
content-type: text/html; charset=utf-8
cross-origin-opener-policy: same-origin
permissions-policy: camera=(), microphone=(), geolocation=()
referrer-policy: strict-origin-when-cross-origin
strict-transport-security: max-age=63072000; includeSubDomains
x-content-type-options: nosniff
-- GET /missing: 404
content-security-policy: default-src 'self'; script-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
content-type: text/plain; charset=utf-8
cross-origin-opener-policy: same-origin
permissions-policy: camera=(), microphone=(), geolocation=()
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
Text

The 404 carries the same protections, and it has no HSTS header because that request didn’t claim HTTPS.

That X-Forwarded-Proto check needs care. When Node runs behind a load balancer that terminates TLS, the socket Node sees is plain HTTP and req.socket.encrypted is false, so the proxy’s header is the only way to know the original scheme. But any client can send that header, as my test just did. Trust it only when your proxy overwrites it on every request and Node isn’t reachable directly. In Express this is what app.set('trust proxy', ...) configures. In many deployments the simplest answer is to let the proxy or CDN add HSTS itself.

Timing is the other trap. Headers go out with the first byte of the response, so a header set after that fails:

late header: ERR_HTTP_HEADERS_SENT
Text

I triggered that by calling res.setHeader after res.end(). In real code it shows up when middleware that sets headers runs after a route has already started streaming, or in an error handler that tries to add headers to a response that’s half sent. Setting security headers first, before any route runs, avoids both.

In Express or Fastify you’d normally use helmet (or @fastify/helmet) instead of writing this function. It sets a sensible baseline, including a default CSP. The interview point is the same: know what each header does, because you’ll have to adjust the defaults, and the CSP default in particular will break any page that uses inline scripts or third-party analytics.

Rolling out a Content Security Policy without breaking the site

On an existing site, switching on script-src 'self' usually breaks something: an inline <script> that sets up analytics, an onclick= attribute, a chat widget from another domain. The browser blocks them and logs messages like Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'" in the console.

The safe path is to send the policy as Content-Security-Policy-Report-Only first. The browser enforces nothing but reports every violation, to the console and, if you add a report-to or report-uri directive, to an endpoint you collect. Run that for a week, fix or allow each legitimate source, then switch the header name to enforce. For inline scripts you can’t move into files, generate a random nonce per response, add it to the policy (script-src 'self' 'nonce-r4nd0m') and to each <script nonce="r4nd0m"> tag. Avoid 'unsafe-inline', which allows exactly the injected scripts CSP exists to block.

Interview exercise: the pen test report

“A penetration test of your Node API and its admin dashboard reports: no CSP, missing X-Content-Type-Options, clickjacking possible on /admin, and HSTS missing. You add headers in the dashboard’s page route, the retest passes for /admin, but the testers still flag the same issues on /admin/does-not-exist and on uploaded files under /uploads/. What went wrong, and how would you fix it properly?”

Answer. The headers were added per route, so only responses from that route got them. The 404 handler and the static file handler for /uploads/ never called the code, and those paths are precisely what a scanner probes. I’d move header-setting into the first middleware (or a wrapper around the request handler) so every response, including 404s, 500s and static files, passes through it, and add a test that requests a sample of routes, including a nonexistent one, and asserts the headers on each.

For uploads I’d go further than headers. nosniff plus a correct Content-Type stops the browser from treating an uploaded file as HTML, but I’d also serve uploads with Content-Disposition: attachment and ideally from a separate domain, so even a mis-typed file can’t run script in the dashboard’s origin. For clickjacking, frame-ancestors 'none' in the CSP covers it (with X-Frame-Options: DENY for old browsers). For HSTS, I’d confirm whether TLS ends at a load balancer; if so, I’d set the header there or only trust X-Forwarded-Proto from it, and start with a short max-age before increasing it.

Further reading

More in Node.js

read ✓Node.js · mid

Node.js Request Body Validation and Limits

Read a JSON request body in Node.js with a byte limit that also catches chunked uploads, then validate its shape and return 413, 415, 400 or 422.

~6 min readread →
read ✓Node.js · hard

Node.js Password Hashing with scrypt

Hash passwords in Node.js with crypto.scrypt, a random salt and stored cost parameters, then verify logins with timingSafeEqual.

~7 min readread →
read ✓Node.js · mid

Node.js URL and URLSearchParams

Build query strings with URLSearchParams, resolve relative URLs against a base, and check protocol and host before your server fetches.

~7 min readread →
esc