Security Headers Overview: HSTS, X-Frame-Options, and More
AI generated
OWASP
0x00
Security · HTTP Headers · Hardening · Magento 2
Security Headers Overview
HSTS, X-Frame-Options, CSP frame-ancestors, and more configured right

A valid TLS certificate alone does not protect a Magento store from clickjacking, MIME sniffing, or silent data leaks through the referrer header. This article shows in practice how HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and CSP frame-ancestors work together, how to configure these headers correctly at the webserver or CDN layer instead of in application code, and how scanners like securityheaders.com grade the result.

12 min. read HSTS · CSP frame-ancestors · Permissions-Policy nginx · Apache · Varnish · Cloudflare

1. Why HTTP security headers are essential

A valid TLS certificate encrypts the connection, but it tells the browser nothing about how to handle the response. That's exactly the gap HTTP security headers close: they explicitly instruct the browser to reject insecure connections, refuse to load foreign content inside an iframe, stop guessing content types, and not hand out sensitive browser APIs without asking. Without these headers, a Magento store relies entirely on the browser's permissive defaults, and those defaults are optimized for maximum compatibility, not maximum security.

The consequences of missing headers are concretely measurable: clickjacking attacks embed a checkout page invisibly inside a foreign iframe, MIME sniffing lets an uploaded text file get interpreted as an executable script, and an overly permissive referrer policy leaks cart parameters to third-party analytics. The following sections cover the most important security headers in detail, from HSTS through nosniff and Referrer-Policy to configuring them at the webserver layer instead of in application code.

2. Strict-Transport-Security: syntax, max-age, and the preload list

The Strict-Transport-Security header (HSTS for short) instructs the browser to reach a domain exclusively over HTTPS for a defined period, even if a user explicitly types http:// or follows an unencrypted link. The syntax has three parts: max-age=<seconds> sets the validity duration, includeSubDomains extends the protection to all subdomains, and preload flags the domain for inclusion in the browser vendors' HSTS preload list. A production-ready value is max-age=63072000, which is two years.

Without HSTS, there's always a short window on the very first visit for SSL-stripping attacks, since the browser only learns about the HTTPS requirement after the first response. The preload list closes exactly that gap by hard-coding HSTS into the browser before the very first contact. Submission happens via hstspreload.org and requires includeSubDomains and preload to be set correctly on every subdomain including the root domain, because removal from the list takes months and affects all major browsers simultaneously.


# nginx: HSTS and baseline security headers for a Magento storefront
server {
    listen 443 ssl http2;
    server_name shop.example.com;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }
}

# Separate HTTP server block: redirect only, never send HSTS over plain HTTP
server {
    listen 80;
    server_name shop.example.com;
    return 301 https://$host$request_uri;
}

3. X-Content-Type-Options: nosniff against MIME sniffing

Without the X-Content-Type-Options: nosniff header, browsers try to guess the actual file type from its content whenever the declared Content-Type is ambiguous or missing, a behavior called MIME sniffing. If a user uploads a file with a .txt extension whose content looks like HTML or JavaScript, the browser can still interpret and execute it as such. In Magento stores with file uploads, for example in product reviews with attachments or customer service forms, that opens a real attack surface for stored cross-site scripting.

The header itself only accepts a single valid value: nosniff. Once set, it forces the browser to strictly follow the declared Content-Type instead of running its own detection. For scripts and stylesheets that additionally means they only execute or apply if the MIME type is exactly text/javascript or text/css respectively. Since the header has no configuration options and causes no compatibility issues with modern browsers, there is practically no reason not to set it globally.

4. Referrer-Policy: privacy versus analytics needs

The Referrer-Policy header controls what information the browser sends to the destination in the Referer header when a user leaves a page. The historical default value no-referrer-when-downgrade sends the full URL including the query string to any destination, as long as there's no downgrade from HTTPS to HTTP, which in a Magento store can mean that search terms, coupon codes, or internal session parameters leak to external ad networks.

The recommended balance between privacy and functioning analytics is strict-origin-when-cross-origin: the full URL is sent within the own domain, only the origin without path and query string is sent to foreign domains, and no referrer at all is sent on a downgrade from HTTPS to HTTP. Anyone needing maximum restraint, for example on sensitive checkout flows, uses no-referrer and gives up referrer data entirely, but then also loses attribution for incoming referral links in their own analytics tool. same-origin is a good middle ground for stores that don't need any cross-origin referrer at all.

5. Permissions-Policy: restricting browser APIs on purpose

Permissions-Policy replaced the older, since-renamed Feature-Policy header and controls which powerful browser APIs a page and its embedded iframes are allowed to use. A typical Magento store needs neither camera nor microphone access and in most cases no geolocation API either, yet without an explicit header these APIs remain available by default to every embedded script, including third-party tracking or payment iframes.

The syntax lists a so-called allowlist per feature: camera=() disables the camera entirely for the page and all iframes, geolocation=(self) allows it only on the own origin, and payment=(self "https://checkout.payment-provider.com") grants it specifically to the own domain and one named payment provider. This fine-grained control significantly reduces the attack surface from compromised third-party scripts, because even an injected script can't activate the camera if the policy forbids it globally. For most Magento installations, a restrictive baseline with targeted exceptions for features actually in use, such as payment widgets, is the right approach.

6. X-Frame-Options vs. CSP frame-ancestors

X-Frame-Options was the standard clickjacking defense for years and has two practically relevant values: DENY blocks any embedding in an iframe regardless of origin, while SAMEORIGIN allows embedding only from the own domain. For checkout pages and the account area of a Magento store, DENY is almost always the right choice, since there's no legitimate reason to embed these pages elsewhere.

The header has a structural weakness: it only allows a single origin or none at all, a list of several permitted domains isn't possible. The Content-Security-Policy directive frame-ancestors solves exactly that and is the modern replacement: frame-ancestors 'self' https://partner-portal.example.com allows embedding from the own domain and a named partner at the same time. Important during the transition period: modern browsers prefer frame-ancestors when both headers are present, while older browsers only understand X-Frame-Options. Setting both headers in parallel therefore remains sensible practice until the legacy header can be fully retired.


# Apache .htaccess: legacy clickjacking protection plus modern CSP replacement
<IfModule mod_headers.c>
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set Content-Security-Policy "frame-ancestors 'self' https://partner-portal.example.com"

    # Avoid leaking the technology stack alongside the security headers
    Header unset X-Powered-By
</IfModule>

<IfModule mod_headers.c>
    # DENY specifically for checkout and customer account paths
    <If "%{REQUEST_URI} =~ m#^/(checkout|customer)#">
        Header always set X-Frame-Options "DENY"
    </If>
</IfModule>

7. Header scans: understanding securityheaders.com and its grade

Scanners modeled on securityheaders.com send a single HTTP request to a URL, evaluate the response headers returned, and assign a grade from A+ to F. The grading is based on fixed weighting: the absence of Content-Security-Policy and Strict-Transport-Security affects the grade most heavily, while Permissions-Policy and Referrer-Policy carry less weight. A grade alone doesn't say whether the individual values are actually configured sensibly, since an overly permissive Access-Control-Allow-Origin: * can remain a real risk despite a good overall grade.

More important than the letter grade is the detailed report's individual findings: which headers are missing entirely, which are set but too permissive, and which conflict between the CDN and the origin server. A scan should run automatically after every deployment, for example as a step in the CI pipeline using curl -sI against the staging URL, so regressions surface before a customer reaches the unprotected store in production.


{
  "url": "https://shop.example.com/",
  "grade": "B",
  "score": 78,
  "headers": {
    "strict-transport-security": {
      "present": true,
      "value": "max-age=63072000; includeSubDomains; preload",
      "weight": "high",
      "status": "pass"
    },
    "content-security-policy": {
      "present": false,
      "weight": "high",
      "status": "fail",
      "note": "Missing CSP allows unrestricted script and frame sources"
    },
    "x-content-type-options": {
      "present": true,
      "value": "nosniff",
      "weight": "medium",
      "status": "pass"
    },
    "referrer-policy": {
      "present": true,
      "value": "strict-origin-when-cross-origin",
      "weight": "low",
      "status": "pass"
    },
    "permissions-policy": {
      "present": false,
      "weight": "low",
      "status": "warn"
    }
  }
}

8. Configuring headers at the webserver and CDN layer, not in application code

Security headers structurally belong at the infrastructure layer, not in Magento controllers, observers, or layout XML. A setHeader() call in PHP only runs after the entire Magento bootstrap has completed, costs unnecessary compute time, and doesn't apply at all to static assets, error pages, or responses served directly from the Full Page Cache or a CDN edge without ever reaching the application server. Headers set at the webserver, in Varnish, or at the CDN, on the other hand, apply to every response regardless of which layer ultimately serves it.

For Magento setups using Varnish as the Full Page Cache, setting headers in vcl_deliver is recommended so they're sent consistently on both cache hits and cache misses. With a CDN in front, such as Fastly or Cloudflare, the same headers can additionally be set via response header rules in the CDN dashboard or through an edge script, which is especially helpful in multi-origin setups with several backends. What matters is committing to a single responsible layer instead of redundantly maintaining the same headers in multiple places.


<?php
// ANTI-PATTERN: setting security headers inside a Magento plugin.
// This code only runs for requests that reach PHP - cached and static
// responses served by Varnish or the CDN never pass through it.
declare(strict_types=1);

namespace Vendor\Module\Plugin;

use Magento\Framework\App\Response\Http;

/**
 * Demonstrates why response headers should not be set in application code.
 */
class SecurityHeaderPlugin
{
    /**
     * Adds headers too late and too narrowly. Webserver/CDN configuration
     * covers every response; this plugin only covers PHP-rendered ones.
     *
     * @param Http $subject
     * @return void
     */
    public function beforeSendResponse(Http $subject): void
    {
        // Never reached for static assets, 404 pages served by Varnish,
        // or CDN edge cache hits - use nginx/Apache/Varnish/CDN instead.
        $subject->setHeader('X-Content-Type-Options', 'nosniff', true);
        $subject->setHeader('X-Frame-Options', 'SAMEORIGIN', true);
    }
}

9. Header ordering and duplicates: CDN versus origin

The table below sums up the most important security headers once more as a checklist: the default state without the header versus the recommended production value and its concrete effect.

Header Missing / insecure value Recommended value Effect
Strict-Transport-Security (header not set) max-age=63072000; includeSubDomains; preload Enforces HTTPS, prevents SSL stripping
X-Content-Type-Options (header not set) nosniff Prevents MIME sniffing attacks
X-Frame-Options (header not set) SAMEORIGIN / DENY Clickjacking protection (legacy)
Content-Security-Policy (no frame-ancestors) frame-ancestors 'self' Modern, granular clickjacking protection
Referrer-Policy no-referrer-when-downgrade strict-origin-when-cross-origin Reduces data leaks to third parties
Permissions-Policy (header not set) camera=(), microphone=(), geolocation=(self) Disables unused browser APIs

As soon as a CDN like Cloudflare or Fastly sits in front of the actual webserver, both layers frequently end up setting the same headers, usually because a configuration was migrated without removing the old one. The result is duplicate headers in the HTTP response, for example two X-Frame-Options lines with different values. Browsers react to this inconsistently: some evaluate only the first value, some the last, and for Content-Security-Policy a special rule even applies where multiple CSP headers aren't overwritten but combined by intersection, which can make the policy unexpectedly restrictive.

The reliable fix is clear ownership: either the CDN sets security-relevant headers and the origin server leaves them untouched, or the other way around, never both at once for the same header. Running curl -sI against the live URL quickly reveals duplicate headers, since each line is listed individually. With Varnish, also watch out: vcl_deliver can accidentally duplicate a header already set by the origin if set resp.http... is used without a preceding unset.


#!/usr/bin/env bash
# Detect duplicate security headers between CDN and origin
set -euo pipefail

echo "Checking for duplicate headers on shop.example.com..."
curl -sI "https://shop.example.com/" | grep -i "x-frame-options\|content-security-policy\|strict-transport-security"

# Example output revealing a duplicate (CDN and origin both set it):
# x-frame-options: SAMEORIGIN
# x-frame-options: DENY
# strict-transport-security: max-age=63072000; includeSubDomains; preload

# Fix: unset the header at one layer, keep a single source of truth.
# In Varnish vcl_deliver:
#   unset resp.http.X-Frame-Options;
#   set resp.http.X-Frame-Options = "SAMEORIGIN";

Mironsoft

HTTP security headers, hardening, and infrastructure configuration for Magento stores

Ready to configure your security headers properly?

We analyze your Magento store's current header configuration at the webserver, Varnish, and CDN layers, close gaps in HSTS, CSP, and Permissions-Policy, and remove duplicates between the layers.

Header audit

Full scan of all response headers including a CDN versus origin comparison

Webserver hardening

HSTS, nosniff, frame-ancestors, and Permissions-Policy in nginx, Apache, and Varnish

Monitoring setup

Automated header scans in the CI pipeline after every deployment

10. Summary

The most important security headers for Magento stores each address a specific risk: Strict-Transport-Security enforces HTTPS and, with the preload list, closes the last gap before the very first page visit. X-Content-Type-Options: nosniff blocks MIME sniffing attacks through file uploads. Referrer-Policy and Permissions-Policy reduce what data and browser APIs are reachable for third parties in the first place. X-Frame-Options and the modern CSP directive frame-ancestors prevent clickjacking, and both headers should be set in parallel until older browsers have fully disappeared from the traffic.

What determines success is the right configuration layer: headers belong at the webserver, behind Varnish, or at the CDN, not in Magento controllers or plugins, since only there does every response get covered, including cache hits and static assets. Anyone configuring both CDN and origin at once must watch for duplicate headers and keep ownership clearly separated. A regular scan with a tool like securityheaders.com in the CI pipeline ensures that new deployments don't quietly introduce regressions in the header configuration.

Security Headers Overview, the Essentials at a Glance

HSTS & preload

max-age=63072000; includeSubDomains; preload enforces HTTPS from the first visit onward. Submit via hstspreload.org.

nosniff & Referrer-Policy

X-Content-Type-Options: nosniff prevents MIME sniffing. strict-origin-when-cross-origin balances privacy and analytics.

Permissions-Policy & frame protection

Disable unused APIs like camera and microphone. frame-ancestors replaces X-Frame-Options with multiple allowed domains.

Configuration & testing

Set headers at the webserver, in Varnish, or at the CDN, never in Magento code. Verify with a scanner after every deployment.

11. FAQ: Security Headers Overview

1What are HTTP security headers and why isn't TLS enough alone?
Response headers that give the browser additional security rules, such as enforced HTTPS, clickjacking protection, or restricted browser APIs. TLS only encrypts the connection, it does not govern content, frames, or APIs.
2How does HSTS work and what does preload mean?
Enforces HTTPS for the duration of max-age. preload additionally flags the domain for a fixed list built into the browser, so HTTPS applies even before the very first visit.
3How do I submit a domain to the HSTS preload list?
Via hstspreload.org, with max-age of at least one year and includeSubDomains plus preload on every subdomain. Removal takes months and affects all major browsers.
4What does X-Content-Type-Options: nosniff do?
Stops browsers from guessing the file type themselves (MIME sniffing) and enforces the declared Content-Type. Among other things, prevents uploads from accidentally executing as a script.
5Which Referrer-Policy should a Magento store use?
strict-origin-when-cross-origin as the best compromise between privacy and analytics. For sensitive checkout areas, no-referrer is a sensible alternative.
6What is Permissions-Policy for?
Successor to Feature-Policy. Restricts browser APIs like camera, microphone, or geolocation via allowlist, such as camera=() to disable them entirely.
7Is X-Frame-Options outdated compared to frame-ancestors?
Considered legacy but still needed by older browsers. frame-ancestors functionally replaces it and additionally allows several domains at once.
8How do I interpret a header scanner's grade?
CSP and HSTS weigh most heavily. More important than the grade is the detailed report: missing headers, overly permissive values, and conflicts between CDN and origin.
9Should headers be set in Magento code or at the webserver?
At the webserver, in Varnish, or at the CDN, since that layer covers every response. PHP code only runs for requests that actually make it through to Magento.
10What happens with duplicate headers from CDN and origin?
Browsers handle duplicates inconsistently. For CSP, multiple headers get combined by intersection. Clearly separated ownership per header prevents this.