How to Implement HTTP Security Headers on Apache, Nginx, IIS, and More

Add HTTP security headers on Apache, Nginx, IIS, Caddy, HAProxy, Traefik, Cloudflare, Netlify, Vercel, WordPress, and Express.

Avatar photo
Updated November 7, 2026
Share

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.

HeaderValueWhat it does
Strict-Transport-Securitymax-age=31536000; includeSubDomainsTells browsers to use only HTTPS for the next year
Content-Security-Policyframe-ancestors 'self'; object-src 'none'; base-uri 'self'Stops other sites from framing your pages and blocks plugins and base tag injection
X-Frame-OptionsSAMEORIGINGives older browsers the same framing protection
X-Content-Type-OptionsnosniffStops browsers from guessing a file’s type
Referrer-Policystrict-origin-when-cross-originSends only your domain, not the full URL, to other sites
Permissions-Policycamera=(), 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.

Cloudflare Managed Transforms settings with the Remove X-Powered-By headers and Add security headers options

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-origin keeps 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. Use same-origin-allow-popups if you rely on sign-in or payment pop-ups from another domain.
  • Cross-Origin-Resource-Policy: same-origin stops 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-corp is 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.

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-Protection controlled an XSS filter that browsers have since removed, and MDN warns that the filter could itself create vulnerabilities. Leave it out or set it to 0, which is what Helmet does, and rely on CSP instead.
  • Expect-CT is obsolete. Chromium deprecated it from version 107 because it now enforces Certificate Transparency by default.
  • Feature-Policy was replaced by Permissions-Policy.
  • Server and X-Powered-By reveal your software and version numbers. In Apache, ServerTokens Prod cut the header down to just “Apache”, and in Nginx, server_tokens off left only “nginx”. Set expose_php = Off in php.ini, and in Express call app.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.

Avatar photo
Chandan Kumar
Founder of Geekflare

Chandan Kumar is a founder of Geekflare. He is a technology enthusiast and entrepreneur who loves to help businesses and people around the world. Chandan has worked at BNP Paribas, Citibank, Deutsche Bank, Motorola, and HP. He’s got an in-depth understanding of business software and IT infrastructure.

Thanks to Our Partners

More from Geekflare

Cybersecurity
7 Essential Benefits of Antivirus Software

Antivirus software protects against malware, phishing, ransomware, and identity theft. Here are seven benefits of using…

Cybersecurity
15 Best Cookie Consent Plugins for WordPress in 2026

Already own a WordPress site or looking to create a new one? If so, then you…

Cybersecurity
How AI Firewalls Detect and Block Prompt Injection Attacks

How AI firewalls spot hidden instructions, clean or block risky prompts, guard tool access, and learn…