A default PHP install is tuned for developers, not for an internet-facing server: errors are displayed, the version is advertised, URL wrappers are on and session IDs are accepted from anyone who supplies one. Most of that is fixed in configuration, but only if you know which directives can be overridden by application code, which belong in php.ini only, and which are safety nets rather than security boundaries. This guide goes deeper than the runtime section of our PHP and WordPress Security Guide: it covers php.ini, PHP-FPM pool settings, .user.ini files, session directives, logging and the misconfigurations behind real PHP compromises. It is written for system administrators, platform engineers and AppSec teams who own the PHP runtime on Linux or Windows servers and containers. Directive defaults and modes are taken from the php.net manual as checked on 8 November 2026.
This is an independent guide from NHI Mgmt Group, based on the PHP project's public documentation as of November 2026. It is not sponsored or reviewed by the PHP project. Product features change, so confirm settings against the PHP project's current documentation.
Key takeaways
- Know the change mode of every directive. INI_SYSTEM settings such as disable_functions, allow_url_fopen and expose_php can only be set in php.ini or server configuration; INI_ALL settings such as open_basedir, display_errors and memory_limit can be changed by any script with ini_set(), so pin them with php_admin_value.
- disable_functions and open_basedir are safety nets. The PHP manual itself says open_basedir is not a security boundary and that disable_functions can be circumvented; use them, but isolate untrusted code with separate users, pools or containers.
- The FastCGI socket is a code execution interface. Anyone who can talk to PHP-FPM can run code and pass PHP_ADMIN_VALUE overrides, so bind it to a Unix socket with mode 0660 or restrict listen.allowed_clients.
- Sessions need explicit settings. session.use_strict_mode is off by default; turn it on together with cookie_secure, cookie_httponly, cookie_samesite and use_only_cookies.
- Configuration does not replace patching. Apply PHP security releases promptly (see the PHP 8 ChangeLog) and run a supported branch (8.2 security support ends 31 December 2026).
How identity and access work in PHP
PHP has no user model of its own. Its security posture comes from three identities: the operating system account that runs the interpreter, the web server account that forwards requests to it, and the people or pipelines who can change its configuration. Getting these right matters more than any single directive.
- Process identity: under PHP-FPM every pool runs as the Unix user and group set by the mandatory
userandgroupdirectives. That account should own nothing it serves: code owned by root or a deploy user, readable by the pool user, with write access only to upload, cache and session directories. - One pool per application: separate pools with separate users stop one compromised site reading another's files and secrets. The PHP manual warns that pools are not a full security boundary (for example, all pools share one OPcache instance), so tenants that do not trust each other need separate FPM masters or containers.
- Web server to FPM trust: the web server passes FastCGI parameters, including SCRIPT_FILENAME and optionally PHP_VALUE and PHP_ADMIN_VALUE. Whoever can reach the FPM listener controls those parameters, which is why the listener must be private.
- Configuration layers: php.ini and files under conf.d, then pool files, then web server directives, then per-directory .user.ini (CGI and FastCGI) or .htaccess (Apache module), then ini_set() at runtime. Each later layer can only change directives its mode allows.
Directive change modes
| Mode | Where it can be set | Security directives in this mode | Consequence |
|---|---|---|---|
| INI_SYSTEM | php.ini, server or pool configuration only | disable_functions, allow_url_fopen, allow_url_include, enable_dl, file_uploads, user_ini.filename, doc_root, max_memory_limit (8.5) | Application code and .user.ini cannot override them |
| INI_PERDIR | php.ini, .htaccess, .user.ini, httpd.conf, not ini_set() | upload_max_filesize, post_max_size, max_input_vars, max_input_time | Anyone who can write a .user.ini in the web root can change them |
| INI_ALL | Anywhere, including ini_set() | open_basedir (tighten only at runtime), display_errors, log_errors, error_log, memory_limit, max_execution_time, include_path | Pin with php_admin_value or php_admin_flag, which ini_set() cannot override |
Admin roles and privileged access
Changing PHP configuration is a privileged action: a single line can turn on display_errors, re-enable shell functions or open a URL include path. Treat the following as privileged and control who holds them.
- Write access to php.ini, conf.d and pool files: root or a configuration management service account only. Manage these files in Git and deploy them through configuration management so every change is reviewed. See the NIST SP 800-128 Security-Focused Configuration Management Guide.
- Write access to the web root: any account that can drop a .user.ini (FastCGI) or .htaccess (mod_php) can change INI_PERDIR and INI_ALL settings for that tree. Where applications do not need it, set
user_ini.filename =(empty) in php.ini to stop PHP scanning for .user.ini files; the default re-read interval is 300 seconds. - Diagnostic endpoints: phpinfo() output, the FPM
pm.status_pathandping.pathpages, and OPcache dashboards reveal paths, versions, loaded extensions and, for phpinfo(), environment variables. Remove phpinfo files from production, keep status pages off public virtual hosts, and usepm.status_listento serve status on a separate internal address. - Restart rights: anyone who can reload php-fpm can apply configuration. Limit sudo rules to the specific reload command and log them.
Integrations, API tokens and service accounts
PHP applications hold database passwords, SMTP keys, payment gateway secrets and cloud credentials. Configuration decides how far those secrets travel.
- clear_env: defaults to yes in FPM pools, so workers start with an empty environment and only the variables you pass with
env[NAME] = value. Leave it on and pass only what each pool needs; setting it to no hands every worker the whole master environment. - Environment and phpinfo(): secrets passed as environment variables appear in phpinfo() output and in $_SERVER, and frameworks print them on debug error pages. Combined with display_errors = On this is one of the commonest secret leaks in PHP. Prefer secret files readable only by the pool user, or a secrets manager. See Environment Variables and .env Files.
- Database and mail accounts: give each application its own database account with only the privileges it uses, and its own SMTP or API credential, so a leak from one site is contained and revocable. These are non-human identities and belong in your inventory.
- Outbound access: allow_url_fopen (default 1, INI_SYSTEM) lets file functions fetch URLs. Applications that call APIs usually use cURL instead; if yours does, set allow_url_fopen = Off and add egress filtering so a server-side request forgery bug cannot reach cloud metadata endpoints.
- Session storage: session files contain authenticated state. Keep
session.save_pathin a directory readable only by the pool user, never a shared world-readable /tmp, or use a dedicated Redis or Memcached instance with authentication.
Logging and detection
Production PHP should log everything and display nothing. The manual describes display_errors as a feature that "should never be used on production systems", yet its built-in default is 1 (php.ini-production sets it Off), so an install without a production php.ini displays errors.
| Source | Setting | What it shows | Watch for |
|---|---|---|---|
| PHP error log | log_errors = On, error_log = /var/log/php/app-error.log or syslog | Warnings, fatals, open_basedir violations, disabled function calls | "open_basedir restriction in effect", "has been disabled for security reasons", sudden spikes of include failures |
| FPM worker output | catch_workers_output = yes (default no sends stdout and stderr to /dev/null) | Output from workers outside the PHP error handler | Unexpected output from child processes |
| FPM access log | access.log per pool (not set by default) | Scripts executed, status, duration | Requests to .php files in upload or cache directories |
| FPM slow log | slowlog plus request_slowlog_timeout (non-zero) | Stack traces of slow requests | Long-running requests that may be shells or crypto miners |
| File integrity | FIM on php.ini, conf.d, pool files, .user.ini, .htaccess, web root | Configuration and code changes outside deployment | New .user.ini files or auto_prepend_file directives, new PHP files in uploads |
Web shells are the usual outcome of a PHP compromise (MITRE ATT&CK T1505.003 Web Shell, often after T1190 Exploit Public-Facing Application). An unexpected auto_prepend_file in a .user.ini is a known persistence trick, because it runs attacker code before every script in the tree. See PHP web shells for detection and clean-up.
Metrics that show the baseline is holding:
- Percentage of PHP hosts and images on a branch with security support (target 100%).
- Percentage of hosts passing the automated php.ini baseline check (display_errors, expose_php, allow_url_include, session.use_strict_mode, enable_dl) on every build.
- Number of publicly reachable FPM listeners, status pages or phpinfo files found by external scanning (target 0).
- Count of .user.ini or .htaccess files changed outside deployment pipelines per month.
- Mean time from a PHP security release to production rollout.
Hardening baseline
The baseline below combines the OWASP PHP Configuration Cheat Sheet with the php.net manual. Test it in a staging pool first: allow_url_fopen, disable_functions and open_basedir break applications that rely on them.
| Directive | Built-in default | Production value | Why |
|---|---|---|---|
| expose_php | 1 | Off | Removes the X-Powered-By version header used for targeting (ATT&CK T1592.002) |
| display_errors / display_startup_errors | 1 / 1 | Off / Off | Stops paths, queries and secrets reaching visitors |
| log_errors, error_reporting | 0, E_ALL | On, E_ALL | Keeps the evidence in a log |
| allow_url_include | 0 (deprecated since 7.4) | Off | Blocks remote file inclusion |
| allow_url_fopen | 1 | Off where the application allows | Limits SSRF and remote content reads through file functions |
| enable_dl | 1 (deprecated) | Off | The manual notes dl() can bypass open_basedir |
| disable_functions | empty | exec, passthru, shell_exec, system, proc_open, popen, phpinfo, show_source (adjust per application) | Raises the cost of turning a bug into a shell |
| open_basedir | NULL | Application root plus its temp and upload directories | Limits file reads after a path traversal or inclusion bug |
| file_uploads, upload_max_filesize, max_file_uploads | 1, 2M, 20 | Off if unused; otherwise smallest workable size and count | Reduces upload abuse and resource exhaustion |
| post_max_size, memory_limit, max_execution_time, max_input_vars | 8M, 128M, 30, 1000 | Sized to the application, pinned with php_admin_value | Denial of service limits; max_input_vars mitigates hash collision attacks |
| session.use_strict_mode | 0 | 1 | Rejects session IDs the server did not create (session fixation) |
| session.cookie_secure, cookie_httponly, cookie_samesite | Off, Off, empty | On, On, Lax or Strict | HTTPS only, no script access, CSRF reduction |
| session.use_only_cookies, use_trans_sid | 1, 0 | 1, 0 | No session IDs in URLs; enabling use_trans_sid is deprecated as of 8.4 |
| session.cache_limiter | nocache | nocache | Prevents caching of authenticated pages |
| user_ini.filename | .user.ini | Empty, unless per-directory settings are needed | Stops writable directories changing configuration |
| zend.exception_ignore_args | Off (php.ini-production sets On) | On | Keeps function arguments (including passwords) out of stack traces |
open_basedir and disable_functions in detail
- open_basedir matching: the value is a directory, not a prefix: /www/ does not match /www2/. Symlinks are resolved before the check. Avoid "." because chdir() changes what it means. Since PHP 8.3 a runtime value containing ".." is rejected. Setting open_basedir disables the realpath cache, which costs some performance.
- open_basedir gaps: it does not restrict Unix domain sockets, so a script can still reach /var/run/docker.sock if the pool user has permission. Socket permissions are the real control.
- disable_functions scope: it covers internal functions only and must be in php.ini (a pool definition appends to the php.ini list rather than replacing it). Since PHP 8.0 a disabled function is removed, so code can define its own function of that name. disable_classes was removed in PHP 8.5.
PHP-FPM pool baseline
- listen: a Unix socket such as /run/php/app.sock with listen.owner and listen.group set to the web server and listen.mode = 0660. If TCP is unavoidable, bind to a private address and set listen.allowed_clients, which otherwise accepts any address.
- security.limit_extensions = .php: the default also parses .phar; restricting it stops FPM executing files with other extensions.
- Path handling: on nginx, check that a script exists before passing it to FPM (try_files or an equivalent check) so a request such as /uploads/image.jpg/x.php cannot execute an uploaded file. Review fastcgi_split_path_info settings with the same care.
- request_terminate_timeout and pm.max_children: set both so a runaway or malicious request cannot pin all workers.
- chroot: available per pool, but it complicates DNS, time zones and libraries; containers with a read-only root filesystem usually give the same isolation with less breakage.
Choosing an isolation model
| Model | Isolation | Fits | Trade-off |
|---|---|---|---|
| Single pool, single user | None between applications | One application per server | Any compromise reaches every file the user can read |
| Pool per application, user per pool | File system separation by Unix permissions | Small to medium hosting of trusted applications | Shared OPcache and master process; not for hostile tenants |
| FPM master per tenant or container per application | Process, file system and network namespaces | Multi-tenant hosting, regulated workloads, large estates | More memory and operational tooling |
Small organisations with one application should still run a dedicated pool user and the full php.ini baseline. Large estates should build PHP container images from a single hardened php.ini, test them in CI, and forbid per-site overrides except through reviewed pool files. Windows hosts should not expose php-cgi.exe, and should run a current supported release.
Rolling out the baseline
- Inventory: record every PHP version, SAPI (FPM, mod_php, CGI), php.ini path (php --ini) and pool file. Flag branches without security support; see Unsupported PHP versions.
- Capture current effective settings: dump ini_get_all() from a protected CLI or internal endpoint per pool, so you compare the effective values, not just files.
- Apply the no-break settings first: expose_php, display_errors, log_errors, allow_url_include, enable_dl, session cookie flags and session.use_strict_mode rarely break applications.
- Pilot the breaking settings: enable allow_url_fopen = Off, disable_functions and open_basedir on a staging pool, run the full test suite and watch the error log for "has been disabled" and "open_basedir restriction" messages for at least one business cycle.
- Lock and deploy: pin INI_ALL values with php_admin_value in the pool file, disable .user.ini scanning where possible, move FPM to Unix sockets, and ship the files through configuration management.
- Roll back safely: keep the previous php.ini and pool files versioned; rollback is a redeploy plus
php-fpm -t(configuration test) and a graceful reload, so roll back a single directive rather than the whole baseline. - Verify continuously: run the baseline check in CI for images and on a schedule for servers, and scan externally for X-Powered-By headers, phpinfo pages and status endpoints.
Common misconfigurations
- Development php.ini in production: display_errors on and debug pages that print environment variables, which hands database and API credentials to anyone who triggers an error.
- Relying on open_basedir for multi-tenant isolation: the manual calls it an extra safety net; tenants sharing a Unix user can still reach each other through sockets, shared session directories or a shared OPcache.
- FPM listening on 0.0.0.0:9000: with listen.allowed_clients unset, any host that reaches the port can execute code and inject PHP_ADMIN_VALUE settings.
- Writable web root with .user.ini enabled: an upload bug becomes a configuration change, for example an auto_prepend_file that loads a web shell into every request.
- Uploads executable as PHP: no try_files check and security.limit_extensions left at defaults, so an uploaded file is executed. Pair configuration with a web server rule that refuses PHP execution in upload directories.
- Hardening an unsupported branch: no setting replaces an interpreter security update; move to a supported branch and apply security releases.
Framework mapping
| Control area | NIST SP 800-53 Rev. 5 | NIST CSF 2.0 | CIS Controls v8.1 | ISO/IEC 27001:2022 Annex A |
|---|---|---|---|---|
| Hardened php.ini baseline under change control | CM-2, CM-6 | PR.PS-01 | 4.1 | A.8.9 |
| Disabled functions, URL wrappers, unused extensions | CM-7 | PR.PS-01 | 4.8 | A.8.9 |
| Pool users, file ownership, socket permissions | AC-6 | PR.AA-05 | 3.3 | A.8.2, A.8.3 |
| Private FastCGI listener, egress limits | SC-7 | PR.IR-01 | 4.4 | A.8.20 |
| Error, access and slow logs, file integrity | AU-2, SI-4 | PR.PS-04, DE.CM-09 | 8.2 | A.8.15, A.8.16 |
| Supported branch and patching | SI-2 | ID.RA-01 | 7.4 | A.8.8 |
Practitioner checklist
- Confirm every PHP install is on a branch with security support and record its SAPI and php.ini path.
- Set expose_php, display_errors and display_startup_errors Off and log_errors On with a protected error_log.
- Set allow_url_include and enable_dl Off, and allow_url_fopen Off where the application allows.
- Apply disable_functions and open_basedir per application after a staging pilot, and treat both as safety nets.
- Turn on session.use_strict_mode, cookie_secure, cookie_httponly, cookie_samesite and a private session.save_path.
- Run one FPM pool and Unix user per application, on a Unix socket with mode 0660, with security.limit_extensions = .php.
- Pin INI_ALL values with php_admin_value and disable .user.ini scanning unless needed.
- Remove phpinfo files and keep FPM status and ping pages off public hosts.
- Keep clear_env on and pass secrets by file or secrets manager, not broad environment variables.
- Version all PHP configuration, monitor it with file integrity checks and test the baseline in CI.
Standards and references
- PHP Manual: Description of core php.ini directives
- PHP Manual: FPM configuration
- PHP Manual: Securing session INI settings
- PHP Manual: Error handling runtime configuration
- PHP: Supported versions
- OWASP: PHP Configuration Cheat Sheet
- NIST: SP 800-53 Rev. 5
Related NHI Mgmt Group resources: PHP and WordPress Security Guide · PHP web shells · Laravel Security Guide · Environment Variables and .env Files · Web Application Firewall Guide