Security headers are instructions that your server sends with each response. They tell the browser to stick to HTTPS, refuse to show in frame, stop guessing file types, and limit what a page is allowed to load. Adding them takes a few minutes, and they cut the risk of clickjacking, cross-site scripting attacks, and URLs leaking.
This guide covers how to add OWASP recommended headers on Apache, Nginx, Caddy, HAProxy, Traefik, WordPress, and Express with Helmet.
A Safe Starting Set of Headers
These six headers rarely break a site, so add them first.
| Header | Value | What it does |
|---|---|---|
| Strict-Transport-Security | max-age=31536000; includeSubDomains | Tells browsers to use only HTTPS for the next year |
| Content-Security-Policy | frame-ancestors 'self'; object-src 'none'; base-uri 'self' | Stops other sites from framing your pages and blocks plugins and base tag injection |
| X-Frame-Options | SAMEORIGIN | Gives older browsers the same framing protection |
| X-Content-Type-Options | nosniff | Stops browsers from guessing a file’s type |
| Referrer-Policy | strict-origin-when-cross-origin | Sends only your domain, not the full URL, to other sites |
| Permissions-Policy | camera=(), microphone=(), geolocation=() | Turns off browser features your site doesn’t use |
Check two things before you add them. Use HSTS only when your main domain and every subdomain work over HTTPS, because browsers will refuse HTTP and your sites will be broken. If your site uses the camera, microphone, or location, take that feature out of Permissions-Policy, and if other sites are supposed to embed your pages, loosen the framing rules.
Apache
Add these lines to the virtual host that serves HTTPS, or to the main config to cover every site. The mod_headers module has to be loaded, and on Ubuntu you can enable it with sudo a2enmod headers.
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Header always set Content-Security-Policy "frame-ancestors 'self'; object-src 'none'; base-uri 'self'"
Header always set X-Frame-Options "SAMEORIGIN"
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=()"
Keep the always keyword. Without it, Apache served its 404 page with none of these headers.
There’s one thing to know though. When your application already sends a header, Header always set adds a second copy rather than replacing it.
WordPress sends its own X-Frame-Options on the login page, and with this config that page returned both SAMEORIGIN and DENY. Removing the application’s copy first fixed it.
Header unset X-Frame-Options
Header always set X-Frame-Options "SAMEORIGIN"
The same lines work in an .htaccess file on shared hosting, provided the host’s AllowOverride setting includes FileInfo. Test the config and reload Apache to apply it.
sudo apachectl configtest
sudo systemctl reload apache2
Nginx
Add the headers to the server block that handles HTTPS.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "frame-ancestors 'self'; object-src 'none'; base-uri 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" 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=()" always;
Nginx only adds headers to successful responses and redirects unless you include always, and my 404 page came back without them when I left it out.
A location block that has any add_header line of its own ignores every header from the server block.
In my test, an /assets/ location that set only Cache-Control returned none of the security headers. On Nginx 1.29.3 or later, the add_header_inherit directive fixes this.
location /assets/ {
add_header_inherit merge;
add_header Cache-Control "public, max-age=31536000";
}
On older versions, repeat the security headers inside each location that sets its own, or keep them in a separate file and include it in each block.
Check the config and reload with sudo nginx -t and sudo systemctl reload nginx.
Microsoft IIS
In IIS, add the headers to the site’s web.config file. The same file can remove X-Powered-By and, on IIS 10 or later, the Server header.
<configuration>
<system.webServer>
<httpProtocol>
<customHeaders>
<remove name="X-Powered-By" />
<add name="Strict-Transport-Security" value="max-age=31536000; includeSubDomains" />
<add name="Content-Security-Policy" value="frame-ancestors 'self'; object-src 'none'; base-uri 'self'" />
<add name="X-Frame-Options" value="SAMEORIGIN" />
<add name="X-Content-Type-Options" value="nosniff" />
<add name="Referrer-Policy" value="strict-origin-when-cross-origin" />
<add name="Permissions-Policy" value="camera=(), microphone=(), geolocation=()" />
</customHeaders>
</httpProtocol>
<security>
<requestFiltering removeServerHeader="true" />
</security>
</system.webServer>
</configuration>
If you’d rather click than edit XML, open the site in IIS Manager, double-click HTTP Response Headers, and choose Add for each header.
Microsoft’s documentation covers both methods.
Caddy
Caddy’s header directive covers normal responses but not the error pages Caddy generates. My first config sent a 404 with none of the headers. Defining them once as a snippet and importing it into handle_errors as well covered both.
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
Content-Security-Policy "frame-ancestors 'self'; object-src 'none'; base-uri 'self'"
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
-Server
}
}
example.com {
import security_headers
root * /var/www/html
file_server
handle_errors {
import security_headers
respond "{err.status_code} {err.status_text}"
}
}
The -Server line removes the Server header from the response.
HAProxy
Set the headers in the frontend with http-after-response. I also tried http-response, which covered pages from the backend but missed a 503 error that HAProxy generated itself.
frontend web
bind :443 ssl crt /etc/haproxy/certs/example.com.pem
http-after-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains"
http-after-response set-header Content-Security-Policy "frame-ancestors 'self'; object-src 'none'; base-uri 'self'"
http-after-response set-header X-Frame-Options "SAMEORIGIN"
http-after-response set-header X-Content-Type-Options "nosniff"
http-after-response set-header Referrer-Policy "strict-origin-when-cross-origin"
http-after-response set-header Permissions-Policy "camera=(), microphone=(), geolocation=()"
default_backend app
set-header replaces any copy the backend already sent, so you won’t get duplicates.
Traefik
Traefik’s headers middleware has named options for most of these. Define it in the dynamic configuration and attach it to each router.
http:
middlewares:
security-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
contentSecurityPolicy: "frame-ancestors 'self'; object-src 'none'; base-uri 'self'"
customFrameOptionsValue: "SAMEORIGIN"
contentTypeNosniff: true
referrerPolicy: "strict-origin-when-cross-origin"
permissionsPolicy: "camera=(), microphone=(), geolocation=()"
routers:
app:
rule: "Host(`example.com`)"
middlewares:
- security-headers
service: app
tls: {}
You can avoid using the browserXssFilter option. It adds the outdated X-XSS-Protection header covered later in this guide.
Cloudflare
If your site sits behind Cloudflare, you can add headers at the edge without touching the server.
Go to Rules, then Overview, select Create rule, and choose Response Header Transform Rule.
Use the Set static operation for each header, so it replaces any copy from your server, then deploy the rule.
For HSTS, use the Enable HSTS option under SSL/TLS, then Edge Certificates.
Cloudflare also has one-click options under Rules, then Settings, in the Managed Transforms list.

The Remove X-Powered-By headers option is worth turning on.
I’d skip Add security headers, though. Cloudflare shows that it sends X-XSS-Protection: 1; mode=block, a value MDN now advises against, and it doesn’t let you change any of the values.
Netlify and Vercel
On Netlify, save a plain file named _headers in your publish directory. The /* path applies the headers to every page.
/*
Strict-Transport-Security: max-age=31536000; includeSubDomains
Content-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
On Vercel, add a headers entry to vercel.json. The vercel.json reference has more options, such as matching specific paths.
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains" },
{ "key": "Content-Security-Policy", "value": "frame-ancestors 'self'; object-src 'none'; base-uri 'self'" },
{ "key": "X-Frame-Options", "value": "SAMEORIGIN" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" }
]
}
]
}
WordPress
Setting headers at the server or CDN covers every response, so use one of the methods above if you can. If you can’t, a small must-use plugin works.
Save this as wp-content/mu-plugins/security-headers.php, and WordPress will load it automatically.
<?php
/*
* Plugin Name: Security headers
*/
add_action( 'send_headers', function () {
header( 'Strict-Transport-Security: max-age=31536000; includeSubDomains' );
header( "Content-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'" );
header( 'X-Frame-Options: SAMEORIGIN' );
header( 'X-Content-Type-Options: nosniff' );
header( 'Referrer-Policy: strict-origin-when-cross-origin' );
header( 'Permissions-Policy: camera=(), microphone=(), geolocation=()' );
header_remove( 'X-Powered-By' );
} );
If you prefer UI mode, the HTTP Headers plugin has more than 40,000 active installations and covers the same headers.
Node.js and Express
For Express, the Helmet package sets a strict group of headers in one line.
npm install helmet
const express = require("express");
const helmet = require("helmet");
const app = express();
app.use(helmet());
Its default CSP allows scripts only from your own domain, Referrer-Policy is set to no-referrer, and it adds Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy. It also removes the X-Powered-By: Express header that a plain Express app sends.
Test your pages with it before deploying, and configure the CSP if you load scripts, fonts, or images from other domains.
What Each Header Does
Strict-Transport-Security
HSTS tells the browser to use HTTPS for your domain for the number of seconds in max-age, even if someone types http://. Add includeSubDomains to cover every subdomain.
Browsers ignore the header when it arrives over plain HTTP, so it only takes effect after a visit over HTTPS.
Start with a short max-age, such as 300 seconds, and raise it once nothing breaks. The preload directive is only for sites applying to the browser preload list, which requires a max-age of at least one year and includeSubDomains, and is slow to undo.
Content-Security-Policy
CSP lists the sources a page may load scripts, styles, images, frames, and other content from. The starting value only controls framing, plugins, and the base tag, so it won’t break your scripts. A full policy that also limits scripts is what protects against cross-site scripting, and it usually looks more like this.
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googletagmanager.com; img-src 'self' data: https:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'
Every site needs its own list, so roll it out in report-only mode first. Browsers report violations without blocking anything, and the report-to directive sends those reports to the endpoint you name in a Reporting-Endpoints header.
Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-to csp-endpoint
Add the sources your pages need until the reports go quiet, then switch to the enforcing header. Avoid 'unsafe-inline' in script-src, since it undoes most of the XSS protection, and use nonces or hashes for inline scripts instead.
For more detail on the framing directive, see our guide to configuring frame-ancestors.
X-Frame-Options
This older header blocks clickjacking by controlling who can put your page in a frame. Use DENY to block all framing or SAMEORIGIN to allow only your own site.
The ALLOW-FROM value is obsolete, and modern browsers ignore the whole header when they see it, so use frame-ancestors in CSP to allow specific sites. The header has no effect when set in a meta tag.
X-Content-Type-Options
The only value is nosniff. It stops browsers from guessing a file’s type, which closes off attacks that disguise a script as an image or text file.
Browsers will then block scripts and stylesheets served with the wrong Content-Type, so check that your server labels .js and .css files correctly.
Referrer-Policy
This header controls how much of the current URL goes to the next site in the Referer header. Browsers already default to strict-origin-when-cross-origin, which sends the full URL within your site and only your domain to others.
Setting it explicitly keeps that behavior consistent across browsers. Use same-origin or no-referrer if your URLs can contain private data such as reset tokens, but sites you link to will see less referral traffic in their analytics.
Permissions-Policy
Permissions-Policy turns browser features on or off for your pages and any frames inside them. An empty list such as camera=() disables a feature, camera=(self) allows it only for your own origin, and you can add specific origins inside the parentheses.
Older guides show values like fullscreen 'none', which is the syntax of the retired Feature-Policy header and the iframe allow attribute. It doesn’t work in Permissions-Policy, so use the structured format shown here.
Cross-Origin Isolation Headers
Three newer headers control how your pages interact with other origins. Test each one, because they can break integrations.
Cross-Origin-Opener-Policy: same-originkeeps your page in a browsing context group with same-origin pages only, so a cross-origin page that opens it, or that it opens, can’t keep a reference to its window. Usesame-origin-allow-popupsif you rely on sign-in or payment pop-ups from another domain.Cross-Origin-Resource-Policy: same-originstops other sites from loading your resources, which suits private files but not images or scripts that partners or a separate CDN domain need to use.Cross-Origin-Embedder-Policy: require-corpis only needed when you want cross-origin isolation for features such as SharedArrayBuffer. It blocks third-party resources that don’t opt in with CORP or CORS.
X-Permitted-Cross-Domain-Policies
This header controls whether Adobe clients such as Flash Player and Acrobat may read cross-domain policy files from your site.
Flash is dead, but setting X-Permitted-Cross-Domain-Policies: none costs nothing, and some scanners still check for it.
Clear-Site-Data
Clear-Site-Data tells the browser to delete your site’s cookies, storage, or cache. Send it only on the logout response, because sending it with every page would wipe sessions on each load. The values have to be quoted, and the header works only over HTTPS.
Clear-Site-Data: "cache", "cookies", "storage"
Cache-Control on Private Pages
Pages that show account details, invoices, or other personal data shouldn’t stay in shared or browser caches. Send Cache-Control: no-store on those pages only, since applying it site-wide would slow down every page.
Cookie Attributes
Session cookies need their own protection in the Set-Cookie header. Secure keeps them on HTTPS, HttpOnly hides them from JavaScript, and SameSite=Lax stops them from being sent with most cross-site requests. Set these in your application or framework.
On WordPress, our guide to HttpOnly and Secure cookies covers the details.
Headers to Remove or Stop Sending
X-XSS-Protectioncontrolled an XSS filter that browsers have since removed, and MDN warns that the filter could itself create vulnerabilities. Leave it out or set it to0, which is what Helmet does, and rely on CSP instead.Expect-CTis obsolete. Chromium deprecated it from version 107 because it now enforces Certificate Transparency by default.Feature-Policywas replaced by Permissions-Policy.ServerandX-Powered-Byreveal your software and version numbers. In Apache,ServerTokens Prodcut the header down to just “Apache”, and in Nginx,server_tokens offleft only “nginx”. Setexpose_php = Offin php.ini, and in Express callapp.disable("x-powered-by")if you don’t use Helmet.
How to Check Your Headers
Request a page with curl and look through the response headers.
curl -sI https://example.com/
Then request a page that doesn’t exist. Error pages are where headers went missing most often in my tests.
curl -sI https://example.com/this-is-404
If a header shows up twice, both your application and your server are setting it. You can also open your browser’s developer tools, select a request on the Network tab, and read its response headers.
Secure Headers Test checks any URL against OWASP’s recommendations.
Conclusion
Start with the six headers at the top, add them where all your traffic passes, and confirm that they appear on error pages as well as normal ones. Then build out a full Content-Security-Policy in report-only mode, since that’s the header that protects most against script injection.
Security headers are one layer. For more protection against attacks on your application, look at a web application firewall.