Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Web Security (OWASP Top 10:2025)  Module 3 – A02:2025 Security Misconfiguration

HTTP security headers: a baseline that holds

Set HSTS, nosniff, Referrer-Policy, frame-ancestors, COOP, CORP and no-store on every kind of response, retire harmful old headers and test the result.

  • Beginner
  • 20 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Set the baseline response headers and explain each one, HSTS, nosniff, Referrer-Policy, frame-ancestors, COOP, CORP and no-store
  • State what each baseline header does not protect against
  • Remove obsolete and harmful headers, an enabled X-XSS-Protection, Expect-CT, Public-Key-Pins and version banners
  • Plan an HSTS rollout in stages and decide whether to preload
  • Test the headers on every kind of response, errors, redirects and downloads included

Before you start

What you’ll learn · Before you start

What you will learn

  • Set the baseline response headers and explain each one, HSTS, nosniff, Referrer-Policy, frame-ancestors, COOP, CORP and no-store
  • State what each baseline header does not protect against
  • Remove obsolete and harmful headers, an enabled X-XSS-Protection, Expect-CT, Public-Key-Pins and version banners
  • Plan an HSTS rollout in stages and decide whether to preload
  • Test the headers on every kind of response, errors, redirects and downloads included

Before you start

On this page

A config that sends the security headers with every page can still drop them on 4 of the 6 kinds of response this lesson tests: the 404 page, the 500 page and both answers from the downloads folder. Headers are instructions that a browser follows on its user’s behalf, so a response without them is one the browser was never asked to protect.

You will set a baseline of seven headers and say what each one does not stop, audit ten responses for missing, weak and obsolete headers, roll out HSTS: HTTP Strict Transport Security: a response header that tells a browser to reach a host only over HTTPS for max-age seconds and to refuse certificate errors there without a click-through. Browsers ignore it when it arrives over plain HTTP. in stages, and finish with a test that checks every kind of response, errors and downloads included.

Headers ask the browser to protect its user

A response header such as X-Content-Type-Options: nosniff changes nothing on the server. It is a request to the browser that receives it: treat this response this way, from now on. Follow one visit to a shop that sends the baseline, and watch what each header changes in the browser:

Interactive · message by message One visit with the baseline headers Step through a first visit to shop.example, then a page on another site that frames it, and watch what each header changes in the browser.
One visit with the baseline headers, step 8 of 8. Messages so far: Browser to shop.example: GET http://shop.example/; shop.example to Browser: 301 to https://; shop.example to Browser: 200 and the baseline; other.example to Browser: a page with a frame; Browser to shop.example: GET /account in a frame; Browser to other.example: Referer: origin only.other.exampleanother siteBrowserHSTS: shop.example, 1 yearshop.examplesends the baselineGET http://shop.example/301 to https://200 and the baselinea page with a frameGET /account in a frameReferer: origin onlyheaders bind browsers only

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. The first visit. The user types shop.example. The browser has no HSTS record for it yet, so the very first request goes out over plain HTTP, where anyone on the network can read or change it.
  2. Redirect to HTTPS. The server answers with a permanent redirect to HTTPS. Browsers ignore HSTS sent over plain HTTP, so this answer carries none.
  3. The baseline arrives. Over HTTPS the page arrives with the baseline. HSTS for a year, nosniff, a referrer policy, frame-ancestors 'none' and an opener policy each change what the browser will do next.
  4. The browser remembers. For a year the browser turns every http link to shop.example into https before it sends anything, and it refuses certificate errors without offering a way to click through.
  5. Another site frames it. The user visits other.example, whose page puts shop.example's account page in a frame.
  6. The frame stays empty. The browser loads the account page into the frame, reads frame-ancestors 'none' and refuses to render it. Nothing of the account page appears on the other site.
  7. A link out. Back on shop.example, the user follows a link from an order page to another site. The referrer policy sends only https://shop.example/ to it, never the path with the order number.
  8. What it does not stop. Every step above happened inside the browser, on the user's behalf. A script that sends requests to shop.example directly reads no header and obeys none, so every server-side check still has to hold.

Every protection in the figure happened inside the browser, and the last step is the one to remember. A script, a mobile app’s HTTP client or another server talking to yours reads no header and obeys none, as the lesson on origins showed for requests in general. OWASP’s cheat sheet makes the same point the other way round: most of these headers matter only for responses a browser renders or loads, so a JSON API needs fewer of them than a page. Security headers are one layer, the one that protects your users in their browsers; every check that decides what a request may do still runs on the server.

The baseline: seven headers and what each leaves open

Here is the baseline this lesson uses, with the ASVS 5.0.0 requirement behind each header. What each one leaves open matters as much as what it does, because a header that is set gets trusted:

  • Strict-Transport-Security: max-age=31536000; includeSubDomains (3.4.1). The browser reaches the host and its subdomains only over HTTPS for a year, and refuses certificate errors without a click-through. Leaves open: the very first visit, before any header was seen.
  • X-Content-Type-Options: nosniff (3.4.4). The browser trusts the declared Content-Type instead of MIME sniffing: A browser guessing a response's type from its bytes instead of trusting its Content-Type. X-Content-Type-Options: nosniff switches it off, so a file declared as text/plain is never run as HTML., and refuses a script or a stylesheet served with the wrong one. Leaves open: a response that really is HTML.
  • Referrer-Policy: strict-origin-when-cross-origin (3.4.5). Other sites get only your origin, never the path or the query. It is the browser default too, and OWASP still advises sending it. Leaves open: secrets in your own URLs, which stay in your logs.
  • Content-Security-Policy: frame-ancestors 'none'; object-src 'none'; base-uri 'none' (3.4.3, 3.4.6). The page is refused in any frame, loads no plugins and ignores a planted base tag. Leaves open: injected script, which needs script-src, the subject of the CSP lesson.
  • Cross-Origin-Opener-Policy: same-origin (3.4.8). The link between the page and windows of other origins that it opens, or that open it, is cut. Leaves open: requests to your server.
  • Cross-Origin-Resource-Policy: same-site (3.5.8). The browser refuses to hand the response to another site’s img or script tag. Leaves open: pages of your own site, and clients outside browsers.
  • Cache-Control: no-store on personal data (14.3.2). The browser keeps no copy in its cache. Leaves open: caches on your own servers, which 14.2.2 covers.

Two values need care. same-origin for COOP breaks a sign-in or payment pop-up that must keep a handle on your page; same-origin-allow-popups exists for that case. And no single header list fits every response: the audit below asks pages for framing and opener rules, APIs with personal data for no-store, downloads for Content-Disposition: attachment, and everything for HSTS and nosniff. It runs on ten responses:

Ten responses against the baseline JavaScript · audit/headers_audit.mjs
// auditHeaders(kind, headers) applies this lesson's baseline to one response and returns what is missing, weak,
// obsolete or leaking, each with the ASVS 5.0.0 requirement or the guide behind it. The baseline depends on the kind
// of response: every response gets HSTS and nosniff, a page that renders gets the framing and opener rules, a
// response with personal data gets no-store, and a download is sent as an attachment.

function auditHeaders(kind, raw) {
  const h = Object.fromEntries(Object.entries(raw).map(([k, v]) => [k.toLowerCase(), String(v)]));
  const found = [];
  const add = (type, header, why, ref) => found.push({ type, header, why, ref });

  const hsts = h['strict-transport-security'];
  const maxAge = Number(/max-age=(\d+)/i.exec(hsts ?? '')?.[1] ?? 0);
  if (!hsts) add('missing', 'Strict-Transport-Security', 'not sent', 'ASVS 3.4.1');
  else if (maxAge < 31536000) add('weak', 'Strict-Transport-Security', `max-age ${maxAge} is under a year`, 'ASVS 3.4.1');
  if (h['x-content-type-options'] !== 'nosniff') add('missing', 'X-Content-Type-Options', 'nosniff not set', 'ASVS 3.4.4');

  if (kind === 'page') {
    if (!/frame-ancestors/i.test(h['content-security-policy'] ?? '')) add('missing', 'CSP frame-ancestors', 'any site may frame it', 'ASVS 3.4.6');
    if (!h['cross-origin-opener-policy']) add('missing', 'Cross-Origin-Opener-Policy', 'not sent', 'ASVS 3.4.8');
    if (/unsafe-url|no-referrer-when-downgrade/i.test(h['referrer-policy'] ?? '')) add('weak', 'Referrer-Policy', 'full URLs go to other sites', 'ASVS 3.4.5');
  }
  if (/allow-from/i.test(h['x-frame-options'] ?? '')) add('weak', 'X-Frame-Options', 'ALLOW-FROM is ignored', 'ASVS 3.4.6');
  if (kind === 'private' && !/no-store/i.test(h['cache-control'] ?? '')) add('missing', 'Cache-Control', 'personal data may be cached', 'ASVS 14.3.2');
  if (kind === 'download' && !/^attachment/i.test(h['content-disposition'] ?? '')) add('missing', 'Content-Disposition', 'opens inline, not as a file', 'ASVS 3.2.1');

  if (h['x-xss-protection'] !== undefined && h['x-xss-protection'].trim() !== '0') add('obsolete', 'X-XSS-Protection', 'set 0 or leave it out', 'OWASP');
  if (h['expect-ct'] !== undefined) add('obsolete', 'Expect-CT', 'remove it', 'OWASP');
  if (h['public-key-pins'] !== undefined) add('obsolete', 'Public-Key-Pins', 'remove it', 'OWASP');
  if (/\d/.test(h.server ?? '')) add('leak', 'Server', `names a version: ${h.server}`, 'ASVS 13.4.6');
  if (h['x-powered-by'] !== undefined) add('leak', 'X-Powered-By', `names the stack: ${h['x-powered-by']}`, 'ASVS 13.4.6');
  return found;
}

const BASE = { 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', 'X-Content-Type-Options': 'nosniff' };
const PAGE = { ...BASE, 'Content-Security-Policy': "frame-ancestors 'none'; object-src 'none'; base-uri 'none'", 'Cross-Origin-Opener-Policy': 'same-origin', 'Referrer-Policy': 'strict-origin-when-cross-origin' };

const responses = [
  ['shop page, as installed', 'page', { Server: 'nginx/1.27.4', 'X-Powered-By': 'Express' }],
  ['shop page, baseline', 'page', PAGE],
  ['login page, old habits', 'page', { ...PAGE, 'Strict-Transport-Security': 'max-age=300', 'X-XSS-Protection': '1; mode=block', 'Expect-CT': 'max-age=86400, enforce' }],
  ['partner page', 'page', { ...BASE, 'X-Frame-Options': 'ALLOW-FROM https://partner.example', 'Cross-Origin-Opener-Policy': 'same-origin' }],
  ['404 page', 'page', {}],
  ['account API, personal data', 'private', { ...BASE, 'Cache-Control': 'max-age=60' }],
  ['account API, fixed', 'private', { ...BASE, 'Cache-Control': 'no-store' }],
  ['product API, public data', 'public', { ...BASE, 'Cache-Control': 'public, max-age=300' }],
  ['invoice download', 'download', { ...BASE, 'Content-Type': 'application/pdf' }],
  ['invoice download, fixed', 'download', { ...BASE, 'Content-Type': 'application/pdf', 'Content-Disposition': 'attachment; filename="invoice-77.pdf"' }],
];

let total = 0;
for (const [name, kind, headers] of responses) {
  const found = auditHeaders(kind, headers);
  total += found.length;
  console.log(`${name} (${kind}): ${found.length ? `${found.length} finding${found.length === 1 ? '' : 's'}` : 'clean'}`);
  for (const f of found) console.log(`  ${f.type.padEnd(9)}${f.header.padEnd(28)}${f.why} (${f.ref})`);
}
console.log(`${total} findings in ${responses.length} responses`);

Output

shop page, as installed (page): 6 findings
  missing  Strict-Transport-Security   not sent (ASVS 3.4.1)
  missing  X-Content-Type-Options      nosniff not set (ASVS 3.4.4)
  missing  CSP frame-ancestors         any site may frame it (ASVS 3.4.6)
  missing  Cross-Origin-Opener-Policy  not sent (ASVS 3.4.8)
  leak     Server                      names a version: nginx/1.27.4 (ASVS 13.4.6)
  leak     X-Powered-By                names the stack: Express (ASVS 13.4.6)
shop page, baseline (page): clean
login page, old habits (page): 3 findings
  weak     Strict-Transport-Security   max-age 300 is under a year (ASVS 3.4.1)
  obsolete X-XSS-Protection            set 0 or leave it out (OWASP)
  obsolete Expect-CT                   remove it (OWASP)
partner page (page): 2 findings
  missing  CSP frame-ancestors         any site may frame it (ASVS 3.4.6)
  weak     X-Frame-Options             ALLOW-FROM is ignored (ASVS 3.4.6)
404 page (page): 4 findings
  missing  Strict-Transport-Security   not sent (ASVS 3.4.1)
  missing  X-Content-Type-Options      nosniff not set (ASVS 3.4.4)
  missing  CSP frame-ancestors         any site may frame it (ASVS 3.4.6)
  missing  Cross-Origin-Opener-Policy  not sent (ASVS 3.4.8)
account API, personal data (private): 1 finding
  missing  Cache-Control               personal data may be cached (ASVS 14.3.2)
account API, fixed (private): clean
product API, public data (public): clean
invoice download (download): 1 finding
  missing  Content-Disposition         opens inline, not as a file (ASVS 3.2.1)
invoice download, fixed (download): clean
17 findings in 10 responses

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node headers_audit.mjs

The page as installed had six findings, four missing headers and two that name the software (nginx 1.27.4 and Express), while the same page with the baseline had none. The 404 page lost everything, which the last section of this lesson explains. The account API was missing only no-store, and the public product API needed none: caching public data is fine. Even version banner: A header or a page that names the software and the version a server runs, such as Server: nginx/1.27.4 or X-Powered-By: Express. It saves anyone looking for a known flaw the work of finding out. appear in the audit, because ASVS 13.4.6 asks for them to go.

Key idea

Each header is an instruction to the browser with a limit of its own: set the baseline by kind of response, and know what each header leaves to the server.

Checkpoint

Check: Headers instruct the browser, not the server · The baseline headers and their limits

  1. Question 1 of 3 A script on someone's laptop sends requests to your API in a loop. Which of your security headers changes what the script can do?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: None of them, because headers are instructions to browsers and the script obeys none

    Security headers protect users in browsers. Anything that is not a browser ignores them, so every decision about what a request may do still has to be made on the server.

  2. Question 2 of 3 A user uploads notes.txt, which contains HTML markup, and your server returns it with Content-Type text/plain and X-Content-Type-Options nosniff. How does a browser show it?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: As plain text, because with nosniff the browser trusts the declared type and never runs it as HTML

    With nosniff the browser uses the declared Content-Type as it is, which is why a declared text/plain upload can never run as an HTML page. It also means every response must declare its type correctly.

  3. Question 3 of 3 The audit flagged the account API, which returns personal data with Cache-Control max-age=60, but not the public product API with max-age=300. Why the difference?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: Personal data must not stay in the browser's cache (ASVS 14.3.2), while caching public data is fine

    The baseline depends on the kind of response. A response with personal data gets no-store, so no copy stays on a shared or lost device; public data may be cached for as long as it stays correct.

HSTS is a promise you ramp up, not a switch

HSTS is the header with the longest memory. Once a browser has seen Strict-Transport-Security over HTTPS, it reaches the host only over HTTPS until max-age runs out, and it refuses certificate errors there without offering a way to click through. That memory is the point and the risk. MDN notes that browsers ignore the header over plain HTTP, so the first visit stays exposed until a secure response sets it, and that the only way to switch it off is max-age=0, sent over HTTPS to each browser that remembers. Set a year with includeSubDomains on a domain with one subdomain that still needs plain HTTP, and its users can be locked out of that subdomain for up to a year.

So roll it out the way hstspreload.org describes: check every subdomain first, then raise max-age in stages, each lasting its full value before the next. Walk through them:

Timeline · 6 events in order Rolling out HSTS in stages Walk through the stages in order. Each one must last its full max-age before the next, because that is how long browsers remember it.
  1. Before stage 1

    Every subdomain on HTTPS check first

    includeSubDomains covers every subdomain, internal ones and www included. Before the first header goes out, check that each one works over HTTPS, or the header will lock users out of the ones that do not.

  2. 5 minutes

    max-age=300; includeSubDomains

    Send the header on every HTTPS response, errors and redirects included. A mistake now costs five minutes. Watch for broken pages and fix them before you move on.

  3. 1 week

    max-age=604800; includeSubDomains

    One week of every browser that visits remembering the policy. Keep watching traffic and errors, and wait the full week before the next stage.

  4. 1 month

    max-age=2592000; includeSubDomains

    A month, waited in full. Anything still served over plain HTTP under the domain shows up now, and a mistake at this stage lasts at most a month in any browser.

  5. 1 year

    max-age=31536000; includeSubDomains goal

    The value ASVS 5.0.0 requirement 3.4.1 asks for at level 1, at least one year on every response, and from level 2 with includeSubDomains. Raising max-age is the end of the rollout.

  6. Preload, or not

    A decision, not a stage optional

    The preload list needs max-age of at least 31536000, includeSubDomains, preload and a redirect to HTTPS on the same host. Its maintainers now recommend HSTS but not preloading, and removal reaches users only through browser updates, over months.

The timeline has 6 events, from Before stage 1 to Preload, or not.

The end of the rollout is one year with includeSubDomains, the value ASVS 5.0.0 requirement 3.4.1 asks for on every response. Preloading is a separate decision. The HSTS preload list: A list of domains built into browsers that are reached only over HTTPS, even on the first visit. Joining needs a year of max-age, includeSubDomains and preload, and leaving it reaches users only through browser updates. is built into browsers, so it protects even the first visit, but its maintainers now recommend HSTS and do not recommend preloading: current browsers already upgrade many plain HTTP visits by themselves, and getting off the list reaches users only through browser updates, over months.

A year on day one

Sending max-age=63072000; includeSubDomains; preload on the first day because a cheat sheet shows it skips every stage at once. Start at max-age=300, wait out each stage, and add preload only after you have decided to preload, never by default.

Checkpoint

Check: Rolling out HSTS

  1. Question 1 of 2 A site sends Strict-Transport-Security on its plain HTTP responses and never over HTTPS. What do browsers do with it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: They ignore it, so no HSTS policy is ever stored

    HSTS counts only when it arrives over HTTPS. Send it on every HTTPS response, redirects and errors included, and redirect plain HTTP to HTTPS.

  2. Question 2 of 2 ASVS 5.0.0 requirement 3.4.1 asks for an HSTS max-age of at least one year. How many seconds is that?

    Type a number.

    A hint

    365 days of 24 hours of 3,600 seconds.

    Show the answer: Question 2 of 2

    Answer: 31536000 seconds

    365 × 24 × 3,600 = 31,536,000 seconds, the value of max-age at the last stage of the rollout and the minimum for the preload list.

Old headers that now do harm or nothing

Some headers that still appear in templates and old answers have outlived their purpose, and one of them can do harm:

Header Where it stands What to do
X-XSS-Protection Deprecated and never standard. MDN warns that the filter it controls can create XSS in otherwise safe pages Send 0, or leave it out, and rely on CSP
Expect-CT Deprecated: only Chromium-based browsers implemented it, and they now enforce Certificate Transparency by default Remove it
Public-Key-Pins Obsolete: no current browser supports it, and Chromium removed it in 2018 Remove it
X-Frame-Options: ALLOW-FROM Browsers ignore the whole header with this value, which leaves the page frameable Use frame-ancestors with the origin
Server with a version, X-Powered-By Version banners, a ready map for anyone matching flaws Send a value without a version, and drop X-Powered-By

The login page with old habits in the audit carried two of them, X-XSS-Protection: 1; mode=block and Expect-CT, next to a five-minute HSTS that someone never raised. Defaults from libraries help but do not finish the job. Helmet, the middleware that Express’s security guide recommends, sends X-XSS-Protection: 0, removes X-Powered-By and sets most of this baseline, yet its default CSP allows 'unsafe-inline' for styles. A default is a starting point for your own policy. Mind the browser support of newer headers as well: MDN’s compatibility data lists Permissions-Policy in Chromium-based browsers only, so it adds a layer there and nothing elsewhere.

Checkpoint

Check: Obsolete and harmful headers

  1. Question 1 of 2 What should X-XSS-Protection be set to today?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 0, or leave it out, and rely on a Content-Security-Policy

    X-XSS-Protection was never standard and is deprecated. Send 0 or leave it out; a CSP that blocks inline script does the job the filter tried to do.

  2. Question 2 of 2 A page sends X-Frame-Options ALLOW-FROM https://partner.example and no Content-Security-Policy. Who can frame it in a current browser?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Any site, because current browsers ignore the whole header with this value

    MDN calls ALLOW-FROM obsolete and says modern browsers ignore the header completely when they meet it. Name the partner in frame-ancestors instead.

Every kind of response needs the headers

The audit’s 404 page had no headers at all, and that is a configuration problem with a precise cause in a common web server. nginx documents two rules of add_header. Without the always parameter, a header is added only to responses with the status 200, 201, 204, 206, 301, 302, 303, 304, 307 or 308, so error pages go without. And a location block with an add_header of its own inherits none of the server block’s, unless add_header_inherit merge (nginx 1.29.3 and later) says otherwise. Here is a config that falls into both, and its fix:

weak.conf: the baseline once, without always CONF · nginx/weak.conf
# The weak config the matrix models: the baseline once, at the server level, without "always".
server {
    listen 443 ssl;
    server_name shop.example;
    # The certificate and upstream lines are left out.

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
    add_header X-Content-Type-Options "nosniff";
    add_header Content-Security-Policy "frame-ancestors 'none'; object-src 'none'; base-uri 'none'";
    add_header Referrer-Policy "strict-origin-when-cross-origin";

    location /downloads/ {
        # A header of its own: this block now inherits none of the four above.
        add_header Content-Disposition "attachment";
    }
}
fixed.conf: always on every line, inheritance merged CONF · nginx/fixed.conf
# The fixed config the matrix models: "always" on every line, and the server's lines merged into each location
# (add_header_inherit needs nginx 1.29.3 or later; on older versions repeat the lines in the location block).
server {
    listen 443 ssl;
    server_name shop.example;
    # The certificate and upstream lines are left out.
    add_header_inherit merge;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Content-Security-Policy "frame-ancestors 'none'; object-src 'none'; base-uri 'none'" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    location /downloads/ {
        add_header Content-Disposition "attachment" always;
    }
}

This program models those two rules and tests six kinds of response under each config, the matrix a scanner of the home page never sees:

Six kinds of response under two configs JavaScript · matrix/header_matrix.mjs
// A model of two rules from nginx's documentation of add_header, used to test which kinds of response get the
// baseline. 1. Without the parameter "always", a header is added only to responses with the status 200, 201, 204, 206,
// 301, 302, 303, 304, 307 or 308. 2. A location block with add_header lines of its own inherits none from the server
// block, unless add_header_inherit merge (nginx 1.29.3 and later) says otherwise.

const PLAIN_STATUSES = [200, 201, 204, 206, 301, 302, 303, 304, 307, 308];
const BASELINE = ['Strict-Transport-Security', 'X-Content-Type-Options', 'Content-Security-Policy', 'Referrer-Policy'];
const SHORT = { 'Strict-Transport-Security': 'HSTS', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': 'CSP', 'Referrer-Policy': 'Referrer' };

function headersFor(config, location, status) {
  const own = config.locations[location] ?? [];
  let lines = own.length ? own : config.server;
  if (own.length && config.inherit === 'merge') lines = [...config.server, ...own];
  return lines.filter((line) => line.always || PLAIN_STATUSES.includes(status)).map((line) => line.name);
}

const add = (always) => (name) => ({ name, always });
const weak = {
  server: BASELINE.map(add(false)),
  locations: { '/downloads/': [add(false)('Content-Disposition')] },
};
const fixed = {
  inherit: 'merge',
  server: BASELINE.map(add(true)),
  locations: { '/downloads/': [add(true)('Content-Disposition')] },
};

const kinds = [
  ['200 page /', '/', 200],
  ['301 redirect /old-page', '/', 301],
  ['404 not found', '/', 404],
  ['500 error /checkout', '/', 500],
  ['200 /downloads/77.pdf', '/downloads/', 200],
  ['404 /downloads/78.pdf', '/downloads/', 404],
];

// Why a header is missing: the location's own add_header lines, or a status that needs "always".
function reason(config, location, status) {
  const own = config.locations[location] ?? [];
  if (own.length && config.inherit !== 'merge') return 'own add_header in location';
  return `${status} needs always`;
}

const verdict = (config, location, status) => {
  const sent = headersFor(config, location, status);
  const missing = BASELINE.filter((name) => !sent.includes(name));
  const got = `${BASELINE.length - missing.length} of ${BASELINE.length}`;
  return { ok: missing.length === 0, text: missing.length ? `${got}: ${reason(config, location, status)}` : got };
};

console.log(`Baseline: ${BASELINE.map((name) => SHORT[name]).join(', ')}`);
console.log(`${'response'.padEnd(24)}${'weak config'.padEnd(36)}fixed config`);
const gaps = { weak: 0, fixed: 0 };
for (const [label, location, status] of kinds) {
  const a = verdict(weak, location, status);
  const b = verdict(fixed, location, status);
  gaps.weak += !a.ok;
  gaps.fixed += !b.ok;
  console.log(`${label.padEnd(24)}${a.text.padEnd(36)}${b.text}`);
}
console.log(`Matrix test: weak ${gaps.weak ? `FAIL, ${gaps.weak} of ${kinds.length} kinds lack the baseline` : 'pass'}; fixed ${gaps.fixed ? 'FAIL' : 'pass'}`);

Output

Baseline: HSTS, nosniff, CSP, Referrer
response                weak config                         fixed config
200 page /              4 of 4                              4 of 4
301 redirect /old-page  4 of 4                              4 of 4
404 not found           0 of 4: 404 needs always            4 of 4
500 error /checkout     0 of 4: 500 needs always            4 of 4
200 /downloads/77.pdf   0 of 4: own add_header in location  4 of 4
404 /downloads/78.pdf   0 of 4: own add_header in location  4 of 4
Matrix test: weak FAIL, 4 of 6 kinds lack the baseline; fixed pass

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node header_matrix.mjs

Under the weak config the page and the redirect got all four headers, and four kinds got none: the 404 and the 500, because their statuses need always, and both downloads, because the location block added Content-Disposition and so dropped the rest. The fixed config gave every kind all four. Error pages and downloads are exactly the responses someone else can make your users load, so they need the baseline most.

One word, three kinds

Press Edit, change add(true) to add(false) in the fixed config’s server line and run it. The 404, the 500 and the 404 from the downloads folder lose all four headers again; the page, the redirect and the 200 download keep them.

Set the headers in one place, at the edge or in one middleware that wraps every response, and test the matrix: one request per kind of response, against each environment you own, after every deploy.

Key idea

A header that is missing from an error page or a download protects nobody there: set the baseline once, for every status and every location, and test one response of each kind.

Checkpoint

Check: Headers on every kind of response

  1. Question 1 of 2 nginx has add_header X-Content-Type-Options nosniff at the server level, without always. Which response goes out without it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A 500 error page

    Without always, nginx adds the header only for 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308. Error pages such as 404 and 500 go without, as the matrix showed.

  2. Question 2 of 2 The server block has the four baseline headers, each with always. A location /api/ block adds add_header Cache-Control no-store always, and nothing else. What do responses under /api/ get?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Only Cache-Control, because a location with an add_header of its own inherits none of the server's

    nginx inherits add_header lines from the level above only when the current level has none. Merge them with add_header_inherit merge (1.29.3 and later), or repeat them, and test a response from every location.

Practise: headers for three kinds of response

Write the headers of an HTML page, a JSON API with personal data and a download of a user’s file. The sample tests check each kind against the lesson’s baseline, the obsolete headers included:

Exercise · Easy · JavaScript, Python

Security headers for a page, an API and a download

Write securityHeaders(kind) in headers.mjs (Python: security_headers(kind) in headers.py). It returns the security headers of one kind of response as an object (a dict in Python), header name to value:

  • **'page'**, an HTML page of the app: HSTS for at least a year with includeSubDomains, nosniff, a Content-Security-Policy whose frame-ancestors is 'none' or 'self', Cross-Origin-Opener-Policy: same-origin and a referrer policy that sends other sites the origin at most.
  • **'api'**, JSON with the signed-in user's data: HSTS and nosniff as above, Cache-Control with no-store, and Cross-Origin-Resource-Policy of same-origin or same-site.
  • **'download'**, a file a user uploaded, sent back to them: everything the API gets, and Content-Disposition starting with attachment.

No kind sends X-Powered-By, a Server value with a version, Expect-CT, Public-Key-Pins or an X-XSS-Protection other than 0. Any other kind is an error: throw (Python: raise ValueError).

JavaScript · Starter code · headers.mjs

/**
 * The security headers of one kind of response: 'page' (an HTML page of the app), 'api' (JSON with the signed-in
 * user's data) or 'download' (a file a user uploaded, sent back to them). Any other kind throws.
 */
export function securityHeaders(kind) {
  // Starter: the same single header for every kind.
  return { 'X-Content-Type-Options': 'nosniff' };
}
The sample tests · headers.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { securityHeaders } from './headers.mjs';

// The lesson's baseline as a check: the names of the rules a response breaks.
function problems(kind, headers) {
  const h = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), String(v)]));
  const out = [];
  const hsts = h['strict-transport-security'] ?? '';
  if (Number(/max-age=(\d+)/.exec(hsts)?.[1] ?? 0) < 31536000 || !/includesubdomains/i.test(hsts)) out.push('hsts');
  if (h['x-content-type-options'] !== 'nosniff') out.push('nosniff');
  if ('x-powered-by' in h || /\d/.test(h.server ?? '')) out.push('version');
  if ((h['x-xss-protection'] ?? '0') !== '0' || 'expect-ct' in h || 'public-key-pins' in h) out.push('obsolete');
  if (kind === 'page') {
    if (!/frame-ancestors\s+'(none|self)'/.test(h['content-security-policy'] ?? '')) out.push('frame-ancestors');
    if (h['cross-origin-opener-policy'] !== 'same-origin') out.push('coop');
    if (!['strict-origin-when-cross-origin', 'strict-origin', 'same-origin', 'no-referrer'].includes(h['referrer-policy'])) out.push('referrer');
  } else {
    if (!/no-store/.test(h['cache-control'] ?? '')) out.push('no-store');
    if (!['same-origin', 'same-site'].includes(h['cross-origin-resource-policy'])) out.push('corp');
  }
  if (kind === 'download' && !/^attachment/.test(h['content-disposition'] ?? '')) out.push('attachment');
  return out;
}

test('a page refuses framing, isolates its window and keeps its URLs to itself', () => {
  assert.deepEqual(problems('page', securityHeaders('page')), []);
});

test('the API is never cached and is loaded only by its own site', () => {
  assert.deepEqual(problems('api', securityHeaders('api')), []);
  assert.ok(!('content-disposition' in Object.fromEntries(Object.keys(securityHeaders('api')).map((k) => [k.toLowerCase(), 1]))), 'an API answer is not a file to save');
});

test('a download is saved as a file and never cached', () => {
  assert.deepEqual(problems('download', securityHeaders('download')), []);
});

test('an unknown kind is an error, not a guess', () => {
  assert.throws(() => securityHeaders('video'));
});

Python · Starter code · headers.py

def security_headers(kind):
    """The security headers of one kind of response: 'page' (an HTML page of the app), 'api' (JSON with the
    signed-in user's data) or 'download' (a file a user uploaded, sent back to them). Any other kind raises
    ValueError."""
    # Starter: the same single header for every kind.
    return {"X-Content-Type-Options": "nosniff"}
The sample tests · test_headers.py
import re

from headers import security_headers


def problems(kind, headers):
    """The lesson's baseline as a check: the names of the rules a response breaks."""
    h = {k.lower(): str(v) for k, v in headers.items()}
    out = []
    hsts = h.get("strict-transport-security", "")
    age = re.search(r"max-age=(\d+)", hsts)
    if (int(age.group(1)) if age else 0) < 31536000 or "includesubdomains" not in hsts.lower():
        out.append("hsts")
    if h.get("x-content-type-options") != "nosniff":
        out.append("nosniff")
    if "x-powered-by" in h or re.search(r"\d", h.get("server", "")):
        out.append("version")
    if h.get("x-xss-protection", "0") != "0" or "expect-ct" in h or "public-key-pins" in h:
        out.append("obsolete")
    if kind == "page":
        if not re.search(r"frame-ancestors\s+'(none|self)'", h.get("content-security-policy", "")):
            out.append("frame-ancestors")
        if h.get("cross-origin-opener-policy") != "same-origin":
            out.append("coop")
        if h.get("referrer-policy") not in ("strict-origin-when-cross-origin", "strict-origin", "same-origin", "no-referrer"):
            out.append("referrer")
    else:
        if "no-store" not in h.get("cache-control", ""):
            out.append("no-store")
        if h.get("cross-origin-resource-policy") not in ("same-origin", "same-site"):
            out.append("corp")
    if kind == "download" and not h.get("content-disposition", "").startswith("attachment"):
        out.append("attachment")
    return out


def test_a_page_refuses_framing_isolates_its_window_and_keeps_its_urls_to_itself():
    assert problems("page", security_headers("page")) == []


def test_the_api_is_never_cached_and_is_loaded_only_by_its_own_site():
    assert problems("api", security_headers("api")) == []
    assert "content-disposition" not in {k.lower() for k in security_headers("api")}, "an API answer is not a file to save"


def test_a_download_is_saved_as_a_file_and_never_cached():
    assert problems("download", security_headers("download")) == []


def test_an_unknown_kind_is_an_error_not_a_guess():
    try:
        security_headers("video")
    except ValueError:
        return
    raise AssertionError("an unknown kind must raise ValueError")
A hint

Start with the two headers every kind shares, HSTS and nosniff, in one object, and build each kind from a copy of it with its own headers added. The API and the download share three headers more, so the download can start from the API's object and add Content-Disposition.

The sample tests run on this device, in your browser (QuickJS, Pyodide): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: JavaScript (about 0.6 MB) or Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Security Headers Checker Paste the response headers of a page you own, or check its address, and get each baseline header explained, with the fixes. Content Security Policy (CSP) Generator Draft a Content-Security-Policy for your own site, starting from frame-ancestors, object-src and base-uri.

Then the self-check, one question at a time, picked from the header rules you are least sure of:

Practice that adapts to you

Practise this lesson

10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 10 Your mobile app's HTTP client calls the same API as your web page. Which protections of the baseline apply to the app's requests?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: None of the browser protections, so the server's own checks must stand alone

    Headers protect the people using browsers. Every other client gets only what the server checks itself, which is why authentication and authorisation never live in headers.

  2. Question 2 of 10 A teammate removes the server's session check on an image endpoint, because Cross-Origin-Resource-Policy same-site is now set. Is that safe?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: No, because CORP only stops other sites' pages in browsers from loading the image; any other client still gets it

    A header is a layer on top of server-side checks, never a replacement. Keep the session check; CORP then also keeps other sites' pages from embedding the image in a signed-in browser.

  3. Question 3 of 10 Which part of the baseline keeps your page out of other sites' frames?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: frame-ancestors 'none' in the Content-Security-Policy

    frame-ancestors, sent as a header, decides which sites may frame the page; 'none' allows no one. The clickjacking lesson covers it in depth, with X-Frame-Options as the fallback for old browsers.

  4. Question 4 of 10 After adding Cross-Origin-Opener-Policy same-origin, the pop-up of your payment provider can no longer report back to your page through window.opener. What do you change?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: Use same-origin-allow-popups on the page that opens the pop-up

    same-origin cuts the link to every window of another origin. same-origin-allow-popups keeps the isolation but lets the page keep a handle on pop-ups it opens itself, which is what a payment or sign-in flow needs.

  5. Question 5 of 10 Put the stages of an HSTS rollout in order, from first to last.

    Give each item its position, from 1 (first).

    Show the answer: Question 5 of 10

    Answer:

    1. Check that every subdomain works over HTTPS
    2. max-age=300 with includeSubDomains
    3. max-age=604800 with includeSubDomains
    4. max-age=2592000 with includeSubDomains
    5. max-age=31536000 with includeSubDomains

    Check first, then five minutes, a week, a month and a year, each waited out in full, because browsers remember each stage for its whole max-age.

  6. Question 6 of 10 Your rollout is at max-age=604800 with includeSubDomains when you find an internal subdomain that only works over plain HTTP. What happens, and what do you do?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: Browsers that saw the header refuse plain HTTP there for up to a week; move the subdomain to HTTPS, and keep the rollout at this stage until it works

    This is the case the stages are for. A week is recoverable: fix the subdomain, wait the stage out, then go on. To end the policy sooner, send max-age=0 over HTTPS, which only browsers that visit again receive.

  7. Question 7 of 10 Your rollout has reached max-age=31536000 with includeSubDomains on every response. A teammate wants to add preload and submit the domain to the preload list to finish the job. What do you answer?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: Preloading is a separate decision, not the last stage; its maintainers no longer recommend it by default, and leaving the list takes months

    Preloading protects even the first visit, but it is built into browsers and slow to undo. Decide it on purpose, for a domain whose every name will stay on HTTPS for years, and never add preload by default.

  8. Question 8 of 10 Helmet's default headers are on in your Express app. Is its default Content-Security-Policy your final policy?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: No, it is a starting point; its style-src allows 'unsafe-inline', and your app needs its own policy

    Library defaults save work: Helmet sends X-XSS-Protection 0, removes X-Powered-By and sets most of this baseline. Its CSP still has to become your own, starting from what your pages really load.

  9. Question 9 of 10 Which of these responses fail the audit's version-banner rule? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 9 of 10

    Answer:

    • X-Powered-By: Express
    • Server: nginx/1.27.4

    ASVS 5.0.0 requirement 13.4.6 asks that responses do not name the versions of backend components. Send a neutral Server value and no X-Powered-By.

  10. Question 10 of 10 Which word makes nginx's add_header apply to responses of every status?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: always

    add_header name value always; adds the header to every response, error pages included. Without it, only the ten listed statuses get it.

Interview questions

Warm-up: what does Strict-Transport-Security do, and what does it not protect?

It tells the browser to reach the host, and with includeSubDomains every subdomain, only over HTTPS for max-age seconds, upgrading http links itself and refusing certificate errors without a click-through. That stops someone on the network from downgrading the user’s later visits to plain HTTP. It does not protect the first visit, because the browser has not seen the header yet and ignores it over HTTP; the preload list closes that gap, at the cost of a slow removal. It does nothing for clients that are not browsers, and nothing about the application itself: an HTTPS page with an XSS hole is still a page with an XSS hole. Roll it out in stages, because a year of max-age cannot be taken back quickly.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A scanner of your home page reports every security header as present, yet a tester shows your 404 page and a file download without any of them. How can both be true, and how would you test so that it cannot happen again?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to staff

    You are asked to turn on HSTS with includeSubDomains and preload for your company's main domain, which has dozens of subdomains run by different teams. What is your plan, and what could go wrong?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Who obeys a security header, and who ignores it?

    Only the browser that receives the response: the header asks it to treat the response a certain way for its user. A script, a mobile app's HTTP client or another server reads no header and obeys none, so headers add a layer for users in browsers and never replace a server-side check.

  2. Which seven headers make up the lesson's baseline?

    Strict-Transport-Security with max-age=31536000 and includeSubDomains; nosniff in X-Content-Type-Options; a Content-Security-Policy with frame-ancestors 'none', object-src 'none' and base-uri 'none'; strict-origin-when-cross-origin as the Referrer-Policy; same-origin for Cross-Origin-Opener-Policy; same-site for Cross-Origin-Resource-Policy; and Cache-Control: no-store on personal data.

  3. Does one header list fit every response?

    No. Pages need framing and opener rules, APIs with personal data need no-store, downloads need Content-Disposition: attachment, and everything needs HSTS and nosniff. OWASP's cheat sheet notes that most of these headers matter only for responses a browser renders or loads.

  4. How do you roll out HSTS safely?

    Check every subdomain first, then raise max-age in stages starting at 300 seconds, waiting out each stage's full value, up to one year with includeSubDomains (ASVS 5.0.0 3.4.1). Browsers ignore the header over plain HTTP, and only max-age=0 over HTTPS switches it off, so a year on day one can lock users out of a subdomain that still needs HTTP.

  5. What does the preload list add to HSTS, and what is the current advice?

    It is built into browsers, so it protects even the first visit. Its maintainers now recommend HSTS and do not recommend preloading, since current browsers already upgrade many plain visits and getting off the list takes browser updates and months. Add preload only after deciding to preload, never by default.

  6. Which old headers should go, and what should X-XSS-Protection be?

    X-XSS-Protection should be 0 or absent, because its filter can create XSS. Remove Expect-CT and Public-Key-Pins, and do not send X-Frame-Options with ALLOW-FROM: browsers drop the entire header when they see that value, so use frame-ancestors instead. Drop version banners in Server and X-Powered-By too.

  7. Why do error pages and downloads lose headers in nginx, and what is the fix?

    add_header without the always parameter adds a header only when the status is a success (200, 201, 204 or 206), a redirect (301, 302, 303, 307 or 308) or 304, so 404 and 500 pages go without; and a location block with its own add_header inherits none of the server block's, unless add_header_inherit merge (nginx 1.29.3 and later). Put always on every line and test one response of each kind after every deploy.

Key takeaways

  • Security headers are instructions the browser follows for its user; clients outside a browser ignore them, so they add a layer and never replace a server-side check.
  • The baseline depends on the kind of response: HSTS and nosniff everywhere, framing and opener rules on pages, no-store on personal data, attachment on downloads, and no version banners.
  • HSTS is rolled out from five minutes to a year in stages, each waited out in full, and preloading is a separate decision that is slow to undo.
  • X-XSS-Protection other than 0, Expect-CT, Public-Key-Pins and ALLOW-FROM should go, and library defaults such as Helmet’s are a start for your own policy.
  • In nginx, headers without always skip error pages and a location with its own add_header drops the rest, so test one response of every kind on every environment.

Terms in this lesson

HSTS
HTTP Strict Transport Security: a response header that tells a browser to reach a host only over HTTPS for max-age seconds and to refuse certificate errors there without a click-through. Browsers ignore it when it arrives over plain HTTP.
HSTS preload list
A list of domains built into browsers that are reached only over HTTPS, even on the first visit. Joining needs a year of max-age, includeSubDomains and preload, and leaving it reaches users only through browser updates.
MIME sniffing
A browser guessing a response's type from its bytes instead of trusting its Content-Type. X-Content-Type-Options: nosniff switches it off, so a file declared as text/plain is never run as HTML.
version banner
A header or a page that names the software and the version a server runs, such as Server: nginx/1.27.4 or X-Powered-By: Express. It saves anyone looking for a known flaw the work of finding out.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.