Security Headers and HTTPS
Response headers that keep the browser on HTTPS, stop it guessing content types, and stop other sites framing your pages.
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');Every response has headers: lines of metadata sent before the body, which the browser reads and obeys. A handful of them turn off browser behaviour you do not want. They cost one function call each and they are the cheapest security you will ever add.
The short list
Strict-Transport-Security(HSTS) tells the browser to use HTTPS for this host for the nextmax-ageseconds, so a laterhttp://link never leaves the machine in the clear.X-Content-Type-Options: nosniffstops the browser guessing a type that contradicts yourContent-Type, which is how an uploaded text file becomes a script.Content-Security-Policywithframe-ancestors 'none'stops other sites loading your page in a frame;X-Frame-Options: DENYis the older header for the same thing and is still worth sending.Referrer-Policy: strict-origin-when-cross-originkeeps paths and query strings out of theReferersent to other sites.
Getting onto HTTPS and staying there
Redirect plain HTTP to HTTPS, but leave the health check path alone: a load balancer checking /healthz over HTTP will read a 301 as a failure and pull the server out of rotation. Behind a proxy, PHP sees the proxy's connection, so $_SERVER['HTTPS'] is unset and the scheme arrives in X-Forwarded-Proto. Trust that header only when the request came from your own proxy, because a client can send it too.
HSTS sticks. Set a small max-age until certificates work on every subdomain.
Example
When to use it
- Hardening an existing site in one commit, before any refactoring.
- Keeping your pages out of frames on someone else's domain.
- Moving a site to HTTPS behind a load balancer without a redirect loop.
- Stopping a container being marked unhealthy by your own HTTPS redirect.
More examples
One file, included first
Put this in a bootstrap file that every entry point requires, so no page can forget. headers_sent() takes two by-reference arguments that name the file and line where output began, which is the fastest way to find a stray blank line. A framework or a web server config can set these instead; the point of doing it here is that it is the same four headers either way.
<?php
declare(strict_types=1);
function sendSecurityHeaders(): void
{
if (headers_sent($file, $line)) {
error_log("Output already started at $file:$line");
return;
}
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: strict-origin-when-cross-origin');
header("Content-Security-Policy: default-src 'self'; frame-ancestors 'none'");
// Not X-XSS-Protection: browsers removed it, and it did more harm
// than good while it existed.
if (isRequestSecure()) {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
}Forcing HTTPS without breaking the health check
The exemption is the part people leave out and then spend an afternoon debugging, because a monitor that follows redirects looks fine while the orchestrator's own check, which does not follow them, marks the container unhealthy. Parse the path out of REQUEST_URI rather than comparing the whole string, since /healthz?probe=1 is the same endpoint. The exit after a Location header matters: without it the page body is still sent.
<?php
declare(strict_types=1);
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?? '/';
if (!isRequestSecure() && $path !== '/healthz') {
$target = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
header('Location: ' . $target, true, 301);
exit;
}
if ($path === '/healthz') {
header('Content-Type: text/plain');
echo "ok\n";
exit;
}Deciding the scheme behind a proxy
X-Forwarded-Proto is just a header, so any client can claim https and skip your redirect. Checking REMOTE_ADDR against the proxies you actually run is what makes it trustworthy. If you forget this function entirely and deploy behind a proxy, the HTTPS redirect loops forever: the proxy terminates TLS, PHP sees plain HTTP, and it redirects a request that was already secure.
<?php
declare(strict_types=1);
function isRequestSecure(): bool
{
// Direct TLS connection to this PHP process.
if (($_SERVER['HTTPS'] ?? '') === 'on') {
return true;
}
// Behind a load balancer the connection to PHP is plain HTTP, so the
// original scheme only survives in a header the proxy adds.
$trustedProxies = ['10.0.0.4', '10.0.0.5'];
if (in_array($_SERVER['REMOTE_ADDR'] ?? '', $trustedProxies, true)) {
return ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
}
return false;
}
Discussion