Logo von nextlevels
Request a project

Security Headers Explained: Set HSTS, CSP & Co. Correctly in 30 Minutes

Which HTTP security headers matter in 2026, which values OWASP recommends, and ready-to-use snippets for Nginx, Caddy and the Shopware .htaccess

Security headers are the part of web security that costs the least and is missing most often. The HTTP Archive Web Almanac 2025 finds an HSTS header on 36 percent of the pages crawled on mobile and a Content Security Policy on 21.9 percent. The rest set neither.

This article sorts the seven relevant headers, names the recommended values and delivers ready-to-use configuration blocks for Nginx, Caddy and the Shopware .htaccess. The time in the title covers everything except the Content Security Policy; that one gets its own section because it is the only header that can damage a running shop.

Headline graphic: security headers as a rule set by the server and enforced by the browser
Headline graphic: security headers as a rule set by the server and enforced by the browser

What security headers are and what they do not do

A security header is a line in your server's HTTP response that the browser reads as an instruction. Strict-Transport-Security: max-age=63072000 means: for the next two years, talk to this domain over HTTPS only, even if someone types http:// or a link without the s is still in circulation. The server enforces nothing itself. It sets the rule, the browser enforces it.

That is also the limit: security headers protect the user's browser, not your server. A SQL injection in the checkout, an unpatched plugin or an open admin login remain what they are. The header shrinks the attack surface in the client: injected scripts do not run, the page cannot be embedded in a foreign frame, the browser does not interpret an uploaded text file as JavaScript.

The reference for recommended values is the OWASP Secure Headers Project. It maintains a machine-readable list of headers to set and a second list of headers to remove: Server, X-Powered-By, X-AspNet-Version and around 85 other identifiers that tell attackers which software and version you run. The second list is the one most often overlooked in audits, even though a scanner matches a Server: nginx/1.18.0 directly against the CVE list for that version.

The seven headers at a glance

The table shows the headers that matter today. The value column lists the OWASP proposal or, where that goes too far for a shop, the shop-safe variant; the third column rates how risky setting it is for a live shop.

The seven security headers at a glance: OWASP value or shop-safe variant, protection and risk when setting
Header OWASP value or shop-safe variant Protects against Risk when setting
Strict-Transport-Security max-age=63072000; includeSubDomains Downgrade to HTTP, cookie theft on open Wi-Fi Medium: locks out HTTP subdomains, long lifetime
Content-Security-Policy Project-specific; minimum object-src 'none'; base-uri 'self'; frame-ancestors 'self' (OWASP: 'none') Cross-site scripting, clickjacking, content injection High: blocks your own scripts, payment and tracking when wrong
X-Frame-Options DENY (OWASP) or SAMEORIGIN Clickjacking Low: only relevant if the shop deliberately runs in frames
X-Content-Type-Options nosniff MIME sniffing (file executed as script) None
Referrer-Policy strict-origin-when-cross-origin (OWASP: no-referrer) Leaking internal URLs and parameters to third parties Low: check affiliate and analytics attribution
Permissions-Policy camera=(), microphone=(), geolocation=(), usb=() ... Embedded content accessing device features Low: set payment=() only if no Payment Request API checkout runs
Cross-Origin-Opener-Policy same-origin-allow-popups (OWASP: same-origin) Foreign windows accessing your window object Low with allow-popups; same-origin breaks popup checkouts such as PayPal

Three headers from older guides no longer belong in the configuration. X-XSS-Protection controlled a browser filter that Chrome removed in 2019 and Firefox never had; the MDN documentation explicitly advises against it, because the filter itself opens holes in certain cases. Expect-CT has been without function since the end of the Certificate Transparency transition phase. Public-Key-Pins (HPKP) was removed by Chrome in 2019 and Firefox in 2020 because of the lockout risk; Safari never supported it. Setting these three does no harm, but it shows that nobody has touched the configuration in years.

Cross-Origin-Embedder-Policy: require-corp is also on the OWASP list but does not belong in a shop. The header requires every cross-origin resource to be explicitly released via CORP or CORS and, together with COOP, creates a so-called cross-origin isolated page. Stripe states in its security guide that it does not support such pages, because core dependencies of its payment flow do not. The same applies to most payment and tracking embeds.

Risk matrix of security headers: five safe headers, HSTS with care, CSP as its own project
Risk matrix of security headers: five safe headers, HSTS with care, CSP as its own project

Setting HSTS correctly: max-age, includeSubDomains, preload

HSTS (HTTP Strict Transport Security) is the header with the best ratio of effort to effect. Without HSTS, the browser sends an unencrypted request on the first call to http://yourshop.com before your server redirects to HTTPS. In that single request, session cookies can be captured on open Wi-Fi or the user can be steered to a copy of the site. With HSTS, the browser remembers the rule after the first HTTPS visit and rewrites every later http:// call to https:// locally, before a single byte leaves the device.

The header has three parts:

  1. max-age in seconds. OWASP recommends 63072000 (two years), the HSTS preload list requires at least 31536000 (one year). The browser remembers the rule for exactly that long, even if you remove the header later.
  2. includeSubDomains extends the rule to all subdomains. This is the part that locks projects out: an internal intranet.yourshop.com or a staging system without a valid certificate becomes unreachable for every browser that has visited the main domain once.
  3. preload registers the domain for the list built into Chrome, Firefox, Safari and Edge. HSTS then applies on the very first visit. The list operators themselves write that inclusion cannot easily be undone and that removal takes months.

For an application that does not yet send an HSTS header, hstspreload.org recommends a staged plan: first bring all subdomains to HTTPS and verify the HTTP-to-HTTPS redirect. Then set max-age=300 (five minutes) and observe. Then one week (604800), then one month (2592000), then two years. Wait the full lifetime between stages.

Only once the two-year value with includeSubDomains has run for several weeks without incident do you add preload and submit the domain.

With a Shopware 6 shop, the staged plan is already moot for the HTML pages: the core sends max-age=31536000; includeSubDomains on every successful HTTPS response, and according to RFC 6797 the browser evaluates only the first HSTS header in a response. One year with includeSubDomains is therefore already live, regardless of what the web server adds. The consequence: subdomains without HTTPS already lock out visitors of Shopware shops today, as soon as they have opened the main domain once. The web server value should then carry the same or a longer max-age, so that static files and error pages do not deliver a shorter rule.

Browsers only honour HSTS when the header arrives over HTTPS; over HTTP it is ignored, so the redirect has to be in place first. And HSTS belongs on the checklist next to the 301 redirects during a platform migration, otherwise the old http:// links from price comparison sites and newsletters remain an open window; the article on shop migration without SEO loss covers the redirect side of that.

HSTS rollout in four stages: max-age from 5 minutes via one week and one month to two years
HSTS rollout in four stages: max-age from 5 minutes via one week and one month to two years

Snippets: Nginx, Caddy and the Shopware .htaccess

The following blocks set the five safe headers plus HSTS. The HSTS value matches what Shopware sends itself; for an application without its own HSTS header you start with max-age=300 from the staged plan instead. The Content Security Policy is deliberately missing; it follows in the next section as a report-only variant.

Nginx

Before the code, the trap that lurks most often in Nginx configurations: according to the Nginx documentation, add_header directives are inherited from the parent block only if the current block contains no add_header directive of its own. A single add_header Cache-Control in location ~* \.(css|js)$ therefore removes all security headers for CSS and JavaScript, without any error message.

# In the server block (HTTPS). add_header in a location block
# replaces ALL add_header of the server block: either set them
# only here or repeat them in every location with its own add_header.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), usb=()" always;
add_header Cross-Origin-Opener-Policy "same-origin-allow-popups" always;
server_tokens off;

The always parameter makes sure the headers are also set on error pages. Without it they only apply to status codes 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308; a 404 page or a 500 would be unprotected.

Caddy

yourshop.com {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Frame-Options "DENY"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        Permissions-Policy "camera=(), microphone=(), geolocation=(), usb=()"
        Cross-Origin-Opener-Policy "same-origin-allow-popups"
        -Server
    }
    reverse_proxy shopware:8000
}

Caddy obtains certificates automatically and redirects HTTP to HTTPS, but does not set an HSTS header on its own and sends Server: Caddy by default. The -Server removes the identifier. If you run Shopware or other services self-hosted with Coolify, set the headers at the proxy in front (Traefik or Caddy), not in the container behind it.

Apache: the Shopware .htaccess

Shopware ships a public/.htaccess with the Composer setup. Everything between # BEGIN Shopware and # END Shopware is regenerated on updates; your own directives therefore belong outside the markers, best below them.

# public/.htaccess – insert AFTER the "# END Shopware" block
<IfModule mod_headers.c>
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" env=HTTPS
    Header always set X-Frame-Options "DENY"
    Header always set X-Content-Type-Options "nosniff"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), usb=()"
    Header always set Cross-Origin-Opener-Policy "same-origin-allow-popups"
    Header unset X-Powered-By
</IfModule>

The env=HTTPS prevents the HSTS header from landing on an HTTP response. If an upstream proxy terminates TLS, the HTTPS variable in Apache may never be set; then the condition does not fire and the header is missing. In that case HSTS belongs at the proxy. Header always set applies to error pages too, like always in Nginx. ServerTokens Prod can only be set in the main Apache configuration, not in the .htaccess.

What Shopware 6 sets itself

A look into the Shopware core puts the effort into perspective: the CoreSubscriber sets four headers on every successful response before it reaches the web server.

What Shopware 6 sets in the storefront response itself and what the web server has to cover
Header Shopware storefront Web server needed?
Strict-Transport-Security max-age=31536000; includeSubDomains, only if Shopware recognises the request as HTTPS Yes, for static files and as a safeguard behind proxies
X-Frame-Options deny Yes, for static files
X-Content-Type-Options nosniff Yes, for static files
Referrer-Policy strict-origin-when-cross-origin Yes, for static files
Content-Security-Policy Storefront: empty. Administration: nonce-based Only for the storefront, if wanted
Permissions-Policy, COOP not set Yes

The headers only apply to responses that pass through PHP. Theme files, media and thumbnails are served directly by the web server, and those responses lack everything the core sets. That is why the snippets above are needed despite the Shopware core.

The second reason is HTTPS detection. Shopware checks with isSecure() whether the request arrived encrypted. Behind a load balancer or Cloudflare that is only true if TRUSTED_PROXIES is configured correctly. Concretely: the proxy terminates TLS and talks plain HTTP to the Shopware container internally; TRUSTED_PROXIES is not set. Shopware sees an HTTP request, omits HSTS, and the shop sends the header only where the web server sets it. If the web server sets no HSTS header either, nothing shows in the browser; the Observatory report shows it.

Duplicate headers with the same value are no problem; for HSTS, RFC 6797 counts only the first one anyway. That is why the snippets use the same values as the core: DENY instead of SAMEORIGIN, one year instead of five minutes.

Request flow in Shopware 6: HTML pages get security headers from the core, static files only from the web server
Request flow in Shopware 6: HTML pages get security headers from the core, static files only from the web server

Content Security Policy: why a broken CSP breaks the shop

The Content Security Policy is the most effective and the most dangerous header on the list. It tells the browser which sources scripts, styles, images, frames and connections may come from. Anything not on the list is not loaded.

That is exactly the problem in a shop: the PayPal checkout button, the Stripe input field, Google Tag Manager, the consent banner and the review widgets are foreign scripts from a CSP's point of view. A script-src 'self' without these hosts means: the page loads, the cart works, and in the checkout not a single payment button appears. The browser log shows the cause, the monitoring shows only a drop in revenue.

Why Shopware does not ship a storefront CSP

For Shopware there is a second hurdle. The core deliberately ships no CSP for the storefront; the parameter shopware.security.csp_templates contains an empty string for storefront, while the administration gets a nonce-based policy and API routes a restrictive default template. At the same time the storefront writes inline scripts into every page, such as window.activeNavigationId and the router configuration in layout/meta.html.twig. A script-src without 'unsafe-inline' or without a matching nonce therefore breaks the storefront on every page.

Shopware does generate a nonce per request in the request attribute _cspNonce, but the storefront templates do not put it into the <script> tags. A real nonce-based CSP for the storefront is therefore a template project, not a header tweak.

The numbers confirm how hard this is in practice. According to the Web Almanac 2025, 92 percent of served CSPs with script-src contain an 'unsafe-inline'; nonces are used by around 20 percent, 'strict-dynamic' by around 10 percent.

And back in 2016 a Google research team showed in the study “CSP Is Dead, Long Live CSP!”, across 26,011 policies examined, that 94.68 percent of policies attempting to restrict scripts can be bypassed. The reason is host allowlists that somewhere contain a JSONP endpoint or an Angular library. The recommendation derived from that is the strict CSP with nonce and 'strict-dynamic'.

Statistic: 92 percent of served Content Security Policies allow unsafe-inline in script-src (Web Almanac 2025)
Statistic: 92 percent of served Content Security Policies allow unsafe-inline in script-src (Web Almanac 2025)

The road to the first CSP: report-only, safe directives, host list

For a shop without a CSP this means: report-only first. The header Content-Security-Policy-Report-Only blocks nothing but reports every violation to the browser console and to a report endpoint. Two weeks of report-only across all page types (home, category, product, cart, checkout, account pages, order confirmation) show which hosts are really needed. That is how long it takes until rare payment methods, newsletter links and account pages that someone opens only every few days have all run through once. Only then does -Report-Only become the enforced policy.

The safe directives right away, script-src as a project. object-src 'none', base-uri 'self', frame-ancestors 'self' and form-action 'self' break nothing in a normal shop and each close a concrete attack class. frame-ancestors replaces X-Frame-Options in all current browsers; setting both together is the clean transitional solution. The script-src is added with the hosts of the services in use, for now with 'unsafe-inline', because the storefront needs it.

The hosts come from the providers' documentation. PayPal lists for its JS SDK *.paypal.com, *.paypalobjects.com and *.venmo.com for script-src, frame-src, connect-src, img-src and style-src. Stripe lists js.stripe.com, *.js.stripe.com, hooks.stripe.com and api.stripe.com. Google Tag Manager needs www.googletagmanager.com and, depending on the tags, further hosts; which tracking scripts may run at all is governed by consent anyway, more on that in the article on cookies, consent and the German TDDDG.

A starting policy for a Shopware storefront with PayPal and Google Tag Manager then looks like this, initially as report-only:

# One directive per line, assembled through a variable
set $csp "default-src 'self'; ";
set $csp "${csp}script-src 'self' 'unsafe-inline' *.paypal.com *.paypalobjects.com *.venmo.com www.googletagmanager.com; ";
set $csp "${csp}style-src 'self' 'unsafe-inline' *.paypal.com *.paypalobjects.com *.venmo.com; ";
set $csp "${csp}img-src 'self' data: *.paypal.com *.paypalobjects.com *.venmo.com www.googletagmanager.com *.google-analytics.com; ";
set $csp "${csp}frame-src *.paypal.com *.paypalobjects.com *.venmo.com; ";
set $csp "${csp}connect-src 'self' *.paypal.com *.paypalobjects.com *.venmo.com *.google-analytics.com analytics.google.com; ";
set $csp "${csp}font-src 'self' data:; ";
set $csp "${csp}object-src 'none'; base-uri 'self'; frame-ancestors 'self'; ";
set $csp "${csp}form-action 'self' *.paypal.com; ";
set $csp "${csp}report-uri https://yourshop.report-uri.com/r/d/csp/reportOnly";
add_header Content-Security-Policy-Report-Only $csp always;

form-action needs the payment hosts whenever a payment method works via form redirect (3-D Secure, buy-now-pay-later providers, bank transfer services). This is a typical report-only finding and particularly expensive in an enforced policy: the customer clicks “Buy now”, the browser refuses the redirect, and the payment never happens. The console then shows a line like Refused to send form data to 'https://www.paypal.com/…' because it violates the following Content Security Policy directive: "form-action 'self'".

In 30 minutes: the procedure

  1. Measure the status quo (5 minutes): enter the domain at MDN's HTTP Observatory or at securityheaders.com and save the result.
  2. Set the five safe headers (10 minutes): take the snippet for your web server, remove the server identifier, reload the configuration.
  3. HSTS (5 minutes): with Shopware, mirror the core value at the web server; with other applications max-age=300; includeSubDomains, over HTTPS only. Check the subdomains that still speak HTTP first.
  4. CSP as report-only (10 minutes): create the starting policy with the hosts of your own payment and tracking providers, enter a report endpoint, click through the checkout once, read the console.
  5. Re-measure: run the Observatory again and compare with the saved result. HSTS to two years and enforcing the CSP follow after weeks, not minutes.
The 30-minute plan for security headers: measure, set five headers, HSTS, CSP report-only, re-measure
The 30-minute plan for security headers: measure, set five headers, HSTS, CSP report-only, re-measure

That takes care of the client side: every browser that opens your shop receives the rules that protect it against downgrade, clickjacking and injected scripts. What the headers do not cover, meaning plugin versions, admin access, cache and database, is checked by a Shopware audit.

Frequently asked questions about security headers

Isn't the redirect from HTTP to HTTPS enough?

No. The redirect happens only after the browser has sent the unencrypted request. HSTS prevents exactly that first request, on every visit after the first one.

How do I check whether the headers actually arrive?

With curl -I https://yourshop.com or in the browser DevTools under Network → Response Headers. Important: check not only the home page but also a CSS file, a product image and a 404 page, because static files and error pages are the ones that slip through most often.

I set includeSubDomains and an internal system without HTTPS is no longer reachable. What now?

Set the header to max-age=0 and open the main domain once in the affected browser; that makes the browser delete the entry. This only works per browser and only if the domain is not on the preload list. With Shopware shops this rollback at the web server does not take effect, because the core header comes first and dictates one year with includeSubDomains. The only permanent fix is to bring the internal system to HTTPS.

My hoster or Cloudflare already sets headers. Do I still have to?

Check instead of assuming. Cloudflare sets HSTS only if it has been enabled in the dashboard, and no CSP. If the proxy sets headers, the same principle applies as between Shopware and the web server: one value per header, maintained in one place.

Why not adopt the strict OWASP CSP right away?

Because default-src 'self' without payment, tracking and font hosts stops the checkout in a shop. The OWASP values are the target for an application without third-party scripts. A shop gets there via report-only, a host list and, if it is to be done properly, nonces in the templates.

Ready for the next step?

Put what you've learned into practice — we'll support you.

Related posts