Security Headers

Response headers a browser reads and then enforces, what each one prevents, and how to set them in Node.

Syntaxres.setHeader('Content-Security-Policy', "default-src 'self'");

A header is a name and value sent with a response, ahead of the body. A security header is one the browser reads and then enforces a restriction on itself. Node sends none of them for you. You set them on every response you want protected.

The ones that earn their place

  • Content-Security-Policy lists where the page may load scripts, styles and images from. default-src 'self' means only this origin. This is the main defence against cross-site scripting: markup injected into your page that runs an attacker's JavaScript with your user's cookies.
  • Strict-Transport-Security tells the browser to use HTTPS for this host for the next max-age seconds, even when a link says http://.
  • X-Content-Type-Options: nosniff stops the browser guessing a response's type from its bytes, so an uploaded file is not treated as script.
  • Referrer-Policy controls how much of the current URL is sent when the user follows a link off site.
  • X-Frame-Options: DENY, or the CSP directive frame-ancestors 'none', stops another site embedding your page in a frame.

Two ways to write the same thing

res.writeHead(200, headersObject) is the shorthand. The longhand is one res.setHeader(name, value) per entry followed by res.writeHead(200). The response on the wire is identical; pick whichever reads better where you are.

What trips people up

These headers only bind browsers. Another service calling your JSON API ignores every one of them, so headers are a layer for browser-loaded pages, not a substitute for checking input. And a CSP without 'unsafe-inline' blocks your own inline <script> blocks and style attributes. That is the point of it, and it does break pages that relied on them.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Harden a Node service that serves HTML before it goes in front of real users.
  • Satisfy a penetration-test finding that lists missing CSP, HSTS and nosniff headers.
  • Stop a partner site from embedding your dashboard in a frame.
  • Keep query strings containing identifiers out of the Referer sent to third-party sites.

More examples

One header set, applied in one place

Headers set per route get forgotten on the route added next month. Funnelling every response through one helper makes the set a property of the service rather than of each handler. Reach for this the moment you have more than two or three handlers.

Example · javascript
// Object.freeze stops a route accidentally mutating the shared set.
const BASE_HEADERS = Object.freeze({
  'Content-Security-Policy': "default-src 'self'; object-src 'none'",
  'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
  'X-Content-Type-Options': 'nosniff',
  'Referrer-Policy': 'strict-origin-when-cross-origin',
  'X-Frame-Options': 'DENY'
});

// Every response in the service goes through this helper, so no route can
// forget the headers. { ...BASE_HEADERS, ...extra } is shorthand for
// Object.assign({}, BASE_HEADERS, extra) - the same object, spelled shorter.
function sendJson(res, status, body, extra = {}) {
  res.writeHead(status, {
    ...BASE_HEADERS,
    'Content-Type': 'application/json; charset=utf-8',
    ...extra
  });
  res.end(JSON.stringify(body));
}

sendJson(res, 200, { id: 7 });
sendJson(res, 404, { error: 'not found' }, { 'Cache-Control': 'no-store' });

A CSP nonce, so your own inline script still runs

The usual reason teams give up on CSP is that their pages carry inline scripts and switching on 'unsafe-inline' defeats the policy. A per-response nonce keeps the strict policy and lets your own inline script through. Generate it fresh for every response; a fixed nonce is worth nothing.

Example · javascript
const { randomBytes } = require('node:crypto');

// A nonce is a random value generated per response. The browser runs an
// inline script only if its nonce attribute matches the one in the header,
// which an injected <script> cannot know.
function sendPage(res, template) {
  const nonce = randomBytes(16).toString('base64');

  res.writeHead(200, {
    'Content-Type': 'text/html; charset=utf-8',
    'Content-Security-Policy':
      `default-src 'self'; script-src 'self' 'nonce-${nonce}'; object-src 'none'`,
    'X-Content-Type-Options': 'nosniff'
  });

  // The template contains: <script nonce="__NONCE__">...</script>
  res.end(template.replaceAll('__NONCE__', nonce));
}

A different policy for a JSON API

Copying a page's CSP onto an API gives you a policy that permits more than the endpoint needs. default-src 'none' is the honest answer for JSON: nothing may load. Use this header set for every authenticated data endpoint.

Example · javascript
// A JSON endpoint never loads anything, so it can say so outright.
// This matters when a browser opens the URL directly, or when a response
// is mislabelled and the browser tries to render it as a document.
res.writeHead(200, {
  'Content-Type': 'application/json; charset=utf-8',
  'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'",
  'X-Content-Type-Options': 'nosniff',
  'Cache-Control': 'no-store'          // keep per-user data out of caches
});
res.end(JSON.stringify({ balance: 1240 }));

Discussion

  • Be the first to comment on this lesson.