Secrets and Headers

Secrets, Security Headers and Hiding PHP

Three loose ends turn a hardened application into a hardened deployment: keep secrets out of the code, send the right headers, and stop the server advertising itself. By default expose_php is On, adding an X-Powered-By header that names the exact version.

Serving a page under php -d expose_php=1 versus =0 and reading the header with curl 3,008 :

Output of 187
== expose_php=On ==
X-Powered-By: PHP/8.5.4
== expose_php=Off ==
(no X-Powered-By header)

Set expose_php = Off for production. Secrets, database passwords, API keys, encryption keys, must never live in committed source. Read them from the environment with getenv() (populated by the web server, an orchestrator, or a .env loaded by vlucas/phpdotenv and kept out of git), and make sure the server refuses to serve .env, .git and backups (Subsections 2.8.6 and 2.10.1). Set the headers once in the front controller.

Security headers set for every response from the front controllerPHP
foreach ([
    'X-Content-Type-Options: nosniff',
    'Referrer-Policy: strict-origin-when-cross-origin',
    'Cross-Origin-Opener-Policy: same-origin',
    'Strict-Transport-Security: max-age=31536000; includeSubDomains',
] as $h) header($h);
header_remove('X-Powered-By');            // belt and suspenders if expose_php is still On
Output
X-Content-Type-Options: nosniff
...
Strict-Transport-Security: max-age=31536000; includeSubDomains

These four, plus the CSP of Subsection 4.17.7, are the baseline; Security Headers and CORS explains each and adds CORS. Set errors to log rather than display in production (display_errors = Off, log_errors = On, Subsection 4.13.7) so a stack trace never reaches a visitor, covering misconfiguration, the second OWASP risk.