← All articles

Security headers for Laravel on nginx: what to set and why

· 4 min read

Security headers are instructions your server sends to the browser: only load this site over HTTPS, do not let other sites put it in a frame, do not guess file types. They cost nothing, take ten minutes to add, and each one shuts a door that attackers routinely try.

These are the five that PHPGuard's free scan checks for, because they are the baseline every site should send.

The five headers

Header What it does
Strict-Transport-Security Tells browsers to use HTTPS for your domain, blocking downgrade attacks
X-Frame-Options Stops other sites from embedding yours in a frame (clickjacking)
X-Content-Type-Options Stops browsers from guessing a file's type (MIME sniffing)
Referrer-Policy Controls how much of your URL is sent to other sites
Content-Security-Policy Restricts where scripts, styles and other resources may load from

The nginx config

Put these in the server block of your site, inside the HTTPS server:

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 Content-Security-Policy-Report-Only "default-src 'self'" always;

Test the config and reload:

sudo nginx -t && sudo systemctl reload nginx

The always flag matters. Without it, nginx only adds the header to successful responses, so your error pages (the ones attackers like to poke at) go out without them.

The inheritance trap

This catches almost everyone once. In nginx, add_header directives are inherited from the outer block only if the inner block defines none of its own. If you add a single add_header inside a location block, for example to set a cache header on static files, every security header from the server block silently disappears for that location.

Fix it by repeating the headers in that location, or by keeping them in one file and including it:

# /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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;
location ~* \.(?:css|js|png|jpg|svg|woff2)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    include snippets/security-headers.conf;
}

Header by header

Strict-Transport-Security

max-age=31536000 is one year. Before you send it, make sure HTTPS works on your domain and every subdomain if you add includeSubDomains, because browsers will refuse plain HTTP for that whole period. Do not add preload until you understand that it is very hard to undo.

X-Frame-Options

SAMEORIGIN lets your own pages frame each other and blocks everyone else. Use DENY if you never frame your own pages. The modern equivalent is the frame-ancestors directive inside a CSP, but the older header is still widely checked and does no harm.

X-Content-Type-Options

nosniff is the simplest of the lot and has almost no way to break anything. Add it.

Referrer-Policy

strict-origin-when-cross-origin sends the full URL for same-site navigation and only your domain name to other sites. Browsers default to similar behaviour today, but setting it explicitly means the behaviour does not depend on the visitor's browser.

Content-Security-Policy

The powerful one, and the one that can break your site. A strict policy blocks inline scripts and any resource from a domain you did not list, which includes Google Analytics, CDNs, embedded fonts and the inline scripts many Laravel frontends rely on.

Roll it out in two steps:

  1. Start with Content-Security-Policy-Report-Only, as in the config above. The browser logs what it would have blocked in the developer console, and nothing breaks.
  2. Open your site, click through the important pages, and read the console. Add the domains you actually need, then switch the header name to Content-Security-Policy.

Keep in mind that a header being present is not the same as a policy being good. default-src 'self' is a sound starting point, but once you allow 'unsafe-inline' or whole third-party domains, the protection shrinks. Tighten it over time.

Verify it

Check what your site really sends:

curl -sI https://yourdomain.com | grep -iE 'strict-transport|x-frame|x-content-type|referrer-policy|content-security'

Also test a page that returns an error and one that serves a static file, since those are where the inheritance trap hides.

Or run the free PHPGuard scan: it lists which of the five headers are missing and shows the nginx line to add for each. With a PHPGuard account the check re-runs automatically every 15 minutes, so a later config change that drops a header gets caught.

Check your own site

The free PHPGuard scan checks for exposed .env and .git files, missing security headers and SSL problems in seconds. For continuous monitoring from inside your server, start the 14-day free trial (no card required).

More articles