How to add security headers on Nginx or Apache

Security headers are short instructions your server sends with every page. They tell browsers to use HTTPS, not to guess file types and not to let other sites show your pages in a hidden frame. Adding them takes one small file.

Go slowly with one header: Content Security Policy can break a site if you turn it on straight away. This guide starts it in "report only" mode, so you can see what it would block before it blocks anything (step 4).

Before you start

  • Your site already works over HTTPS. If not, follow our HTTPS with Let's Encrypt guide first.
  • You can log in with sudo and edit files with nano or another editor.
  • The examples use example.com. Use your own domain. PHP is 8.3 on Ubuntu 24.04 and 8.4 on Debian 13. Change the number if yours is different (php -v shows it).

1. See what your site sends now

curl -sI https://example.com

This shows the headers without the page. Look for lines like these:

Server: nginx/1.24.0 (Ubuntu)
X-Powered-By: PHP/8.3.6

Both tell attackers exactly which versions you run. You can also test with our free site checker, which shows each missing header in plain words.

2. Hide the version numbers

Nginx: check whether the setting is already on:

grep -n server_tokens /etc/nginx/nginx.conf

On Debian 13 it already says server_tokens off;, so skip ahead. On Ubuntu the line starts with #, which means it's turned off. Remove the #:

sudo sed -i 's/# server_tokens off;/server_tokens off;/' /etc/nginx/nginx.conf

Don't add a second server_tokens line somewhere else. Nginx refuses to start with a "duplicate" error.

Apache: open /etc/apache2/conf-available/security.conf and change these two lines (Ubuntu and Debian ship them as OS and On):

ServerTokens Prod
ServerSignature Off

PHP: Ubuntu and Debian packages already hide the PHP version. Check it anyway, because other installs and old servers may not:

grep -n '^expose_php' /etc/php/8.3/fpm/php.ini /etc/php/8.3/apache2/php.ini

If it says expose_php = On, change it to Off in that file. Then restart PHP: sudo systemctl restart php8.3-fpm for PHP-FPM, or restart Apache if PHP runs inside Apache.

3. Add the safe headers

These five rarely break anything:

  • Strict-Transport-Security (HSTS): always use HTTPS for this site.
  • X-Content-Type-Options: don't guess file types, so an uploaded file can't be run as a script.
  • X-Frame-Options: only my own site may show my pages in a frame. This stops "click on the hidden button" tricks.
  • Referrer-Policy: when someone clicks a link to another site, share only the domain, not the full page address.
  • Permissions-Policy: this site never uses the camera, microphone or location.

Nginx

Put the headers in one file, so every site and every location can use the same list. Create /etc/nginx/snippets/security-headers.conf:

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

The word always sends the headers on error pages too. Then add this line inside the server { } block that has listen 443 ssl, in your site's file in /etc/nginx/sites-available/:

include snippets/security-headers.conf;

The common trap: if a location block has its own add_header line (often for caching images, CSS or JavaScript), Nginx drops all the server-level headers for those files. Add the same include line inside that location block too:

location ~* \.(css|js)$ {
    add_header Cache-Control "public, max-age=86400";
    include snippets/security-headers.conf;
}

Test and reload:

sudo nginx -t && sudo systemctl reload nginx

Apache

Turn on the headers module and create /etc/apache2/conf-available/security-headers.conf:

sudo a2enmod headers
<IfModule mod_headers.c>
    Header always set X-Content-Type-Options "nosniff"
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
</IfModule>

Turn it on for all sites:

sudo a2enconf security-headers

HSTS belongs only on the HTTPS site, so add it inside the <VirtualHost *:443> block. Certbot puts that block in a file ending in -le-ssl.conf in /etc/apache2/sites-available/:

Header always set Strict-Transport-Security "max-age=31536000"

Test and reload:

sudo apache2ctl configtest && sudo systemctl reload apache2

About HSTS: browsers remember it for the time you set. If HSTS is new for your site, start with max-age=300 (5 minutes) and raise it to 31536000 (one year) after a week with no problems. If you already added HSTS with our HTTPS guide, keep only one copy of the line.

4. Add a Content Security Policy, in report-only mode

A Content Security Policy (CSP) lists where your pages may load scripts, styles and images from. If an attacker manages to inject a script, the browser refuses to run it. That makes it the strongest header here. It can also block your own analytics, fonts or chat widget if you forget to list them.

So start with Content-Security-Policy-Report-Only. The browser only reports what it would block. Nginx, add to the snippet file:

add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data: https:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;

Apache, add inside the <IfModule> block:

Header always set Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data: https:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'"

In plain words: load everything from my own site, allow images from anywhere over HTTPS, no plugins, and only my own site may frame my pages.

Reload, then open your site in a browser, press F12 and click Console. Click through your main pages, the login, forms and checkout. Each message marked Report Only names something the policy would block, for example a script from a font or analytics service. Add those addresses to the policy, for example script-src 'self' https://analytics.example.net.

When the console stays quiet for a few days, change Content-Security-Policy-Report-Only to Content-Security-Policy and reload. Now it really blocks.

WordPress and many older apps use inline scripts, so a strict policy needs a lot of testing. If the list of reports is long, keep report-only mode for now. The other headers still protect you.

5. Check the result

curl -sI https://example.com
curl -sI https://example.com/this-page-does-not-exist

Both should show the new headers, and Server: should show only nginx or Apache, without a version. Also test a CSS or image file, because of the Nginx trap in step 3. Then run our site checker again. With the headers above, every check passes. Content Security Policy shows a warning until you leave report-only mode, and HSTS shows one while max-age is below six months.

If something goes wrong

  • nginx -t says "duplicate": the setting already exists in another file. Find it with sudo grep -rn server_tokens /etc/nginx/ and keep only one.
  • Apache says Invalid command 'Header': the headers module is off. Run sudo a2enmod headers.
  • Headers are missing on some files: that's the Nginx location trap from step 3.
  • A header shows up twice: your app (or a plugin) sends it too. Two copies with the same value do no harm. If the values differ, remove one.
  • Your site no longer loads inside a frame on another of your domains: that's what X-Frame-Options does. Leave out that header and use frame-ancestors 'self' https://other.example.com in your CSP instead.
  • Old guides say to add X-XSS-Protection: don't. Modern browsers ignore it, and in old browsers it could cause new problems.

Official sources

Next steps: make sure nobody can download your .env, .git or backup files, then work through the rest of the security checklist for a new PHP server.

ServerNest is being built to do this for each site it sets up: hide the version numbers, send the basic security headers and test the web server settings before every reload. Join early access to try it first.

← Back to the security blog