HTTP Security Headers·19 min read

Complete Guide to HTTP Security Headers

A working developer's reference for the modern HTTP security-header set: Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, Permissions-Policy, Cross-Origin-Opener-Policy + Cross-Origin-Embedder-Policy, and Referrer-Policy — what each does, how to set it, and the realistic ways each one breaks a production site.

by Dowon Oh

Modern web application security is layered. The cheapest layer to ship is the response-header layer. It needs no application-code changes, no library upgrades, and no schema migrations. Every modern security header is just a string the origin or the edge proxy emits on the way out. The browser does the enforcement work for free. That is why a site stuck at a Security Headers grade of F can reach a B in an afternoon and an A in a week. It is also why "fix the headers" is the first ticket I file when I audit a third-party endpoint or inherit a new stack.

This guide is a working-developer's reference for the six headers I expect to see on a hardened production response in 2026. They are Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, Permissions-Policy, the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy pair, and Referrer-Policy. Each section below is structured the same way — what the header does, how to set it, and the realistic ways it breaks a production site. The recommendations are conservative defaults, not maximalist ones. The failure modes come from real audits where I watched a too-aggressive value take a feature offline. If you want to follow along with your own server's responses, the HTTP Header Analyzer parses any URL's response headers in browser with no upload. It is the pattern I use to check website headers without leaving evidence in someone else's logs.

A note on what is not in this guide. X-Content-Type-Options: nosniff is real and you should set it. One line, no downside, no failure modes worth a section. X-XSS-Protection is dead. Modern browsers ignore it, and the OWASP Secure Headers Project now recommends omitting it entirely. Cross-Origin-Resource-Policy shows up alongside COOP and COEP and gets a mention in that section. The six headers below are the ones with non-trivial deployment surface. They are the ones I have seen teams misconfigure most often.

Contents

Content-Security-Policy

Content-Security-Policy — usually written CSP — is the response header that tells the browser which sources the page may load. It covers script, style, image, font, connect, and frame. When the browser sees a request that violates the policy, it drops the resource on the floor and continues rendering. It can also beacon a violation report back to an endpoint you control. CSP is the single biggest lever any client-side application has against cross-site scripting. That is why every modern hardening checklist starts here.

What it does

CSP scopes the origins each resource type may load from. The grammar is directive source-list; directive source-list; .... The directive default-src acts as the fallback for any resource type you do not name. The directives that matter most in practice are script-src (where executable code can come from), style-src (CSS), img-src (images, including the favicon and data: URIs), and connect-src (fetch, WebSocket, EventSource). Three more matter for embedding: frame-src (what your page can embed), frame-ancestors (who can embed your page — the modern replacement for X-Frame-Options), and base-uri (where <base href> can point). The keyword 'self' means same-origin. 'none' refuses every source. An explicit hostname like https://cdn.example.com allows that origin only. The threat model is stored or reflected XSS. If an attacker injects a <script src="..."> tag pointing at a host the policy does not allow, the browser refuses the load, and the attack is inert.

How to set it

A reasonable starter policy fits on one line, ships as a single header, and is explicit enough to block the common XSS patterns without breaking a typical first-party app:

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'

Here it is in context, the way it shows up when you run curl -I against a hardened production host:

$ curl -I https://example.com
HTTP/2 200
content-type: text/html; charset=utf-8
content-security-policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
cache-control: max-age=600

'unsafe-inline' on style-src is a deliberate compromise — most apps still ship inline CSS for above-the-fold content, and CSS-based exfiltration is a far narrower attack class than script-based XSS. Tighten it to 'self' only when you have audited every inline style="..." attribute. The frame-ancestors 'none' directive doubles as the modern clickjacking defense and supersedes X-Frame-Options on browsers that honor both.

Common pitfalls

The biggest source of CSP rollout failure is inline content that the browser refuses by default. Any literal inline script tag, any style="..." attribute, any onclick="..." handler, and any javascript: URL counts as inline. The browser blocks all of them unless you opt in to 'unsafe-inline'. That keyword essentially turns CSP off for the directive it appears in, because an attacker who can inject HTML can also inject inline blocks. The right migration path is per-request nonces for dynamic inline blocks and SHA-256 hashes for static ones. The wrong path is 'unsafe-inline' indefinitely.

connect-src is the directive that bites silently. WebSocket, EventSource, and fetch calls to a different origin do not crash the page when CSP refuses them. They fail quietly, with a violation written to the console. Whenever a real-time feature stops working after a CSP rollout, connect-src is the first place I look in browser, with the network panel open and the console filtered for "Refused to connect."

For the deep dive on nonces, hashes, 'strict-dynamic', and the Content-Security-Policy-Report-Only migration ramp, see the supporting article What is Content-Security-Policy (CSP)? A Practical Guide. That ramp is what lets you deploy CSP on a real production site without breaking it on day one. W3C CSP Level 3 defines the matching algorithms and is the spec to keep open in a tab. Mozilla MDN documents every directive with browser-version notes.

Strict-Transport-Security

Strict-Transport-Security — HSTS — is the response header that tells a browser, "from now on, only ever talk to me over HTTPS." Without it, every typed http:// URL, every cached link, and every email-pasted bookmark gives an attacker on the same network one chance to intercept the request. The redirect to HTTPS comes too late. With HSTS, the browser refuses to even try the plaintext request. The mechanism is small — one header, three directives. But the policy is sticky in the browser cache. That is why deploying it too aggressively is the most common way I see teams brick a host they cannot remotely roll back.

What it does

HSTS solves the first-visit MITM gap. A user on a hostile network types example.com into the address bar. The browser, having no prior policy, sends an http:// request on port 80. An SSL-stripping proxy on the same access point swaps the inevitable HTTPS redirect for a plaintext page they control. Cookies marked Secure will not be sent on that first request. But session identifiers in URLs, CSRF tokens echoed in forms, and any credentials posted to a forged login page absolutely will. Once the browser has seen a valid HSTS response from an origin, it records a policy for that host. Every subsequent navigation — typed, clicked, or programmatic — is upgraded to HTTPS internally before any packet leaves the device. RFC 6797 defines this mechanism. It explains why the first-visit problem is worth solving even on sites that already redirect every plaintext request.

How to set it

The header is one line and three directives. The recommended hardened production value is:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

In context, against a production endpoint that has finished its deployment ramp:

$ curl -I https://example.com
HTTP/2 200
content-type: text/html; charset=utf-8
strict-transport-security: max-age=31536000; includeSubDomains; preload
content-security-policy: default-src 'self'
x-content-type-options: nosniff
cache-control: max-age=600

max-age is the policy lifetime in seconds — 31536000 is one year, the value the Chromium preload list requires. includeSubDomains extends the policy to every host under the parent, so api.example.com and staging.example.com are also locked to HTTPS. preload is an opt-in token signaling that you intend to submit the host to the preload list at hstspreload.org; it carries no behavioral effect by itself but is the explicit consent flag the submission form requires.

Common pitfalls

Shipping max-age=31536000 on day one is the failure mode I see most. The policy is sticky for the full lifetime. If HTTPS later misconfigures — an expired certificate, a botched redirect, a CDN that drops a Subject Alternative Name — every browser that has visited the host in the last year is locked out for the remainder of the year. There is no override available from your side. The safer cadence is to ramp. Ship max-age=300 first. Watch for a working day. Bump to max-age=86400. Watch for a week. Then commit to a year only when every subdomain and every redirect chain is clean.

includeSubDomains is a separate trap. If you set it on the apex, you have also set it on dev.example.com and internal.example.com. Anyone using a self-signed cert on those leaves now has a hard outage on every modern browser. Audit every subdomain before you turn it on. Preload is one step beyond. Once a host ships in a Chromium release, removal is a separate request to the maintainers. Historically that takes on the order of three months to roll out to most users.

The full deployment ramp, the preload-list submission process, and the cases where HSTS is the wrong call are covered in HSTS Explained: Force HTTPS Without Breaking Anything. Mozilla MDN is the canonical reference for directive semantics and browser support.

X-Frame-Options

X-Frame-Options is the legacy clickjacking defense. It tells a browser whether your page may be loaded inside a <frame>, <iframe>, <embed>, or <object> element on a third-party site. The threat is simple. An attacker frames your authenticated page invisibly. They overlay it with bait UI. They trick a logged-in user into clicking through to an action they did not intend — transfer money, change a password, accept a connection. Without a frame defense, the browser renders your page inside that iframe and the attack works.

What it does

The header has two values that matter on modern browsers. DENY means no site, including yours, may frame this page. SAMEORIGIN means only pages from the same origin may frame it. A third value, ALLOW-FROM uri, was specified in the original draft. It has been deprecated and is unsupported in Chromium and modern Firefox. If you need that behavior, use the CSP frame-ancestors directive instead. It accepts a source list and is the modern, well-supported equivalent. When the browser encounters a frame load that violates the header, it refuses to render the framed content and the iframe shows blank.

How to set it

The conservative default for any page that does not need to be framed is DENY. For sites that need internal embedding — admin consoles framed inside a parent shell, the same product on multiple subdomains — SAMEORIGIN is the right call. The literal header line:

X-Frame-Options: DENY

In context against a production response:

$ curl -I https://example.com
HTTP/2 200
content-type: text/html; charset=utf-8
x-frame-options: DENY
content-security-policy: default-src 'self'; frame-ancestors 'none'
strict-transport-security: max-age=31536000
cache-control: max-age=600

Setting both X-Frame-Options and CSP frame-ancestors is the recommended pattern. Modern browsers honor frame-ancestors and ignore X-Frame-Options when both are present; older browsers fall back to X-Frame-Options. There is no downside to belt-and-suspenders.

Common pitfalls

The first pitfall is using ALLOW-FROM and assuming it works. It does not, on any browser shipping in 2026. Substitute Content-Security-Policy: frame-ancestors 'self' https://parent.example.com and ship both headers if you need legacy support. The second is forgetting that X-Frame-Options is per-page, not per-site. Server-side templating that omits the header on certain routes — a marketing page rendered by a different framework, a legacy admin path — leaves a clickjacking gap exactly where authenticated actions live. Audit response headers across every route, not just the homepage, in browser, with a tool that walks the route table.

The third is over-blocking embed flows you actually want. OAuth consent screens, payment-processor iframes, and embeddable widgets you sell to customers all need framing to work. DENY will break them. The right move is SAMEORIGIN or an explicit frame-ancestors allowlist for the consumer origins. Loosening to "no frame defense at all" is the wrong move. Mozilla MDN documents the directive grammar and the browser-version matrix. The OWASP Secure Headers Project covers the recommended-default decision tree alongside the rest of the security-header set.

Permissions-Policy

Permissions-Policy is the header that controls which browser features your page — and any third-party iframe loaded inside it — is allowed to use. The threat model is feature abuse. A chat widget you embedded last quarter silently turns on the camera. An analytics tag fires the geolocation API on every pageview. An ad iframe tries to access the clipboard. Without a Permissions-Policy header, every feature the browser ships is allowed by default for both your origin and any framed origin. That is the wrong default for a hardened site.

What it does

The header is a structured-fields list mapping feature names to an allowlist. The allowlist syntax is small but strict. () denies the feature for everyone — your origin and any iframe. (self) allows it only for same-origin documents. (self "https://embed.example.com") allows it for same-origin plus an explicit third party. Feature names follow the W3C Permissions Policy registry. They cover camera, microphone, geolocation, payment, USB, midi, accelerometer, gyroscope, and a long tail of more specialized capabilities. The header you ship is the union of "every feature your app actually uses" mapped to its narrowest acceptable allowlist. Everything else gets ().

How to set it

A starter policy that denies the high-risk features by default and only opens what a typical app needs looks like this:

Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()

In context against a hardened response:

$ curl -I https://example.com
HTTP/2 200
content-type: text/html; charset=utf-8
permissions-policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()
content-security-policy: default-src 'self'
strict-transport-security: max-age=31536000
cache-control: max-age=600

If your app legitimately needs one of these features, narrow the allowlist instead of dropping the directive. A site that uses geolocation only on the homepage might ship geolocation=(self). That allows same-origin scripts while still denying any framed third party. A payment flow that embeds a processor's iframe might ship payment=(self "https://checkout.example.com") and nothing else.

Common pitfalls

The first pitfall is feature-name churn. The header was originally specified as Feature-Policy with a different syntax — semicolon-delimited, allowlist-after-feature-name. Browsers shipped both for a transition period. Some still honor Feature-Policy as a fallback. The modern syntax is Permissions-Policy. If you find both in the same response on a legacy stack, drop Feature-Policy and use only Permissions-Policy. The web.dev walkthrough has the migration table.

The second is the wrong allowlist token. () is the deny-everyone form. (*) allows every origin — rarely what you want. (self) is same-origin-only. Quoted hostnames must use double quotes inside the parentheses, not bare URLs. Getting the token wrong silently allows or denies more than you intended. The failure mode is invisible until a third-party widget either stops working or starts working when it should not.

The third is forgetting that some directives are still draft. The registry evolves. Features come and go. A directive your browser does not recognize is silently ignored. A typo or a removed feature name leaves a gap. Run the response through an analyzer (in browser, no upload) to see which directives the parser actually understood. The W3C Permissions Policy spec is the source of truth for the registry. Mozilla MDN maintains a feature-by-feature support matrix.

Cross-Origin-Opener-Policy & Cross-Origin-Embedder-Policy

Cross-Origin-Opener-Policy (COOP) and Cross-Origin-Embedder-Policy (COEP) are the two response headers that, set together, opt your page into cross-origin process isolation. The threat model is the Spectre and Meltdown class of speculative-execution side channels. Those attacks let a malicious page in one process read memory from another process running in the same browser. Cross-origin isolation puts your origin in its own renderer process. It also gates the high-precision timers, SharedArrayBuffer, and performance.measureUserAgentSpecificMemory() that those attacks rely on. If your app needs any of those APIs — most apps that use WebAssembly threading do — you must ship both headers. If it does not, the case for shipping them is defense-in-depth process isolation.

What it does

COOP controls how your page interacts with windows that opened it, or that your page opens. The recommended value is same-origin. That severs the relationship between your page and any cross-origin opener. A malicious site that opens yours in a popup cannot script into the popup or read its location. COEP controls which cross-origin resources your page is allowed to load. The recommended value is require-corp. That means every cross-origin resource — image, script, font, fetch response — must opt in by sending its own Cross-Origin-Resource-Policy: cross-origin header, or a permissive Access-Control-Allow-Origin. When both COOP and COEP are set to their strict values, the browser flips the page's crossOriginIsolated flag to true. That unlocks the gated APIs. web.dev walks through the isolation model and the API gating in detail.

How to set it

The two headers ship together; either one alone is not enough to enable isolation. The literal header lines:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

In context, alongside the rest of a hardened response:

$ curl -I https://example.com
HTTP/2 200
content-type: text/html; charset=utf-8
cross-origin-opener-policy: same-origin
cross-origin-embedder-policy: require-corp
content-security-policy: default-src 'self'
strict-transport-security: max-age=31536000
cache-control: max-age=600

Many production sites also pair these with Cross-Origin-Resource-Policy: same-origin on their own outgoing responses. That prevents other sites from loading your assets cross-origin without an explicit opt-in. CORP is the third header in the family. It is the one that lets a third-party CDN serve your script to a COEP-enabled consumer. Without it, the consumer's browser refuses the load.

Common pitfalls

The deployment trap is third-party embeds. Analytics scripts, ad networks, font CDNs, and chat widgets that do not send Cross-Origin-Resource-Policy: cross-origin will be blocked the moment you ship Cross-Origin-Embedder-Policy: require-corp. The same is true for any third party that does not send a wildcard CORS header. The page renders, the third party silently fails, and the breakage is invisible unless you read the network panel in browser. Audit every cross-origin asset before flipping COEP on. A phased rollout that ships COOP first and COEP behind a feature flag is the cadence I recommend.

The second pitfall is the crossOriginIsolated API gating. Once your page is isolated, any code that relied on shared state with cross-origin openers needs a different communication path. OAuth popup flows that read from window.opener are one example. Federated-identity windows that postMessage back are another. The OAuth popup case is well-documented as a regression source. The workaround is Cross-Origin-Opener-Policy: same-origin-allow-popups if you need softer isolation that still allows popup-to-opener postMessage.

The third is mis-reading the API gain. SharedArrayBuffer and performance.measureUserAgentSpecificMemory() are the headline benefits. Most apps do not use either. If your app does not use WebAssembly threads, does not need high-resolution timers for a profiler, and does not pull cross-origin resources that already serve CORP, the case for COOP+COEP is process isolation as defense in depth. Real, but not urgent. Mozilla MDN documents the COEP semantics and the resource-handling matrix.

Referrer-Policy

Referrer-Policy is the header that tells the browser how much information to include in the Referer request header. It controls what gets sent when your page links out to or loads resources from a different origin. The threat model is information leakage. By default, modern browsers send the full URL of the originating page to the destination. That is fine for a public homepage. It is disastrous for a logged-in dashboard whose URLs encode session tokens, internal paths, customer identifiers, or invitation links. Without an explicit Referrer-Policy header, you are trusting every third-party host your page touches not to log those URLs.

What it does

The header takes a single token from a fixed enum. Four values matter in practice. strict-origin-when-cross-origin is the modern browser default. It sends the full URL on same-origin, the origin only on cross-origin to the same scheme, and nothing on a downgrade from HTTPS to HTTP. same-origin sends the full URL on same-origin and nothing cross-origin. no-referrer always omits the header entirely. origin always sends only the origin, never the path. The browser writes the chosen referrer into outbound Referer request headers and into document.referrer for navigations. W3C Referrer Policy defines the full enum and the algorithm the browser runs to compute the referrer for any given navigation.

How to set it

The conservative default for almost every site is strict-origin-when-cross-origin. It matches what modern browsers do by default. Setting it explicitly still matters, because some older browsers and some non-browser clients use the old default of no-referrer-when-downgrade — full URL on every cross-origin request to HTTPS. The literal header line:

Referrer-Policy: strict-origin-when-cross-origin

In context:

$ curl -I https://example.com
HTTP/2 200
content-type: text/html; charset=utf-8
referrer-policy: strict-origin-when-cross-origin
content-security-policy: default-src 'self'
strict-transport-security: max-age=31536000
cache-control: max-age=600

If your app handles particularly sensitive data — internal admin panels, healthcare, finance — tighten to same-origin (no referrer at all on cross-origin) or no-referrer (no referrer ever). If you run a public content site that depends on referral analytics, strict-origin-when-cross-origin is the right balance. Outbound clicks send the origin so partners can attribute traffic to your site. The path is not sent, so query parameters and internal URLs do not leak.

Common pitfalls

The first pitfall is leaving the header off and assuming the browser default is safe. It is, on Chrome and Firefox shipping in 2026. But the spec history matters. Before 2020 the default was the looser no-referrer-when-downgrade. Any non-browser client — curl, server-to-server fetch, an embedded WebView — may still apply a different default. Setting the header explicitly is the cheap insurance. The OWASP Secure Headers Project recommends an explicit Referrer-Policy on every response.

The second is going too strict and breaking analytics. no-referrer everywhere will zero out the referral source in your analytics tool. It breaks attribution for any partner you depend on for traffic. If strict-origin-when-cross-origin is the default for the site, the right pattern for sensitive routes — a billing dashboard, a password-reset page — is to override per-route. Many web frameworks let you set the header per-response, not just globally.

The third is forgetting that the header only governs the outgoing Referer you send. It does not affect what other sites send to you. That is their policy, not yours. If you are reading document.referrer for analytics or attribution, you are at the mercy of the upstream site's Referrer-Policy. Mozilla MDN documents the full enum and the per-browser default behavior.

Test your own headers

The fastest way to see where your site stands is to run the response through an analyzer that walks the full set in one pass. The HTTP Header Analyzer does exactly that. It parses the response in browser with no upload, so you can audit a production endpoint or a staging URL without leaving evidence in someone else's logs. The tool flags missing headers, weak directive values, and conflicts. It surfaces every header in a readable table so you can see at a glance which ones are still on default values. It is the same loop I use when I audit a third-party endpoint: paste the URL, read the table, file the deltas. Most teams I have audited get from a Security Headers F to a B in an afternoon and to an A in the week that follows. The work itself is small once you know which headers belong on the response and what their conservative values look like.

Sources

Specifications

OWASP

Mozilla MDN

web.dev

Further reading