MODRACXKENNETH D'SILVA

← Archive & Insights

Configuring Secure HTTP Headers for E-Commerce

A scanner grade measures whether headers exist, not whether they do anything — and it almost never checks your checkout page. Here is what each header actually defends against, which ones to stop setting, and the order to deploy them in.

By Kenneth D'SilvaReading Time: 22 min readCategory: Security & Compliance

1. The Report That Said A+

A client sent me a security scanner report for their storefront. Grade A+, green across the board, eleven headers detected. They wanted to know why I was still recommending work.

The report had checked their homepage. Their checkout page — a different template, served through a different set of Nginx location blocks — sent exactly two of those eleven headers. The Content-Security-Policy that earned most of the grade was default-src 'self' 'unsafe-inline' 'unsafe-eval' *, which permits essentially everything and exists to make scanners happy. And the header that would have caught a Polyfill-style script substitution wasn't a header at all; it was an integrity attribute nobody had added.

Security headers are genuinely valuable. They're also the most cargo-culted part of web security, because they're easy to copy, easy to grade, and hard to verify. A block of ten headers pasted from a blog post produces the same scanner grade as ten headers someone thought about, and only one of those configurations survives contact with an attacker.

This article is the map: what each header actually defends against, which ones matter for a storefront, which ones you can stop setting, and the order to deploy them in so that when something breaks you know what broke it. The deep ones — CSP, HSTS, SRI, Referrer-Policy — have their own articles, and I'll point at them rather than repeat them badly here.

2. What Headers Can and Cannot Do

Worth being precise, because the mental model determines whether you deploy them sensibly.

Every header in this article is an instruction to the browser. Not to your server, not to an attacker's tooling — to the browser rendering your page for a real customer. They constrain what that browser will do on your behalf.

Three consequences follow.

They defend the client side, not the server side. No header protects against SQL injection, a compromised admin account, an unpatched dependency, or a leaked API key. If your threat model is "someone gets into the server," headers do nothing. They are for the class of attack that plays out in the customer's browser: injected scripts, clickjacking, data exfiltration, protocol downgrade.

They're advisory to anything that isn't a browser. A scraper, a script, or an attacker using curl ignores every one of them. This matters because people occasionally treat a header as an access control. It isn't.

They fail silently. This is the operationally important one. A header that stops being sent produces no error, no alert, and no visible change. Your site works exactly as before, minus the protection. That's why the monitoring section near the end isn't optional garnish.

What they're genuinely good at: reducing the blast radius of a mistake you haven't found yet. If a cross-site scripting bug exists somewhere in your codebase — and on a large storefront one probably does — a well-built CSP is the difference between a nuisance and a card-skimming incident.

3. The Headers That Matter, Ranked

Not alphabetically, and not in the order scanners list them. In the order I'd deploy them on a storefront that has none.

1. Strict-Transport-Security

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

Removes the plaintext HTTP request that happens before your redirect fires. Highest value for the least conceptual complexity, and it makes every other control more meaningful — a header set over a connection an attacker can intercept isn't worth much.

The catch is that it's also the header most likely to cause an outage, because includeSubDomains reaches hosts you've forgotten and the policy can't be recalled once a browser has it. The staged rollout matters more than the header does. I've written the full version in the HSTS guide, including how to enumerate the subdomains you don't know about.

2. Content-Security-Policy

The most powerful and by a wide margin the most work. CSP tells the browser which sources of script, style, image, frame and connection are legitimate, and blocks everything else.

On a checkout page it is the single control that most directly addresses card skimming, because a skimmer that can't reach its collection endpoint has stolen nothing:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m' 'strict-dynamic';
  connect-src 'self' https://api.stripe.com;
  frame-src https://js.stripe.com;
  base-uri 'self';
  form-action 'self';
  object-src 'none'

Two directives do most of the real work and are the two most often omitted. connect-src limits where any script may send data. form-action stops an injected form posting card details to an attacker's server. An allowlist of script sources without those two is theatre — the classic Magecart pattern is a script from an already-allowed host posting to somewhere else.

Note also what's not in that example: 'unsafe-inline' and 'unsafe-eval'. A policy containing both is close to no policy at all, and the majority of CSPs I audit contain both because removing them means fixing inline handlers across a legacy theme. That's the actual project. The CSP guide covers the nonce and strict-dynamic approach that makes it tractable.

3. X-Content-Type-Options

X-Content-Type-Options: nosniff

One value, no configuration, no downside. It stops the browser second-guessing your Content-Type and deciding a file is HTML when you said it was an image.

That matters on any site accepting uploads, which is most storefronts somewhere — product review photos, custom-print artwork, returns evidence, CV uploads on a careers page. An attacker uploads a file that is valid enough to pass your image check but also parses as HTML, the browser sniffs it as HTML, and now you're serving attacker-controlled markup from your own origin. nosniff closes it.

Set this today. There is no rollout plan and nothing to test.

4. Referrer-Policy

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

Controls how much of your URL travels to third parties. The reason it matters on ecommerce specifically is that order confirmation URLs accumulate parameters — order IDs, emails, totals — and every tracking pixel on that page receives the lot.

Modern browsers already default to this value, so the risk of setting it is minimal, and the risk of not setting it is that a CDN or a legacy config supplies something more permissive. The full treatment, including what it does to affiliate attribution, is in the Referrer-Policy guide.

5. frame-ancestors (and X-Frame-Options)

Content-Security-Policy: frame-ancestors 'self'
X-Frame-Options: SAMEORIGIN

Stops your pages being embedded in someone else's site. The attack is clickjacking: your checkout in a transparent iframe over a page the attacker controls, so the customer thinks they're clicking something else while actually confirming an order or authorising a change.

frame-ancestors is the modern mechanism and supersedes X-Frame-Options entirely. Keep both only if you care about very old browsers; the CSP directive is the one that counts, and where they conflict, browsers prefer it.

One caution: if any part of your site is legitimately embedded — a store locator on a partner site, a product widget for affiliates — 'self' will break it. List the permitted parents rather than reaching for ALLOWALL.

6. Permissions-Policy

Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=(self)

Disables browser capabilities the page has no business using. A compromised third-party script can't ask for the camera if the page has renounced camera access.

The value shown is a starting point, not a recommendation — audit what your site actually needs. payment=(self) in particular is load-bearing if you use the Payment Request API, and blocking it silently breaks Apple Pay and Google Pay buttons. That failure presents as a conversion drop with no error message, which is a miserable thing to debug. If you're not sure, start by disabling only the capabilities you're confident you don't use.

7. Cross-Origin-Opener-Policy and friends

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-site

These sever the relationship between your page and windows or resources from other origins, and they exist mostly to defend against speculative-execution side channels — the Spectre family.

Honest assessment for a typical storefront: lower priority than everything above. COOP: same-origin is worth setting because it also breaks the window.opener attack path, where a page you link to can navigate your tab somewhere else. The full cross-origin isolation set including COEP is genuinely disruptive — it breaks most third-party embeds — and you only need it if you're using SharedArrayBuffer or high-resolution timers, which a storefront almost certainly isn't.

Set COOP. Leave COEP alone unless you have a specific reason.

4. Cache-Control Is a Security Header Too

It never appears on scanner checklists, and on a storefront it protects something more concrete than most of the headers that do.

The problem: any page rendered for a signed-in customer contains their data. Their name, their addresses, their order history, sometimes a partial card number. If a shared cache stores that response, someone else can receive it.

"Shared cache" is broader than people assume. Your CDN. A corporate proxy. The caching layer at an ISP. And, historically the most damaging on shared devices, the browser's own back-button cache — a customer logs out at an internet café or a library terminal, the next person presses Back, and the account page renders from cache.

The rule is simple and the discipline is where it fails:

# Anything personalised must never be stored
location ~ ^/(customer|checkout|onestepcheckout|sales/order)/ {
    add_header Cache-Control "no-store, private" always;
    add_header Pragma "no-cache" always;
}

# Hashed static assets: cache hard
location ~* \.(css|js|woff2|avif|webp)$ {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

no-store rather than no-cache. They sound alike and they are not: no-cache permits storage and requires revalidation, while no-store forbids writing the response down at all. For a page containing customer data, storage is the thing you're preventing.

The failure I see most often is not a missing directive but an over-broad CDN rule — a "cache everything" setting introduced during a performance push, with bypass rules for /checkout and /customer that miss one route. A single uncached-but-should-be route is enough. When a merchant tells me a customer saw someone else's order, this is the cause about half the time; a mis-scoped full-page cache on the application side accounts for most of the rest.

Test it the direct way rather than reading config: log in as one test account, visit an account page, log in as a second in a different browser profile, and request the same URL. Then check what your CDN reports as a cache hit for those paths over a day. A cache hit on /customer/account is an incident, not a tuning opportunity.

5. The Ones You Can Stop Setting

Every copied header block I review contains at least two of these. Removing them is a small cleanup with a real benefit: fewer lines that someone will assume are doing something.

X-XSS-Protection. This enabled a browser-side XSS filter that was itself a source of vulnerabilities — it could be manipulated to break otherwise-safe pages. Chrome removed the filter entirely years ago; other browsers followed. Setting 1; mode=block does nothing in any current browser. If you have a reason to send it at all, send 0. Otherwise delete it and use CSP, which is what actually addresses this.

Expect-CT. Enforced Certificate Transparency reporting. It became obsolete once CT enforcement moved into browsers by default, and it has been removed. Delete it.

Feature-Policy. Renamed to Permissions-Policy, with different syntax. Sending the old one alongside the new one is harmless but pointless.

Public-Key-Pins. HPKP. Removed from browsers because it was too easy to permanently brick your own domain — pin a key, lose the key, and you've locked every returning visitor out for the pin's lifetime. If you find this in a config, it is a landmine from another era. Delete it.

P3P. A privacy policy format from the early 2000s, kept alive for years by an old Internet Explorer cookie quirk. Dead.

A general principle here: a header that no browser reads is worse than nothing, because it occupies the space in your config and in your team's mind where a working control should be. When someone asks "are we protected against XSS?" and the answer is "we set X-XSS-Protection," the header has actively cost you something.

6. CORS Is Not a Security Header

This gets conflated with the rest constantly, usually in the same ticket, so it's worth separating properly.

Every header above restricts what the browser will do. CORS does the opposite: it relaxes the same-origin policy, which is a restriction browsers apply by default. Access-Control-Allow-Origin is not a lock you're adding. It's a lock you're opening, and every misconfiguration makes you less safe rather than more.

The failure mode I find most often on ecommerce APIs:

// DANGEROUS — reflects any origin and permits credentials
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', req.headers.origin);
  res.header('Access-Control-Allow-Credentials', 'true');
  next();
});

That combination means any website on the internet can make authenticated requests to your API using your customer's session cookie and read the response. A customer visits an attacker's page while logged into your store, and the attacker's JavaScript can read their order history, their addresses, and anything else the session grants.

The specification forbids Access-Control-Allow-Origin: * together with credentials, which is exactly why people reach for reflection — it's the workaround that makes the error message go away, and it reintroduces the whole problem.

The correct shape is an explicit allowlist:

const ALLOWED = new Set([
  'https://shop.example.com',
  'https://app.example.com'
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && ALLOWED.has(origin)) {
    res.header('Access-Control-Allow-Origin', origin);
    res.header('Access-Control-Allow-Credentials', 'true');
    res.header('Vary', 'Origin');
  }
  next();
});

Two details that get missed. Vary: Origin is required whenever the response varies by origin — without it a CDN can cache the response generated for one origin and serve it to another, which turns a correct allowlist into a broken one. And check for exact matches, not substrings: a check like origin.endsWith('example.com') is satisfied by https://evil-example.com, which is a real bug I have found in production more than once.

If your API is genuinely public and unauthenticated, Access-Control-Allow-Origin: * without credentials is fine and simple. The danger lives entirely in the credentialed case.

7. Headers You Should Remove Rather Than Add

The reverse exercise is quicker than the rollout and occasionally more valuable: some headers give away information you'd rather not publish.

Server: nginx/1.22.1
X-Powered-By: PHP/8.1.2
X-Magento-Cache-Debug: HIT
X-Generator: Drupal 9

Each of those tells an attacker which exploit list to start from. This is not a serious defence — anyone determined will fingerprint you from behaviour regardless — but it removes you from the enormous volume of untargeted scanning that searches for specific versions with known vulnerabilities. The cost is about four lines of config.

server_tokens off;

# PHP-FPM: expose_php = Off in php.ini removes X-Powered-By at the source
proxy_hide_header X-Powered-By;
proxy_hide_header X-Magento-Cache-Debug;
proxy_hide_header X-Generator;

The debug headers matter more than the version strings. Cache-debug headers reveal which routes are cached and which bypass, which is a map for anyone probing for a cache-poisoning or a cache-deception weakness. They exist for your engineers and belong behind an internal-only condition, not on every public response.

While you're auditing, look at what your application emits on error pages specifically. A stack trace in a 500 response is a far larger disclosure than any of these headers, and error handling is frequently the one place where a hardened site still leaks framework paths, database names, and occasionally credentials in a connection string.

8. Where to Set Them

One layer. Pick it, own it, and turn the others off. Headers set in three places with different values are the single most common cause of the "the scanner says it's missing but I set it" ticket.

Nginx, and the inheritance trap

server {
    listen 443 ssl;
    http2 on;
    server_name shop.example.com;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
    add_header Content-Security-Policy "frame-ancestors 'self'" always;

    location /checkout/ {
        # WARNING: declaring any add_header here drops ALL of the above.
        # Every header the checkout needs must be repeated.
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header Referrer-Policy "strict-origin-when-cross-origin" always;
        add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(self)" always;
        add_header Content-Security-Policy "frame-ancestors 'self'; form-action 'self'" always;
    }
}

Read that comment twice, because it is the defect I find most often. Nginx's add_header does not merge with the parent — if a location block declares a single header, it silently discards every inherited one. Your checkout, the page you cared about most, ends up as the only page without protection.

The other Nginx detail is always. Without it, headers are omitted on 4xx and 5xx responses. Your 404 page is a real page that loads real scripts, and an attacker who can force an error response gets an unprotected one. Add always to every directive.

If the repetition bothers you — it should — factor the common set into an include:

# /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# then in each block that needs them:
#   include snippets/security-headers.conf;

That doesn't fix the inheritance behaviour, but it makes the repetition one line instead of five and much harder to get subtly wrong.

Apache

<IfModule mod_headers.c>
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
    Header always set X-Content-Type-Options "nosniff"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    Header always set Content-Security-Policy "frame-ancestors 'self'"
</IfModule>

Apache inherits the way you'd expect, which makes it considerably less error-prone than Nginx here. Use set rather than addadd appends, and duplicate headers with different values produce browser-dependent behaviour.

At the CDN

Convenient and the usual choice for teams already behind Cloudflare or Fastly, with one real drawback: dashboard configuration lives outside version control and outside code review. Somebody will change it during an incident and not tell anyone.

Where the platform allows it, express headers as code — a Worker, a VCL snippet, a Terraform resource — so the change goes through the same review as everything else. And if the CDN sets headers, make sure the origin doesn't also set them, or you'll spend an afternoon working out which layer produced the value you're seeing.

Magento 2

Set them at the web server. Magento ships a Magento_Csp module which manages Content-Security-Policy specifically, and if you're doing serious CSP work it's the right tool because it understands the nonce lifecycle. For the static headers, spending an application request cycle to emit a constant string is waste.

The Magento-specific thing worth checking is that admin and storefront often need different policies — the admin panel uses inline scripts extensively — and the routes are distinguishable, so scope accordingly rather than weakening the storefront policy to accommodate the admin.

Shopify

You don't control response headers. Shopify sets a sensible baseline including HSTS and a reasonable Referrer-Policy, and checkout is governed by their own policy which you cannot weaken — which is a feature.

What you own on Shopify is app hygiene: which apps you install, what scripts they inject, and what their permissions are. That's the same underlying concern this article is about, approached from the only lever the platform gives you.

9. Rolling Them Out

The mistake is shipping all of them in one deploy. When something breaks — and something will — you'll have six suspects and a pressure to revert everything, which loses the four that were working fine.

One header per deploy, with a gap between. My usual order and why:

Week one: X-Content-Type-Options. Zero risk, immediate value, and it establishes the mechanics of where headers live and how you verify them.

Week one: Referrer-Policy. Near-zero risk since it matches browser defaults. Watch affiliate attribution for a week if you have affiliate partners.

Week two: frame-ancestors and X-Frame-Options. Low risk, but find out first whether anything legitimately embeds you. Ask partnerships, not just engineering.

Weeks two to five: HSTS, staged. Five minutes, then a week, then adding includeSubDomains, then a year. The staging exists because this is the one that can take hosts offline. Tell internal teams before the subdomains stage.

Week three: Permissions-Policy. Start by disabling only what you're certain you don't use. Test wallet payment buttons specifically.

Weeks four onward: CSP, in report-only mode first. This is a project, not a header. Run Content-Security-Policy-Report-Only in production for at least two weeks, collect violations, and tighten iteratively. Real traffic finds pages your QA never opens.

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-uri /_csp-report

Report-only is the single most useful thing in this article for a large legacy storefront. It tells you exactly what a policy would break, at production scale, without breaking anything.

10. The Admin Panel Deserves Its Own Policy

Header work almost always focuses on the storefront, because that's what customers and scanners see. The admin panel is where an attacker would rather be, and it usually has weaker protection because the storefront policy was written first and the admin was accommodated by loosening it.

Do the opposite: give the admin its own, stricter policy on its own routes.

location ^~ /admin_9k2x/ {
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    # Admin pages have no business being framed, linked out from, or
    # submitting anywhere but here
    add_header Content-Security-Policy "frame-ancestors 'none'; form-action 'self'; base-uri 'self'" always;
    add_header Referrer-Policy "no-referrer" always;
    add_header Cache-Control "no-store" always;
}

Four deliberate differences from the storefront. frame-ancestors 'none' because nothing should ever embed an admin page. Referrer-Policy: no-referrer because admin URLs carry entity IDs and filter state that shouldn't travel anywhere. Cache-Control: no-store because admin pages contain customer data and shared or proxied caches should never hold them. And form-action 'self' because an injected form in an admin session is a straight path to privilege escalation.

Two things beyond headers that matter more, while you're here. The admin path should not be the platform default — Magento's /admin, a predictable WordPress path — because obscurity buys nothing against a targeted attacker but removes you from the enormous volume of automated scanning. And admin access should be restricted at the network layer where the business allows it: an IP allowlist or a VPN requirement is worth more than every header in this article combined.

The uncomfortable truth is that most storefront compromises I've investigated started with an admin credential rather than a clever browser attack. Headers are the right work; they're not the highest-value work if your admin panel is reachable from anywhere with a password someone reuses.

11. Verifying

Check the pages that matter, not the homepage. A short script beats a scanner because you choose the URLs:

#!/usr/bin/env bash
# headers.sh — audit the templates that actually matter
set -uo pipefail

URLS=(
  "https://shop.example.com/"
  "https://shop.example.com/category/outerwear"
  "https://shop.example.com/product/example-item"
  "https://shop.example.com/cart"
  "https://shop.example.com/checkout"
  "https://shop.example.com/customer/account/login"
  "https://shop.example.com/definitely-not-a-real-page"
)

WANT=(
  "strict-transport-security"
  "x-content-type-options"
  "referrer-policy"
  "content-security-policy"
  "permissions-policy"
)

for url in "${URLS[@]}"; do
  echo "── $url"
  headers=$(curl -sI "$url" | tr 'A-Z' 'a-z')
  for h in "${WANT[@]}"; do
    if echo "$headers" | grep -q "^$h:"; then
      printf '   ok      %s\n' "$h"
    else
      printf '   MISSING %s\n' "$h"
    fi
  done
done

Run it against the edge and against the origin separately if you use a CDN — they can disagree, and the edge is what customers get. Include an error URL; that's how you catch a missing always.

Then put it in your deploy pipeline and let it fail the build. Headers regress silently, and the regression is invisible for as long as nobody looks.

For CSP specifically, collect violation reports rather than guessing. A minimal endpoint is enough to start:

// POST /_csp-report — log and move on. Expect volume; sample if needed.
app.post('/_csp-report', express.json({ type: ['application/csp-report', 'application/json'] }), (req, res) => {
  const r = req.body['csp-report'] || req.body;
  logger.warn('csp', {
    documentUri: r['document-uri'],
    blockedUri: r['blocked-uri'],
    directive: r['violated-directive'],
    userAgent: req.get('user-agent')
  });
  res.status(204).end();
});

Fair warning: this endpoint will receive a great deal of noise from browser extensions injecting scripts into your pages. Filter by blocked-uri scheme — extension traffic uses schemes like chrome-extension: and moz-extension: — before you conclude your policy is wrong.

12. Getting Told When Things Break

Since these controls fail silently, the reporting side is worth setting up alongside them rather than later.

CSP has had report-uri for years, and it still works. The newer mechanism routes several kinds of report through one endpoint:

Reporting-Endpoints: default="https://shop.example.com/_reports"

Content-Security-Policy: default-src 'self'; report-to default; report-uri /_csp-report

Send both directives during any transition — report-uri is deprecated but widely supported, report-to is the successor with uneven support. Belt and braces costs you a few bytes.

The same channel carries Network Error Logging, which reports failures that never reached your server at all:

NEL: {"report_to":"default","max_age":86400,"failure_fraction":0.05}

That's a genuinely useful signal and an unusual one, because by definition your server logs cannot contain it. DNS failures, TLS handshake errors, connection resets, certificate problems affecting one region or one ISP — the customers experiencing them are the ones you never hear from, because they see a browser error page rather than your site.

Keep failure_fraction low. At 1.0 a widespread outage means every affected browser reports to an endpoint that is very possibly also affected, and you've built a small self-inflicted denial of service. Five percent is plenty to spot a pattern.

Whatever you collect, route it somewhere a person actually looks. An endpoint that writes to a log nobody reads is the same as no endpoint, and it costs more. In practice a weekly digest of new violation types — not every violation, just types not seen before — is the format that stays useful past the first fortnight.

13. Scanner Grades Versus Actual Security

Since the grade is usually what triggers the ticket, it's worth being blunt about what it measures.

Public scanners check whether a header is present on one URL, and sometimes whether its value looks reasonable. They cannot tell whether your CSP is meaningful or performative, whether your checkout template shares the homepage's configuration, or whether the scripts on your payment page are ones you approved.

Consequences worth knowing:

You can score A+ with a CSP that permits everything. default-src * with 'unsafe-inline' is a present header with a valid value.

You can score poorly while being well-defended, if your headers are on the pages that matter and the scanner checked a marketing landing page on a different host.

And the grade says nothing at all about the two controls that most directly address card skimming: whether third-party scripts on the payment page are pinned by hash, and whether you have an inventory of what runs there. Those are Subresource Integrity and process, and no header scanner will ever grade them.

Use scanners as a checklist for absences. Don't use them as evidence of security, and be a little suspicious of any engagement whose deliverable is a screenshot of a grade.

14. A Worked Rollout

A laboratory equipment retailer on Magento 2.4.7 behind Cloudflare, roughly 90,000 orders a year, prompted by a penetration test that flagged missing headers.

What we found. Five headers on the homepage, set in a Cloudflare Transform Rule added eighteen months earlier by a contractor. Two headers on checkout, because Magento's own Magento_Csp module was emitting a policy that replaced Cloudflare's. Nobody knew both layers were active. The CSP being emitted contained 'unsafe-inline', 'unsafe-eval', and a wildcard script-src.

First decision: one layer. We moved everything to the origin — committed Nginx config — and disabled the Cloudflare rule. That took a day and immediately made the system explicable. It also surfaced four location blocks that were silently dropping inherited headers.

Weeks one to three. nosniff, Referrer-Policy, frame-ancestors. Nothing broke. One surprise: a supplier portal was embedding their returns form in an iframe, discovered because the supplier called. We added them to frame-ancestors. That's the kind of thing you find by shipping and watching, and the reason for spacing the deploys.

Weeks two to six, HSTS. The subdomain enumeration turned up 19 hostnames against 8 documented. Three couldn't serve valid HTTPS; two were decommissioned and one got a certificate. Standard, and the inventory was arguably worth more than the header.

Weeks four to eleven, CSP. The long one. Report-only for three weeks generated about 40,000 violations, of which roughly 90% were browser extensions. The real findings: 23 inline event handlers in the theme, four inline scripts without nonces, two third-party tags loading from undeclared hosts, and one genuinely alarming discovery — a script on the checkout page loading from a domain nobody could identify, which turned out to be a dormant A/B testing tool from a cancelled programme.

Removing 'unsafe-inline' meant rewriting those 23 handlers into addEventListener calls. Unglamorous, about three days, and the thing that actually made the policy worth having.

Where it ended. A CSP with nonces and strict-dynamic, no unsafe-inline, and connect-src and form-action locked down. Total elapsed time about eleven weeks, of which maybe nine engineering days. The pen-test finding would have been closed in week one by pasting a header block; the actual security improvement took the other ten weeks.

15. Questions That Come Up

"Can I just paste a recommended header block?" For nosniff, Referrer-Policy, and frame-ancestors, essentially yes. For CSP, no — a pasted policy is either so permissive it does nothing or so strict it breaks your site, and both outcomes teach you nothing. For HSTS, pasting a one-year max-age with includeSubDomains is how outages happen.

"Do headers affect SEO?" Not directly. No header in this article is a ranking signal. The indirect effects are real but small: HSTS removes a redirect hop which helps TTFB slightly, and a badly configured CSP that blocks your own structured data or breaks rendering can genuinely hurt you. Treat them as security work with an SEO risk to manage, not as an SEO tactic.

"Our scanner says a header is missing but I set it." In order of likelihood: an Nginx location block dropped inherited headers; the CDN stripped or replaced it; always is missing so it's absent on the error response the scanner tested; or two layers are both setting it and one wins. Check the exact URL the scanner used, at both the edge and the origin.

"Will CSP break my analytics?" It will break anything you haven't declared, which is the point. Tag managers are the hard case, because their whole purpose is loading code you didn't declare in advance. strict-dynamic is the usual accommodation, and on a payment page the better answer is often to remove the tag manager entirely.

"What about API endpoints?" Most of these headers are meaningless for JSON responses — there's no document to protect. nosniff is still worth setting. What APIs need instead is correct CORS configuration, which is a different subject and one where the common failure is reflecting the Origin header back with credentials allowed.

"We're mid-replatform. Should we wait?" Do the free ones now — nosniff, Referrer-Policy, frame-ancestors — because they carry over unchanged and cost nothing. Hold CSP until the new platform is the one you're writing a policy against, since a policy tuned to the old theme will be wrong on the new one. HSTS is the interesting case: it's worth doing early, because the subdomain inventory it forces is exactly the information a replatform needs anyway, and finding a forgotten host during migration planning is much cheaper than finding it during cutover.

"How often should we review this?" Twice a year, plus any time you change CDN, replatform, or add a payment provider. And put the verification script in CI so the between-times regressions surface on their own.

16. What I'd Actually Do This Week

Run the verification script against your checkout page, your product page, and a 404. Not the homepage — that's the page everyone already checked.

If headers are missing on checkout but present on the homepage, you have the Nginx inheritance bug, and fixing it is an hour's work with a real security benefit.

Add nosniff and Referrer-Policy today; there's no reason to plan them.

Look at your existing CSP, if you have one, and check honestly whether it contains 'unsafe-inline'. If it does, the header is close to decorative, and the useful next step is putting a report-only policy alongside it and finding out what a real one would cost.

Then start HSTS at a five-minute max-age, and read the rollout guide before you go past a week.

And write down which layer owns these headers, in a file someone will find. Six months from now a CDN migration or a platform upgrade will move the ground under this configuration, and the person handling it will not be you. A three-line comment at the top of the config saying where headers are set, where they are deliberately not set, and which routes have their own policy, is worth more than any of the individual values below it.

The thing I'd most like you to take from the A+ report at the top: the grade measured whether headers existed. It didn't measure whether they did anything, and it didn't look at the page where the money changes hands. Check your own checkout page, with your own script, and trust that over any letter.