PHP's default settings are tuned for development, not for production. On a public server they reveal the PHP version and internal paths, and allow dangerous operations.
Finding php.ini
php --ini | grep "Loaded Configuration"
# /etc/php/8.3/fpm/php.ini
PHP-FPM and PHP CLI have different configuration files even on the same system — usually /etc/php/8.3/fpm/php.ini and /etc/php/8.3/cli/php.ini. Edits to one do not apply to the other: settings for cron jobs and console scripts need to be changed separately.
After editing any php.ini, the changes are not picked up automatically — PHP-FPM reads the file once at startup and keeps it in the worker's memory. A forgotten service restart is a common reason why a setting "seems applied" but a check still shows the old value.
sudo systemctl restart php8.3-fpm
Mandatory security settings
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php/errors.log
expose_php = Off
open_basedir = /var/www/site/:/tmp/:/var/lib/php/sessions/
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,phpinfo
file_uploads = On
upload_max_filesize = 10M
max_file_uploads = 5
max_execution_time = 30
memory_limit = 256M
Anatomy of configuration mistakes
display_errors = On in production
If this setting is left at On, any uncaught PHP error gets printed straight into the HTML response and is visible to every visitor:
Fatal error: Uncaught PDOException: SQLSTATE[HY000] [1045]
Access denied for user 'app'@'localhost' in /var/www/site/db.php:12
Stack trace:
#0 /var/www/site/index.php(5): connect()
#1 {main}
thrown in /var/www/site/db.php on line 12
A message like this reveals the full file path on the server, the database username and the code structure — enough to plan the next step of an attack. Errors should go into error_log, not into the browser.
open_basedir set only for FPM
If the restriction is added to /etc/php/8.3/fpm/php.ini but not to cli/php.ini, console scripts and cron jobs run without it. An attack through a web form that passes data into a background CLI task bypasses a protection that was only designed for web requests.
Every virtual host on a shared server can have its own PHP-FPM pool with its own settings file — if you edit the global php.ini but a particular site runs through a pool with its own php_admin_value overriding the parameter you need, the change in the shared file will not affect that specific site.
What each setting changes
| Parameter | Default value | What the hardened setting gives you |
|---|---|---|
| expose_php | On | Removes the PHP version from the server's response header |
| display_errors | On | Errors go to the log instead of the HTML response shown to the visitor |
| open_basedir | not set | PHP cannot read files outside the listed directories |
| disable_functions | empty | Blocks shell commands from being run from PHP code |
Session security
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Strict
session.use_only_cookies = 1
session.use_strict_mode = 1
session.gc_maxlifetime = 1440
session.save_path = /var/lib/php/sessions
Checking the configuration
php -r "echo ini_get('expose_php');"
php -r "echo ini_get('display_errors');"
Also check the live environment, not just the CLI: request the headers of the real site and make sure the web server does not reveal the PHP version.
curl -I https://your-domain/ | grep -i x-powered-by
# Empty output means the header is not sent - correct
When strict settings get in the way
- An application for image or video processing needs an external binary through
shell_exec(for example, calling a separate converter) — in that case remove only the specific function that is actually needed fromdisable_functions, rather than leaving the list empty entirely. - Shared hosting with several sites under different users on one PHP-FPM pool — a single
open_basedirfor everyone will not fit, each virtual host needs its own FPM pool with its own value. - Legacy code that reads files outside the site's directory (for example, from a shared templates folder) — that code needs to be fixed; widening
open_basedir"just in case" brings back the exact vulnerability it is meant to prevent.
Setting up the web server in front of PHP-FPM is covered in hardening Nginx, and protecting WordPress itself on top of these settings is covered in hardening WordPress.