HostleloBlog
SavedSearch
Visit Hostlelo

Add 4 Missing Security Headers on Apache or Nginx and Check Them

Add nosniff, Referrer-Policy, Permissions-Policy and X-Frame-Options on Apache or Nginx, avoid the always and inheritance traps, and verify with curl -I.

The short answer

Add four low-risk headers: X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, a Permissions-Policy that disables camera, microphone and geolocation, and X-Frame-Options: SAMEORIGIN. On Apache use Header always set in .htaccess; on Nginx use add_header with always, and repeat the lines in any location block that has its own add_header. Then run curl -I on a normal page, a 404 page and a static file to confirm each header appears once.

Reading options

On this page

You can add four small security headers to almost any site in about 15 minutes: X-Content-Type-Options, Referrer-Policy, Permissions-Policy and X-Frame-Options. On Apache you paste four lines into .htaccess, on Nginx you add four add_header lines to your server block, and then you prove they work with one curl -I command.

Here is a fact that surprises a lot of people: on Nginx, a header you add without the word always only shows up on a fixed list of status codes such as 200, 301 and 304. Your 404 and 500 pages go out without it, and most "I added the header but the scanner says it is missing" stories start right there.

By the end of this guide you will have all four headers live on Apache or Nginx, you will know which value to pick for each one and why, and you will have checked them yourself on a normal page and on an error page.

Quick tip: HSTS and Content Security Policy are bigger topics with real ways to lock yourself out, so they have their own guides: see HSTS and security headers in WordPress with .htaccess and Content Security Policy in report-only mode. This guide covers the quick, low-risk ones.

What these security headers actually are

An HTTP response header is a short line of text your server sends before the page itself, telling the browser how to treat what follows. Security headers are the ones that switch on browser protections the browser would not use otherwise.

Think of them as the care label sewn into a shirt. The shirt (your page) is the same either way, but the label tells whoever handles it "do not tumble dry" and "wash cold". The browser reads the label and follows it.

X-Content-Type-Options: stop the browser from guessing

Browsers sometimes "sniff" a file, which means they look at the content and guess its type instead of trusting the Content-Type the server sent. The only valid value is nosniff, and it tells the browser to stop guessing.

MDN says it blocks a stylesheet unless it is served as text/css, and a script unless it has a JavaScript MIME type (MIME type means the file type label, like text/html). MDN's example: a response sent as Content-Type: text/plain with nosniff will not be treated as HTML, even if it contains HTML markup.

Why this matters: if a visitor uploads a file that secretly contains HTML or script, nosniff stops the browser from running it as a web page.

Referrer-Policy: control what your URLs leak

When someone clicks a link on your site, the browser can send the address of your page to the next site in the Referer header. That address can include paths and query strings, such as a search term or an order ID.

The valid values are no-referrer, no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin and unsafe-url.

We will use strict-origin-when-cross-origin. It sends the full URL to your own site, only the origin (for example https://example.com/) to other HTTPS sites, and nothing at all when a link goes from HTTPS to plain HTTP.

But what if that is already the default? It is: MDN says strict-origin-when-cross-origin is the default "if no policy is specified, or if the provided value is invalid", and that the old default was no-referrer-when-downgrade. Setting it explicitly still helps: the behaviour no longer depends on the browser version, and a looser value set elsewhere is easier to spot.

Heads up: never pick unsafe-url. MDN warns it "will leak potentially-private information from HTTPS resource URLs to insecure origins."

Permissions-Policy: switch off features you never use

Permissions-Policy lets you say which powerful browser features, such as camera, microphone and location, your pages and any iframes inside them may use.

The syntax is directive=allowlist. An empty allowlist, written (), means the feature is disabled in your page and in every iframe on it. For example, camera=(), microphone=(), geolocation=() turns off all three.

Two other allowlist values are worth knowing. self allows the feature on your own origin only, and * allows it everywhere.

Heads up: at the time of writing (29 October 2026), MDN marks Permissions-Policy as experimental with limited availability, which means some widely used browsers do not support it. It is still worth sending, because browsers that understand it will use it and the others will simply ignore it.

X-Frame-Options: stop other sites from framing you

Clickjacking is a trick where another site loads your page in an invisible frame so visitors click buttons they cannot see. X-Frame-Options tells the browser who may put your page in a frame.

The two values that work are DENY (no site can frame the page, not even yours) and SAMEORIGIN (only pages from your own origin can frame it). The old ALLOW-FROM value is obsolete, and MDN says modern browsers "will ignore the header completely" if they see it.

We will use SAMEORIGIN, so your own site can still frame its own pages while other sites cannot. If you need to allow specific other sites, use the frame-ancestors directive in a Content Security Policy instead.

Before you start

Two minutes here saves you from the classic "my whole site is down" moment.

  • Know your web server: Apache (you edit .htaccess) or Nginx. If you are not sure, ask your host.
  • Access: for Apache, a way to edit .htaccess (cPanel File Manager, FTP or SSH). For Nginx, SSH access with sudo so you can edit the config and reload the server.
  • A terminal on your own computer with curl installed.
  • A backup of the exact file you will edit. Copy it before you touch it. For a full site backup, see how to back up a site in cPanel and test the restore.
  • A list of anything embedded on your site that uses the camera, microphone or location: video chat, booking widgets, store locators with "use my location", maps.
  • Time: 15 minutes. Risk: low, as long as you test right after saving and keep the backup ready.

Add the headers on Apache with .htaccess

Apache adds headers with the Header directive from the mod_headers module. The Apache docs say it works in the server config, a virtual host, a directory block and .htaccess. In .htaccess it needs AllowOverride FileInfo, which most shared hosts allow.

Two words in the snippet matter. set means "set this header, replacing any earlier header with the same name", so you do not end up with two copies. always means the header is also added to error responses, while the default (onsuccess) can miss them.

Step 1: Back up the .htaccess file

In cPanel, open cPanel > Files > File Manager. Click Settings, select Show Hidden Files (dotfiles) and click Save, so .htaccess becomes visible.

Go to your site's folder (often public_html), right-click .htaccess and choose Copy. cPanel will not copy a file into the same folder, so enter your home folder as the destination (the folder above public_html) and click Copy File(s).

If you prefer SSH, this command makes a dated copy next to the original instead.

cp .htaccess .htaccess.bak-$(date +%F)

You should now see the backup copy in the folder you chose.

Step 2: Paste the header block

Right-click .htaccess and choose Edit (or open it in your SSH editor), then paste this block at the very top, above the # BEGIN WordPress line. It sets all four headers on every response, including errors.

# Security headers (added manually)
<IfModule mod_headers.c>
  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=()"
  Header always set X-Frame-Options "SAMEORIGIN"
</IfModule>

Why above the WordPress block? WordPress edits .htaccess itself to handle pretty permalinks, so the safest habit is to keep your own lines outside its # BEGIN WordPress and # END WordPress markers. That way you can always tell which lines are yours.

The <IfModule mod_headers.c> wrapper is our own habit, not something Apache requires. Apache's docs say that if the module is not loaded, "everything between the start and end markers is ignored." So if mod_headers is missing, your site stays up but the headers silently do not appear, which is why you test in Step 4.

But what if a page embeds a booking widget that needs location, or a video call that needs the camera? Change just that feature from () to (self "https://widget.example.com"), using the real address of the service, and leave the rest disabled.

Step 3: Save and load the site

Save the file, then open your home page in a private browser window. It should look exactly the same as before.

If you see "Internal Server Error" instead, do not panic. Restore the backup right away (copy your saved .htaccess back over the edited one) and read the Troubleshooting section below.

Step 4: Confirm the headers are there

Run this from your own computer, with your real domain in place of example.com. It asks the server for headers only, without the page body.

curl -I https://example.com/

You should see these four lines somewhere in the output, mixed in with headers like content-type and date.

x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(), microphone=(), geolocation=()
x-frame-options: SAMEORIGIN

Apache variant: the virtual host instead of .htaccess

If you run your own Apache server, the same four Header always set lines can go inside your site's <VirtualHost *:443> block instead, since the Apache docs list virtual host as a valid place for Header. Back up that file first and keep the copy until the checks pass.

Add the headers on Nginx

Nginx uses add_header name value [always];. It works in the http, server, location and if in location blocks.

Without always, the Nginx docs say the header is only added when the response code is 200, 201, 204, 206, 301, 302, 303, 304, 307 or 308. That is why every line below ends with always.

Step 1: Check your Nginx version

Run this to print the version. It matters for one inheritance setting later.

nginx -v

You will see something like nginx version: nginx/1.24.0. Write it down.

Step 2: Back up the site config

Find the file with the server block for your domain, often in /etc/nginx/sites-available/ or /etc/nginx/conf.d/. This copies it with a date in the name (use your real path).

sudo cp /etc/nginx/sites-available/example.com /etc/nginx/sites-available/example.com.bak-$(date +%F)

Heads up: if your config folder loads every file in it, a backup with a .conf ending could be loaded too. Keep the backup name ending in the date, not .conf, or store it in your home folder.

Step 3: Add the four lines to the server block

Open the file and add these lines inside the server { ... } block that listens on port 443, next to your other settings. They add all four headers to every response, including errors.

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;
add_header X-Frame-Options "SAMEORIGIN" always;

Step 4: Watch out for the inheritance trap

This Nginx rule catches almost everyone. The docs say add_header directives "are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level."

In plain words: if any location block has its own add_header, for example a Cache-Control header for images, then that location gets only its own headers and none of the four you just added at server level.

Here is a made-up example: Priya adds the four headers to her server block and her home page passes. Her CSS and images, served from a location block that sets Cache-Control, come back with none of them.

The fix that works on every Nginx version is to repeat the four lines inside each location that has its own add_header. This is what such a block looks like with the headers repeated.

location ~* \.(css|js|png|jpg|webp|svg)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    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;
    add_header X-Frame-Options "SAMEORIGIN" always;
}

If nginx -v showed 1.29.3 or newer, you have a cleaner option: the add_header_inherit directive. Its default is on (the old behaviour), and the docs say "The merge parameter enables appending values from the previous level to the values defined at the current level." Put this one line inside the location block instead of repeating the four headers.

add_header_inherit merge;

On older versions, that line will make the config test fail, so only use it after you checked the version. For more on caching headers for static files, see Cache-Control for static assets.

Step 5: Test the config, then reload

First run the built-in test. The Nginx docs say -t checks the configuration for correct syntax and then tries to open the files it refers to.

sudo nginx -t

You want it to report that the syntax is ok and the test is successful. If it reports an error instead, read the message: it points at the file and line to fix. Fix that line, or copy your backup back, and run the test again.

Once the test passes, this command tells Nginx to reload. Per the docs, reload starts new worker processes with the new configuration and gracefully shuts down the old ones, so visitors are not cut off.

sudo nginx -s reload

Why this matters: the Nginx control docs say that during a reload the master process "first checks the syntax validity, then tries to apply new configuration," and if this fails, "it rolls back changes and continues to work with old configuration." A bad reload will not take your site down, but you will not get the new headers either.

How to check it worked

Run these from your own computer, so you see what visitors see.

Check a normal page

Run curl -I https://example.com/ as in Apache Step 4. The status line at the top should say 200, and the four header lines should be in the list.

Check an error page

This is the check most people skip. Request a page that does not exist, which should return a 404.

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

All four headers should still be there. If they show on the home page but not here, you are missing always somewhere.

Check a static file

Run curl -I on a real CSS or image URL from your site. On Nginx, this is where the inheritance trap shows up.

curl -I https://example.com/wp-content/themes/your-theme/style.css

You should see the four headers next to your caching headers.

Check for duplicates

In each curl -I output, every header should appear only once. Two x-frame-options lines mean two places manage the same header, and you cannot be sure which value wins.

Troubleshooting

"Internal Server Error" right after saving .htaccess

Symptom: every page returns a 500 error. Likely cause: a typo in the block, or your host does not allow Header in .htaccess (it needs AllowOverride FileInfo, and the Apache docs say directives that are not allowed cause an internal server error). Fix: restore your backup copy of .htaccess at once, then paste the block again carefully, with straight double quotes. If it still fails, ask your host whether mod_headers and FileInfo overrides are enabled.

The headers do not appear at all on Apache

Symptom: curl -I shows none of the four, but the site works fine. Likely cause: mod_headers is not loaded, so the <IfModule> wrapper skipped your lines without complaint. Another cause is that you edited the wrong .htaccess, for example one in a subfolder. Fix: make sure you edited the file in the folder your domain points to, then ask your host to enable mod_headers.

Headers show on the home page but not on 404 pages

Symptom: the normal page passes, the error page check fails. Likely cause: the word always is missing. On Apache the default onsuccess condition can miss error responses, and on Nginx headers without always only go on the listed codes. Fix: add always to every line, then test and reload.

Nginx: headers missing on images, CSS or some URLs

Symptom: the home page has the headers, static files or one section of the site do not. Likely cause: a location block has its own add_header, so the server-level ones are not inherited there. Fix: repeat the four lines in that location, or use add_header_inherit merge; on 1.29.3 or newer.

The same header appears twice

Symptom: curl -I shows two x-frame-options or two referrer-policy lines. Likely cause: a security plugin, your theme, a caching plugin or a proxy or CDN in front of the server is also sending it. Fix: choose one place to manage each header and remove it from the other. On Apache, set replaces any previous header with the same name.

A booking widget, map or payment form stopped working

Symptom: an embedded tool fails, shows a blank frame, or never asks for location or camera. Likely cause: Permissions-Policy turned the feature off, or X-Frame-Options: SAMEORIGIN blocks another site that needs to frame one of your pages. Fix: allow only the feature you need, for example geolocation=(self "https://maps.example.com") with the real service address. If another site must frame your page, drop X-Frame-Options for that page and use the CSP frame-ancestors directive instead.

nginx -t fails on the addheaderinherit line

Symptom: the config test reports an error that points at the add_header_inherit line. Likely cause: your Nginx is older than 1.29.3. Fix: remove that line and repeat the four add_header lines in the location instead.

Pro tips and when to ask for help

  • Keep your security headers in one place. If you set them in the server config, turn off the same option in your security plugin, and the other way round.
  • On managed hosting where you cannot edit .htaccess or the server config, ask your host to add them, using the message below.
  • Once these four are stable, move on to HSTS and CSP with the guides linked at the top.

The one message worth forwarding

If someone else manages your server, copy this and send it as is:

"Hi, could you please add these four response headers to example.com, on all responses including error pages (Apache: Header always set, Nginx: add_header with always)? The headers are X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy: camera=(), microphone=(), geolocation=() and X-Frame-Options: SAMEORIGIN. On Nginx, please also check that location blocks with their own add_header lines still send them. I will verify with curl -I on the home page, a 404 URL and a CSS file. Thanks!"

Four lines, one curl -I on a normal page and one on a 404, and your site is sending browsers a clear set of instructions it was not sending this morning.

Reader questions

Why do my headers appear on normal pages but not on 404 pages?

The always keyword is missing. On Nginx, add_header without always only applies to codes such as 200, 301 and 304, and on Apache the default onsuccess condition can miss error responses.

Do I still need Referrer-Policy if browsers already default to strict-origin-when-cross-origin?

It is optional but useful. Setting it explicitly makes the behaviour the same across browsers and makes a looser value set elsewhere easier to spot.

Should I use X-Frame-Options DENY or SAMEORIGIN?

SAMEORIGIN is the safer default because your own site can still frame its pages. Use DENY only if nothing ever needs to frame your pages, and use CSP frame-ancestors if specific other sites must embed them.

Is ALLOW-FROM still a valid X-Frame-Options value?

No. MDN lists it as obsolete and says modern browsers ignore the whole header when they see it. Use the frame-ancestors directive in a Content-Security-Policy header instead.

Will Permissions-Policy break anything on my site?

It can if an embedded tool needs the camera, microphone or location, such as a video chat, booking widget or store locator. Allow only that feature for the service's origin and keep the rest disabled.

Why are my Nginx headers missing on images and CSS files?

A location block with its own add_header does not inherit the server-level add_header lines. Repeat the headers in that location, or on Nginx 1.29.3 or newer use the directive add_header_inherit merge inside that location.

What happens if my new Nginx config has an error when I reload?

The Nginx docs say the master process checks the syntax first and, if applying the new configuration fails, rolls back and keeps running the old one. Run nginx -t before every reload so you see the error yourself.

Sources & further reading

  1. MDN: X-Content-Type-Options
  2. MDN: Referrer-Policy
  3. MDN: Permissions-Policy
  4. MDN: X-Frame-Options
  5. Apache HTTP Server: mod_headers
  6. Apache HTTP Server: core features (IfModule, AllowOverride)
  7. nginx: ngx_http_headers_module
  8. nginx: Command-line parameters
  9. nginx: Controlling nginx
  10. cPanel Docs: File Manager

Originally published . About our editorial updates.

Your next project deserves a better foundation.

Explore hosting built for your next chapter.

Explore hosting

Find your next answer.

Search published guides and articles.

Ctrl / ⌘ K to open · Esc to close