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.
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:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- 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.
- Redirect to HTTPS. The server answers with a permanent redirect to HTTPS. Browsers ignore HSTS sent over plain HTTP, so this answer carries none.
- 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.
- 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.
- Another site frames it. The user visits other.example, whose page puts shop.example's account page in a frame.
- 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.
- 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.
- 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 declaredContent-Typeinstead 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 plantedbasetag. Leaves open: injected script, which needsscript-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’simgorscripttag. Leaves open: pages of your own site, and clients outside browsers.Cache-Control: no-storeon 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:
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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
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:
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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
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:
# 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";
}
} # 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:
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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
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 withincludeSubDomains,nosniff, a Content-Security-Policy whoseframe-ancestorsis'none'or'self',Cross-Origin-Opener-Policy: same-originand a referrer policy that sends other sites the origin at most. - **
'api'**, JSON with the signed-in user's data: HSTS andnosniffas above,Cache-Controlwithno-store, andCross-Origin-Resource-Policyofsame-originorsame-site. - **
'download'**, a file a user uploaded, sent back to them: everything the API gets, andContent-Dispositionstarting withattachment.
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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
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.
-
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?
-
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?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
nosniffeverywhere, framing and opener rules on pages,no-storeon personal data,attachmenton 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-Protectionother than0,Expect-CT,Public-Key-PinsandALLOW-FROMshould go, and library defaults such as Helmet’s are a start for your own policy.- In nginx, headers without
alwaysskip error pages and alocationwith its ownadd_headerdrops 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
- A02:2025 Security Misconfiguration (OWASP Foundation)
- HTTP Headers Cheat Sheet (OWASP Foundation)
- Application Security Verification Standard 5.0.0: V3 Web Frontend Security (OWASP Foundation)
- Application Security Verification Standard 5.0.0: V14 Data Protection (OWASP Foundation)
- Strict-Transport-Security header (MDN Web Docs (Mozilla))
- HSTS Preload List Submission (Chromium project (hstspreload.org))
- X-Content-Type-Options header (MDN Web Docs (Mozilla))
- Referrer-Policy header (MDN Web Docs (Mozilla))
- Cross-Origin-Opener-Policy header (MDN Web Docs (Mozilla))
- Cross-Origin-Resource-Policy header (MDN Web Docs (Mozilla))
- X-XSS-Protection header (MDN Web Docs (Mozilla))
- Expect-CT header (MDN Web Docs (Mozilla))
- Module ngx_http_headers_module: add_header and add_header_inherit (nginx)
- Express: production best practices, security (use Helmet) (Express (OpenJS Foundation))
- Helmet (Helmet maintainers)
- MDN browser compatibility data: Permissions-Policy (MDN Web Docs (Mozilla))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress