Bali, Indonesia
Insights

Should You Isolate Sites with open_basedir or Separate PHP-FPM Pools?

October 23, 2026
0:00
If you run several PHP sites on one server, someone has probably told you to set open_basedir so a hacked site can't read its neighbors. Someone else has probably said that isn't enough and every site needs its own PHP-FPM pool and Linux user. Both pieces of advice point at real controls, but they work at very different layers. This guide explains what each one restricts and gives you a few commands to check whether your setup holds up.
open_basedir is a PHP setting that lists the directories PHP's own file functions may touch. With it set to /home/sitea/:/tmp/, a call like file_get_contents('/home/siteb/public_html/wp-config.php') fails with a warning, even when the operating system would allow the read. The important detail is who enforces it. PHP does, inside its own process. The kernel knows nothing about it. That creates three gaps. First, it only covers code paths that respect it. If shell_exec, exec, system, passthru, proc_open, or popen are available, an attacker can run cat as a separate process. That child process is not PHP, so open_basedir doesn't apply, and it reads whatever the OS user can read. Second, PHP has a history of reported open_basedir bypasses through symlinks, wrappers, and similar edge cases. The PHP manual itself doesn't treat it as a hard security boundary. Third, it does nothing about the user the process runs as. If every site runs as www-data, all site files must be readable by www-data. Any bypass then exposes every site on the box. So open_basedir is a useful filter, but it doesn't isolate sites.
A PHP-FPM pool is a group of worker processes with its own config: which user it runs as (user and group), which socket it listens on (listen), and which PHP settings it forces (php_admin_value[...]). When site A's pool runs as sitea and site B's runs as siteb, and /home/siteb is not readable by sitea, the kernel refuses the read. It doesn't matter whether the attacker uses PHP functions, a shell command, or a compiled binary dropped into /tmp. This is the layer that holds when PHP-level checks fail. It still has gaps of its own, and support engineers often find them during cleanups after a compromise:
  • Home directory permissions left at 755, so every local user can list and read them.
  • FPM sockets with loose permissions, so site A's user can connect to site B's pool. That lets an attacker ask site B's workers to execute a file as siteb.
  • Pools listening on 127.0.0.1 TCP ports. Any local user can connect to those.
  • Shared session directories or a shared /tmp, where one site can read another's session files.
  • OPcache shared memory, which belongs to the FPM master and is shared across all of its pools. PHP has opcache.validate_permission and opcache.validate_root for this case. Check whether your version supports them.
In practice, the strong setup is separate users and pools as the real boundary, with open_basedir and a sensible disable_functions list on top as extra layers.
Start with read-only inspection. The paths below are Debian/Ubuntu defaults. RHEL-family systems usually keep pools in /etc/php-fpm.d/, and control panels use their own locations, so adjust for your setup.
BASH
# Which system user does each pool's workers run as?
ps -eo user,pid,args | grep '[p]hp-fpm: pool'

# What each pool is configured with: name, user, group, listen, admin values
grep -RnE '^(\[|user|group|listen|php_admin_value)' /etc/php/*/fpm/pool.d/

# Socket ownership and mode
ls -l /run/php/
Check the results for these patterns:
  • Every pool shows the same user in ps. Your sites aren't isolated at the OS level, whatever open_basedir says.
  • A listen = 127.0.0.1:9xxx line. That pool is reachable by any local user.
  • A socket with mode srw-rw-rw-. That socket is world-writable.
The usual target is a socket owned by the web server user and group, with mode 0660, and no site user in that group.
Config files tell you what was intended. A test tells you what actually happens. Run two tests: one from inside site A's PHP pool, and one at the OS level as site A's user. Replace sitea, siteb, the domain, and the paths with your own. Create a temporary file in site A's document root with a random name. It prints only READABLE or blocked, never file contents.
PHP
<?php
// Temporary isolation test. Delete immediately after use.
header('Content-Type: text/plain');
$target = '/home/siteb/public_html/wp-config.php';

if (function_exists('posix_geteuid')) {
    echo 'pool user: ' . posix_getpwuid(posix_geteuid())['name'] . PHP_EOL;
}
echo 'open_basedir: ' . (ini_get('open_basedir') ?: '(not set)') . PHP_EOL;

// PHP-level read: this is what open_basedir filters
echo 'file_get_contents: ' . (@file_get_contents($target, false, null, 0, 1) === false ? 'blocked' : 'READABLE') . PHP_EOL;

// Child-process read: open_basedir does not apply here, only OS permissions do
if (function_exists('shell_exec')) {
    out = shell_exec('head -c 1 ' . escapeshellarg(target) . ' 2>/dev/null');
    echo 'shell_exec: ' . (strlen((string) $out) > 0 ? 'READABLE' : 'blocked') . PHP_EOL;
} else {
    echo 'shell_exec: disabled' . PHP_EOL;
}
Request the file through site A's normal vhost, delete it right away, then repeat the read and socket checks as site A's OS user:
BASH
# Run the in-pool test, then remove the file immediately
curl -s https://sitea.example/iso-test-7f3k.php
rm /home/sitea/public_html/iso-test-7f3k.php

# OS-level checks as site A's user (ignores all PHP settings)
sudo -u sitea head -c 1 /home/siteb/public_html/wp-config.php >/dev/null 2>&1 && echo 'file: READABLE' || echo 'file: blocked'
sudo -u sitea ls /home/siteb/ >/dev/null 2>&1 && echo 'dir: listable' || echo 'dir: blocked'

# Connecting to a Unix socket requires write permission on it
sudo -u sitea test -w /run/php/siteb.sock && echo 'socket: reachable' || echo 'socket: blocked'
Don't leave the test file in place. It reveals your pool user and open_basedir value to anyone who finds it. Socket names depend on how your pools were created, so take the real name from the ls -l /run/php/ output.
The most common bad result is file_get_contents: blocked with shell_exec: READABLE. That means open_basedir is doing its job, but your sites aren't really separated. The OS user can read site B, and any process that isn't PHP gets straight through. Disabling the exec family of functions narrows that path. The proper fix is a separate pool and user for each site. If the OS-level file check says READABLE even though the pools use different users, the problem is permissions. Typical fixes:
  • Set home directories to 750, or 710 when the web server only needs to traverse them.
  • Set wp-config.php and similar secrets to 600 or 640, owned by the site user.
  • Let the web server read static files through group membership or ACLs, never through world-readable bits.
The right group layout depends on whether Nginx, Apache, or LiteSpeed serves static files and how your panel creates accounts. Test after every change. If the socket check says reachable, set listen.owner, listen.group, and listen.mode = 0660 on that pool so that only the web server's group can connect. Then make sure no site user belongs to that group. Move any TCP listeners to Unix sockets. Restart PHP-FPM with your distro's service name, then rerun all three checks. A clean run looks like this: each pool runs as its own user, file_get_contents and shell_exec both say blocked, and the OS-level file, directory, and socket checks all say blocked. After that, look at the shared areas the checks above don't cover: session paths, /tmp, and OPcache. The practical rule: treat separate users and pools as the isolation, and open_basedir as an extra layer on top. Whichever you choose, don't trust the config file alone. Run the cross-site read test as the other site's user, keep the result, and repeat it whenever you add a site, change panels, or migrate servers, because those are the moments when permissions quietly go back to defaults.