Era Host hosting
EraHost – Free Domain, Cheap Hosting!
Client Area
Support 24/7
Menu

Fix PHP session_start(): Permission denied

33 min read
04.04.2026

A PHP site can load every page normally and still lose logins, shopping carts, CSRF tokens, or control-panel sessions. The first visible clue is often a warning like session_start(): open(..., O_RDWR) failed: Permission denied (13). PHP reached the session-storage operation, tried to open or create a file, and the operating system or another enforcement layer refused access.

The tempting fix is to change permissions on /tmp or the PHP session directory. Sometimes that appears to work. It can also hide the real problem, weaken isolation between sites, or modify a directory the failing website does not even use. On a server with several PHP versions or PHP-FPM pools, CLI PHP may report one session.save_path while the website runs with another.

Before changing permissions, prove two facts: which storage path the failing web request actually uses, and which Unix identity handles that request. Then follow the denial through the filesystem, ACLs, security policy, mounts, or service isolation. Changing three layers at once may bring the site back, but it also destroys the evidence that tells you what was broken.

The warning already gives you the first breadcrumb: the path. Start there, not with chmod 777.

What does session_start(): Permission denied actually mean?

Permission denied (13) means the active PHP process attempted an operation that its current access rules did not permit. With the standard files session handler, PHP usually failed while opening, creating, reading, or updating a sess_* file under the effective session storage path. If you are dealing specifically with a PHP session directory that is not writable, the path and the PHP worker identity are the first two things to confirm.

A typical warning looks like this:

PHP Warning: session_start(): open(/path/to/sessions/sess_abcd1234, O_RDWR) failed: Permission denied (13)

O_RDWR shows that PHP attempted to open the session file for reading and writing. Error 13 is the stronger clue: access was denied. The final session directory does not have to be the component that blocked the operation. PHP may lack traversal permission on a parent directory, an existing sess_* file may belong to another account, an ACL may alter the effective permissions, or a mandatory access-control policy may reject the write even though ls -l looks fine.

First confirm that the failing web request actually uses file-backed sessions:

<?php
header('Content-Type: text/plain');

echo 'PHP version: ' . PHP_VERSION . PHP_EOL;
echo 'PHP SAPI: ' . PHP_SAPI . PHP_EOL;
echo 'Session handler: ' . ini_get('session.save_handler') . PHP_EOL;
echo 'Session path: ' . ini_get('session.save_path') . PHP_EOL;

If session.save_handler is files, the filesystem path becomes central to the investigation. If the handler is Redis, Memcached, or a custom implementation, changing permissions on a local PHP session directory may be unrelated to the failure.

Read the complete warning before changing anything. Record the path, errno, active session handler, PHP SAPI, and application symptom. A WordPress login loop or a WooCommerce cart reset may be what the user notices, but the server-side error tells you which layer to investigate first.

Keep nearby errors separate. No such file or directory (2) points toward a missing path or path visible in one environment but not another. No space left on device (28) points toward free blocks or inodes. A read-only mount normally produces a different error again. headers already sent belongs to the HTTP/session-header side of PHP, not filesystem permissions.

After this check you should have the handler and the path from the failing request. If you do not, changing ownership is still guesswork.

Which session directory is the failing website actually using?

The path used by the web request matters more than the path returned by php -i over SSH. CLI PHP and PHP-FPM can use different binaries, different php.ini files, different conf.d trees, different pool-level overrides, and even different major PHP versions. If the runtime values themselves are inconsistent, the separate guide to PHP session configuration problems is a useful companion check.

A familiar dead end looks like this: SSH reports one session directory, the administrator repairs it, PHP-FPM is restarted, and the warning does not move. The shell was using PHP 8.2 while the virtual host was connected to a PHP 8.3 FPM socket. Both configurations were valid. Only one was relevant.

Check session.save_path from the web request

Create a temporary diagnostic file inside the affected virtual host and return only the values needed for troubleshooting:

<?php
header('Content-Type: text/plain');

echo 'PHP_VERSION=' . PHP_VERSION . PHP_EOL;
echo 'PHP_SAPI=' . PHP_SAPI . PHP_EOL;
echo 'session.save_handler=' . ini_get('session.save_handler') . PHP_EOL;
echo 'session.save_path=' . ini_get('session.save_path') . PHP_EOL;
echo 'system_tmp=' . sys_get_temp_dir() . PHP_EOL;

Open the script through the same hostname and protocol used by the affected application. That request follows the web server's real PHP route rather than whichever PHP binary happens to be first in the SSH user's PATH.

Remove the diagnostic script when the check is finished. Even a small runtime probe exposes information that does not need to stay public.

Compare the web runtime with CLI PHP

Now inspect the shell environment:

php -v
php --ini
php -i | grep -E 'session.save_handler|session.save_path|Loaded Configuration File|Scan this dir'

A mismatch is not automatically a fault. CLI and PHP-FPM are allowed to differ. The mismatch means only that a successful CLI test cannot prove that the website's FPM worker can perform the same operation.

Check CLI Website / PHP-FPM Why the mismatch matters
PHP version php -v PHP_VERSION You may be reading configuration for the wrong PHP branch.
SAPI Usually CLI FPM/FastCGI, Apache module, or another web SAPI The execution model and configuration source can differ.
Loaded configuration php --ini Active FPM/web configuration Editing CLI php.ini may have no effect on the site.
session.save_path php -i ini_get() from the site The failing request may write somewhere else.
Execution identity SSH user PHP-FPM or Apache worker Filesystem permissions are evaluated for different processes.

What if session.save_path contains N;MODE;/path?

With PHP's file session handler, session.save_path can contain more than a plain pathname. A value can use a form such as:

2;0600;/var/lib/php/sessions

The final component is the base storage path. The first numeric component controls the directory depth used for session files, while the optional mode controls the session-file mode. If directory depth is greater than zero, the relevant sess_* file may sit below additional subdirectories rather than directly under /var/lib/php/sessions.

This matters during diagnosis. Passing the literal string 2;0600;/var/lib/php/sessions to stat, namei, or findmnt checks a path that does not exist. Extract the actual filesystem path first, then inspect the required nested directory as well if PHP uses hashed subdirectories.

The warning is particularly useful here because it often shows the concrete pathname PHP attempted to open. Compare that pathname with the runtime setting rather than assuming the raw INI string itself is a directory.

What if session.save_path is empty?

An empty value does not prove that PHP stopped using file sessions. If session.save_handler=files but session.save_path is blank, inspect the warning itself and compare it with PHP's temporary-directory environment:

<?php
header('Content-Type: text/plain');

echo 'handler=' . ini_get('session.save_handler') . PHP_EOL;
echo 'save_path=' . ini_get('session.save_path') . PHP_EOL;
echo 'system_tmp=' . sys_get_temp_dir() . PHP_EOL;

Do not infer a repair path from the empty string alone. Use the concrete path in the PHP warning or runtime behavior as evidence, then run filesystem checks against that path.

Find where the web value is coming from

Once the effective value is known, trace it back to its configuration source. Depending on the server, it may come from the main php.ini, a file under conf.d, a per-site .user.ini, PHP-FPM pool configuration, a php_value/php_admin_value directive, or a hosting-control-panel setting.

Fixing the wrong /var/lib/php/... directory is a classic dead end on multi-PHP servers. The warning path, the web runtime value, and the configuration source should all tell the same story before you move to permissions.

When the warning path and the actual web runtime agree, you have a storage location worth troubleshooting.

Which Unix user is PHP-FPM or Apache using to create the session file?

The account that matters is the process handling the failing PHP request. Root being able to create a file in the session directory proves very little when PHP-FPM runs as an unprivileged site user.

Identify PHP-FPM master and worker processes

Start with the running processes:

ps -eo user,group,pid,ppid,cmd | grep '[p]hp-fpm'

A PHP-FPM master commonly runs with elevated privileges while pool workers run under another account. Look at the children, then compare them with the user and group directives of the relevant pool.

Seeing www-data somewhere in ps is not enough when several PHP versions and five FPM pools are running. You still need to establish which pool serves the affected virtual host.

Map the virtual host to the actual PHP-FPM pool

For Nginx, inspect the site's PHP location and note its fastcgi_pass. A configuration may look like this:

fastcgi_pass unix:/run/php/php8.3-fpm-example.sock;

Then find the PHP-FPM pool whose listen directive uses that same socket:

listen = /run/php/php8.3-fpm-example.sock
user = example
group = example

That gives you a traceable chain:

virtual host
    -> FastCGI socket
    -> PHP-FPM pool
    -> worker user/group

Apache with PHP-FPM can use the same idea through a proxy/FastCGI handler, for example a Unix socket referenced by SetHandler. Control panels may generate these configurations automatically, but the diagnostic question is unchanged: which socket receives this site's PHP requests, and which pool owns that socket?

If the virtual host points to one socket but you inspect workers from another PHP version, every permission conclusion after that point is suspect.

Check Apache when PHP does not use a separate FPM pool

For Apache-based execution models, inspect the actual server processes:

ps -eo user,group,pid,cmd | grep -E '[a]pache2|[h]ttpd'

Do not copy a service account name from another distribution. www-data, apache, httpd, nobody, and per-site users are all possible depending on the execution model.

Do not forget supplementary groups

Owner and primary group are only part of a process identity. Access may be granted through a supplementary group, and a process that started before a group-membership change may still have an older group list.

Check the configured account:

id <php-user>

For a running PHP-FPM worker, inspect what that process actually has:

grep -E '^(Uid|Gid|Groups):' /proc/<worker-pid>/status

If the account was recently added to a group but the running worker does not show that group, reload or restart the relevant service according to the server's change procedure before concluding that group permissions are wrong.

Test access as the worker, not as root

Once the correct worker identity and path are known, reproduce the filesystem operation under that account:

sudo -u <php-user> test -w /path/to/session-dir
echo $?

sudo -u <php-user> touch /path/to/session-dir/session-permission-test

test -w is a quick hint. The touch test is stronger because it asks the kernel to create a real directory entry. Clean it up afterward:

rm -f /path/to/session-dir/session-permission-test

If touch fails with Permission denied as the correct PHP worker, you have reproduced the problem below the application layer. If it succeeds while session_start() still fails, do not open the directory further. The mismatch is somewhere else: an existing session file, ACL, security policy, service namespace, chroot, or the specific pool handling the request.

Root being able to write is not the test. Reproduce the operation with the identity that PHP actually uses.

Are ownership, mode bits, ACLs, or parent-directory permissions blocking PHP?

A session directory can look writable in ls -ld and still be unusable by PHP. Creating a new file requires write and execute permission on the directory, PHP must traverse every parent directory, and an existing session file has its own owner, mode, and possibly ACL.

Inspect the whole path, not only the final directory

namei -l expands every component of the pathname and makes a hidden parent-directory problem much easier to spot:

namei -l /path/to/session/directory

PHP needs execute/traversal permission through each parent. On the final directory, creating a new session file requires the worker to have the appropriate write and execute access. A directory reported as “writable” by a quick check is not the whole story.

Inspect selected components directly as well:

ls -ld /path /path/to /path/to/session /path/to/session/directory
stat /path/to/session/directory

The break is often one directory higher than expected. If /path/to/session/directory looks perfect but the worker cannot traverse /path/to, changing the leaf directory achieves nothing.

Use a real create test when test -w is ambiguous

A permission predicate is useful, but a real operation is more convincing:

sudo -u <php-user> sh -c '
    touch /path/to/session/directory/.php-session-test &&
    rm -f /path/to/session/directory/.php-session-test
'

If this fails, the shell normally reports the same class of OS-level error PHP is hitting. If it succeeds, that proves only that this user can create a new file in that directory. It does not yet prove that PHP can reopen a particular existing sess_* file.

Check ACLs on the entire path when mode bits look correct

POSIX ACLs can alter the effective permission model beyond the owner/group/other bits shown by ls -l. Check the target directory and any suspicious parent:

getfacl -p /path/to/session/directory
getfacl -p /path/to/session
getfacl -p /path/to

Pay attention to the effective ACL mask. An entry can appear to grant access while the mask reduces the effective rights.

If newly created session files get unexpected permissions or ACL entries, inspect default ACLs on the directory:

getfacl -p /path/to/session/directory | grep '^default:'

Default ACLs affect newly created objects. Removing every ACL is not a repair if the server intentionally uses ACLs for site isolation or shared maintenance tasks.

Inspect the existing session file, not just its directory

A PHP-FPM pool change can leave old session files owned by the previous worker. New anonymous sessions may work while users carrying existing cookies continue to fail.

ls -la /path/to/session/directory | head
stat /path/to/session/directory/sess_<session-id>
getfacl -p /path/to/session/directory/sess_<session-id>

This distinction matters because creating a new file and reopening an existing file exercise different permissions. The directory can allow the new pool user to create sess_new while sess_old remains unwritable.

Observed result Likely cause What to check Corrective direction
Root can write, PHP worker cannot Worker does not match owner/group/ACL id, stat, worker-level create test Align access with the intended execution identity
Final directory looks writable A parent blocks traversal namei -l Repair the first blocking component
New sessions work, old sessions fail Existing sess_* files have stale ownership or ACL stat and getfacl on the affected file Handle old sessions deliberately
New files get unexpected rights Default ACL or configured session mode influences creation getfacl and runtime session.save_path Correct the intended creation policy
Unix bits look correct Another enforcement layer may interfere ACL, SELinux/AppArmor, service isolation Move to the next confirmed layer
A recursive chmod 777 changes too much at once and can weaken isolation between sites. A broad recursive chown is just as risky on package-managed or control-panel-managed PHP directories. Find the boundary that actually fails and repair that boundary.

After any change, repeat the worker-level create test before moving on. One variable at a time keeps the diagnosis intact.

Why can PHP get Permission denied even when chmod and chown look correct?

Correct Unix owner, group, and mode bits do not rule out an access denial. SELinux and AppArmor can reject PHP session operations independently of normal filesystem permissions. If chmod looks right, stop changing chmod and check the next enforcement layer.

Check SELinux with audit evidence

Start by checking the current mode:

getenforce

If SELinux is enforcing, inspect the target context:

ls -Zd /path/to/session/directory

Then look for recent AVC denials:

ausearch -m AVC -ts recent

getenforce by itself proves nothing about this incident. What matters is an AVC record that matches the relevant PHP-FPM or web-server process, target path, and denied operation.

If there is no matching denial, do not blame SELinux merely because it is enabled. If there is a matching denial, repair the expected context or policy for that server rather than turning enforcement off permanently.

Check AppArmor where it is used

Start with the loaded profile state:

aa-status

Then inspect kernel or journal output for denials around the failing request:

journalctl -k | grep -i apparmor
journalctl -k | grep -i denied

A useful result names the profile, operation, and path. Once those line up with the PHP session failure, adjust the relevant profile or intended path. Disabling the complete policy layer removes evidence and weakens a protection that may have been working exactly as configured.

Remember that service isolation can exist beyond SELinux and AppArmor

A shell test and a PHP-FPM request do not always see the same filesystem environment. A systemd unit can apply restrictions such as ProtectSystem, ReadWritePaths, ReadOnlyPaths, InaccessiblePaths, or PrivateTmp. A PHP-FPM pool can also run inside a chroot.

These mechanisms do not all produce the same errno, so do not treat them as automatic explanations for error 13. They are the next place to look when the ordinary permissions and MAC audit logs say the write should work but the service behaves differently from an interactive shell.

  1. Reproduce the write as the real PHP worker.
  2. Check owner, group, mode, path traversal, and ACL.
  3. Look for a matching SELinux/AppArmor denial.
  4. If none exists, compare the PHP-FPM service environment with the shell environment.
  5. Only then move deeper into mount namespaces, chroot, or syscall tracing.

Did the problem start after changing PHP version, PHP-FPM pool, migration, or control panel settings?

A session permission error that appears immediately after a server-side change often comes from a mismatch introduced by that change. A new PHP version can load another configuration tree, a new FPM pool can run under another UID, and restored files can preserve numeric ownership that means something different on the destination server.

A PHP version switch can change both configuration and path

After switching PHP versions, verify the web runtime again instead of assuming that the old session settings followed the site:

<?php
header('Content-Type: text/plain');

echo PHP_VERSION . PHP_EOL;
echo PHP_SAPI . PHP_EOL;
echo ini_get('session.save_handler') . PHP_EOL;
echo ini_get('session.save_path') . PHP_EOL;

Compare the result with the virtual host's FastCGI destination and the active FPM pool. Repairing the PHP 8.2 session directory will not help a site that now sends requests to PHP 8.3.

A PHP-FPM pool change can strand existing sessions

Suppose the old pool wrote files as user A and the new pool runs as user B. User B may be able to create new sessions while old sess_* files remain owned by user A. The symptom then looks inconsistent: fresh visitors work, while existing users with cookies still hit failures.

That is a useful clue, not random behavior. Compare a newly created session file with one from an affected request before deleting anything.

Session cleanup can log users out, invalidate carts, or affect other applications if several pools share a session directory. Determine the scope first.

A migration can preserve the wrong numeric UID or GID

Filesystems store numeric IDs. User names are only the local interpretation of those IDs. After a restore, rsync operation, VPS migration, container move, or image clone, the same UID can map to a different account.

stat -c '%u:%g %n' /path/to/session/directory
stat -c '%u:%g %n' /path/to/session/directory/sess_<session-id>

id <php-user>

If the numbers do not describe the intended access model on the current server, repair the mapping or ownership deliberately. Treat the last infrastructure change as evidence, not coincidence.

Recent change Typical mismatch What to compare Safe next step
PHP version switch New PHP tree uses another session path Web PHP version, socket, runtime path, active config Repair the configuration serving the site
FPM pool/user change Existing session files belong to another worker Pool user/group and session-file ownership Limit the repair to affected sessions/access
Server migration Source UID/GID maps differently Numeric IDs on old and new execution model Restore intended ownership
Control-panel PHP mode change Site starts running under another account or pool Virtual host, socket, pool identity Align session access with the new mode
Container/volume move Host and container numeric identities differ UID/GID on both sides of the mount Correct mapping or volume ownership

Could the filesystem, mount, container, or temporary storage be the real problem?

If the active path, worker identity, Unix permissions, ACLs, and MAC policy all look consistent, inspect how that path is actually stored. Bind mounts, container volumes, network filesystems, read-only mounts, and recreated temporary directories can make the path behave differently from what its pathname suggests.

Check which filesystem backs the session directory

Ask the system about the target path directly:

findmnt -T /path/to/session/directory -o TARGET,SOURCE,FSTYPE,OPTIONS
df -h /path/to/session/directory
df -i /path/to/session/directory

findmnt identifies the backing filesystem and options. df -h checks free blocks, while df -i checks free inodes.

Keep the error codes straight. A full filesystem normally gives No space left on device, not literal Permission denied (13). A read-only filesystem also normally identifies itself differently. These checks belong here because application-level symptoms overlap, not because every storage problem explains EACCES.

Compare container and host identities numerically

Containerized PHP adds another identity boundary. A directory may be owned by UID 1001 on the host while the PHP process inside the container runs with another UID. Account names can make this look less obvious than it is.

Compare numeric identities on the relevant side of the mount:

id
stat -c '%u:%g %n' /path/to/session/directory

Do not assume that www-data always has the same UID, or even exists on both sides. The number used by the process that performs the write is what the filesystem sees.

Check NFS, CIFS, and other shared storage separately

If findmnt reports NFS, CIFS, or another network-backed filesystem, local mode bits may not tell the whole story. Server-side export rules, numeric UID/GID mapping, mount options, and client-side ownership presentation can all affect the result.

For NFS, also remember that administrative tests performed as root can behave differently under export options such as root squashing. For CIFS, options that define uid, gid, file_mode, or dir_mode can affect what Linux presents to PHP.

The correct test is still the same: reproduce the session-file operation using the real PHP execution identity on the mounted path.

Check whether the session directory was recreated

A session directory can disappear during reboot, deployment, image rebuild, container replacement, or cleanup and later be recreated with different ownership or ACLs. Do not call a Unix ctime value “creation time”: ctime records inode metadata changes, not universal file creation time.

Look for stronger evidence:

  • the directory inode or metadata changed around a deployment or reboot;
  • birth time changed, if the filesystem exposes a reliable birth time;
  • a startup or deployment script recreates the directory without restoring owner/group;
  • a tmpfiles rule creates it with another mode;
  • a container starts with an empty ephemeral directory;
  • a bind mount covers the directory PHP used before.

After identifying the backing storage, repeat the worker-level create test. If it succeeds, the problem moves back toward the specific session file or PHP service environment. If it fails, the failure still exists below PHP.

What if every permission check looks correct but PHP still gets EACCES?

When the path, Unix permissions, ACLs, SELinux/AppArmor, and a manual worker-level write all look correct, stop making permissions broader. At this stage the most useful work is proving that the failing PHP request really runs in the environment you think it does.

Reconfirm the request path from virtual host to worker

Start again at the web server, but this time follow the exact route end to end:

request hostname
    -> virtual host
    -> FastCGI / proxy socket
    -> PHP-FPM pool
    -> running worker PID
    -> worker UID/GID/groups
    -> session path

This catches a surprisingly stubborn class of mistakes: the administrator tests the right Unix user for the wrong FPM pool. If several PHP versions or pools run at once, verify the socket and pool rather than matching only the process name.

Compare the configured account with the running process

Check the account definition:

id <php-user>

Then inspect an actual worker from the relevant pool:

grep -E '^(Uid|Gid|Groups):' /proc/<worker-pid>/status

If the supplementary group list differs from what you expected, the process may simply be older than the account change. A service reload or restart may be needed for new group membership to reach new workers.

Check systemd restrictions around PHP-FPM

On systemd-based servers, inspect the unit actually launching the relevant PHP-FPM instance:

systemctl cat <php-fpm-unit>

systemctl show <php-fpm-unit> \
  -p ProtectSystem \
  -p ReadWritePaths \
  -p ReadOnlyPaths \
  -p InaccessiblePaths \
  -p PrivateTmp

Look for a setting that makes the session path unavailable, read-only, or different from what an interactive shell sees. PrivateTmp, for example, can give the service its own temporary-directory namespace. That does not automatically explain every EACCES, but it does explain why “I can see and write this from SSH” may not reproduce the service environment.

Check PHP-FPM chroot and pool-specific environment

Inspect the pool configuration for directives such as:

chroot = /some/root
chdir = /some/path

If the worker runs inside a chroot, the pathname visible to PHP is interpreted inside that filesystem root. A host path that looks correct from SSH may not refer to the same object from inside the pool.

A missing path inside chroot may produce a different errno rather than error 13, so treat this as an environment check rather than forcing it into a permission explanation.

Use syscall tracing as the last mile, not the first command

If PHP still returns Permission denied after the normal checks, a short syscall trace can show exactly which pathname fails and which operation returns EACCES.

On a quiet system, staging environment, or carefully selected production worker, an administrator with the required privileges can temporarily trace file-related calls:

sudo strace -f \
  -p <worker-pid> \
  -e trace=open,openat,access,faccessat \
  -s 200 \
  -o /tmp/php-session.strace

Trigger the failing request once, stop the trace, and search for the session path or EACCES:

grep -E 'sess_|EACCES' /tmp/php-session.strace

A useful trace may contain a line conceptually like:

openat(..., "/path/to/sessions/sess_abcd1234", ...) = -1 EACCES (Permission denied)

That is much stronger evidence than another chmod attempt: it identifies the exact object and syscall the kernel rejected.

Attaching strace to a busy production worker adds overhead and may capture unrelated activity. Use it briefly, protect the trace file, and remove it afterward. Also remember that a busy FPM pool may route your test request to another worker, so the absence of a matching syscall in one PID does not prove that PHP never attempted it.

If the trace points to a different path than the one you have been checking, the diagnosis changes immediately. If it confirms the expected path and returns EACCES, walk that exact pathname back through ownership, ACL, security policy, mount, and service restrictions. Now you are working from proof.

What is the safest way to fix PHP session permissions?

Apply the repair to the layer you have already proved is wrong. There is no universal chmod or chown command that fits every FPM pool, control panel, shared server, distribution, container, or cleanup policy.

If session.save_path is wrong

Change the setting in the configuration source used by the web runtime: the relevant pool, per-site override, PHP configuration, or hosting-control-panel setting. Editing CLI PHP because php --ini showed it first will not help a site served by another SAPI.

After the configuration change, reload or restart the relevant service if that setting requires it, then check the value through the website again:

echo ini_get('session.save_path');

The web request must report the intended value before you consider the configuration fixed.

If a dedicated per-user FPM pool needs private session storage

A dedicated pool is the simplest model because one Unix account owns both the PHP worker and its private session directory. For example, if the pool really runs as <pool-user> and no other process needs direct access, an administrator might create a private directory like this:

install -d \
  -o <pool-user> \
  -g <pool-group> \
  -m 0700 \
  /srv/<site>/sessions

The pool can then be configured to use that directory:

php_admin_value[session.save_path] = /srv/<site>/sessions

This is an example access model, not a universal prescription. If another process must clean or inspect sessions, or the hosting panel manages these paths itself, the ownership model has to account for that.

Verify using the pool user:

sudo -u <pool-user> touch /srv/<site>/sessions/.write-test
rm -f /srv/<site>/sessions/.write-test

If the server uses a shared system-managed session directory

Do not convert a package-managed shared directory into a per-site directory by recursively changing its ownership. First compare the current owner, group, mode, ACL, and PHP package/control-panel expectations. On shared Linux hosting, these system directories are usually managed by the platform rather than by an individual site owner.

stat /path/to/system-session-dir
getfacl -p /path/to/system-session-dir

If only one component drifted after an update, migration, manual change, or failed restore, repair that component. Shared session directories often serve several pools, so a change that fixes one site can break another if it ignores the original access model.

If a parent directory blocks traversal

Use namei -l to locate the first failing component. Restore only the traversal/access needed by the intended PHP identity rather than opening every directory below it.

If existing session files belong to the previous PHP user

Decide first whether active sessions should be preserved or invalidated. Correcting ownership can make sense in one migration model; expiring old sessions can be cleaner in another.

Scope the operation carefully. Deleting every sess_* file in a shared directory can log users out across unrelated sites and destroy carts or application state.

If SELinux or AppArmor is the confirmed blocker

Repair the expected context, rule, or path based on the audit evidence. The goal is not to make PHP unrestricted. The goal is to make the intended session write valid under the server's security model.

If the host and container disagree about UID/GID

Compare the numeric account used by PHP inside the container with the ownership exposed on the mounted volume. Fix the mapping or volume ownership at that boundary rather than repeatedly relaxing mode bits inside the container.

A typical diagnostic comparison is:

# Host side
stat -c '%u:%g %n' /host/path/to/sessions

# Container / PHP environment
id <php-user>

If the numbers do not line up with the intended model, changing names in configuration will not help. The filesystem checks numeric identities.

Confirmed cause Fix direction Avoid Verification
Wrong runtime session path Correct the active web/FPM configuration Editing only CLI php.ini Web ini_get() reports the intended path
Wrong worker owner/group access Align access with the real pool identity Universal recursive chmod 777 Worker-level create test succeeds
Blocked parent directory Repair the first blocking path component Opening the complete directory tree namei -l shows valid traversal
Old session-file ownership Preserve or expire affected sessions deliberately Deleting every session in a shared directory Old/new session behavior is consistent
ACL issue Correct the relevant ACL entry or mask Removing all ACLs without checking their purpose getfacl matches the intended model
SELinux/AppArmor denial Repair confirmed context or policy Disabling the entire security layer No matching denial appears during the test
Service namespace/chroot issue Allow the intended path or correct the pool/service configuration Changing filesystem modes outside the service view PHP sees and writes the same expected path
Container UID/GID mismatch Correct numeric mapping or volume ownership Relying only on displayed user names Worker creates and reopens session storage
Mount/filesystem problem Repair mount or storage state Continuing to change PHP permissions Storage operation succeeds under PHP identity

After every repair, rerun the smallest test that proved the failure. If the worker could not create a file before, retest that. If AVC logs showed a denial, trigger one request and check the audit log again. If the problem affected old sessions only, test an old and a new session separately.

Fix the mismatch, then stop. Changing additional layers after the failure is gone only makes the next incident harder to understand.

How do you verify the fix instead of assuming session_start() works?

Do not call the problem fixed after one successful page load. A useful verification proves that PHP starts a session, writes data, sends the session cookie, and reads the same state on a later request without producing another storage warning.

Use a test that checks both session start and session write

Create a temporary endpoint in the affected virtual host:

<?php
header('Content-Type: text/plain');

$started = session_start();

if (!$started || session_status() !== PHP_SESSION_ACTIVE) {
    http_response_code(500);
    echo "session_start failed" . PHP_EOL;
    exit;
}

if (!isset($_SESSION['permission_probe'])) {
    $_SESSION['permission_probe'] = 0;
}

$_SESSION['permission_probe']++;

$id = session_id();
$counter = $_SESSION['permission_probe'];

$written = session_write_close();

echo 'Session ID: ' . $id . PHP_EOL;
echo 'Counter: ' . $counter . PHP_EOL;
echo 'Write closed: ' . ($written ? 'yes' : 'no') . PHP_EOL;

Open the endpoint once. The counter should be 1. Reload it using the same browser session. The session ID should remain consistent and the counter should become 2, then 3.

The explicit session_write_close() makes the test clearer: you are not checking only whether PHP can start the session. You are also forcing PHP to finish the write before printing the result.

Repeat the test with curl and a cookie jar

A browser can hide useful details behind extensions, cached requests, or cookie policy. Two curl requests make the session-cookie flow explicit:

curl -sS \
  -c /tmp/php-session.cookies \
  https://example.com/session-test.php

curl -sS \
  -b /tmp/php-session.cookies \
  -c /tmp/php-session.cookies \
  https://example.com/session-test.php

The first request should return counter 1. The second should return counter 2 with the same session ID. Remove the temporary cookie file afterward:

rm -f /tmp/php-session.cookies

If every request starts again at 1, the original permission warning may be gone while persistence is still broken. At that point inspect the session cookie, handler, application logic, or proxy behavior instead of reopening the filesystem permissions.

Confirm the storage object for file-backed sessions

When session.save_handler=files, inspect the session directory after the first request. Confirm that PHP created or updated the expected file with ownership compatible with the active worker:

ls -la /path/to/session/directory | tail
stat /path/to/session/directory/sess_<session-id>

If session.save_path uses directory depth, remember that the file may be inside nested subdirectories rather than directly under the base path.

Recheck the logs after the successful request

Look at the same logs that exposed the original failure:

  • PHP error log;
  • PHP-FPM service log;
  • Nginx or Apache error log where relevant;
  • SELinux AVC records if SELinux was involved;
  • AppArmor/kernel messages if AppArmor was involved.

A clean page in the browser is not enough if PHP still emits warnings in the background. Trigger several requests and confirm that no fresh permission denial appears.

Test the application that originally failed

Finally, reproduce the real symptom. A WordPress administrator should remain logged in after a reload. A WooCommerce cart or customer session should survive another request. A custom PHP application should keep its authentication and CSRF state.

If the minimal PHP test persists correctly but the application still logs users out, the filesystem part is probably no longer the bottleneck. Move up the stack and inspect cookie domain/path, Secure, SameSite behavior, framework session configuration, custom handlers, reverse proxies, or application code.

10-minute verification checklist
  • Confirm the active session.save_handler.
  • Confirm the web runtime session.save_path and interpret any N;MODE;/path prefix correctly.
  • Confirm the virtual host points to the PHP-FPM pool you inspected.
  • Confirm the worker user, primary group, and supplementary groups.
  • Confirm the worker can traverse every parent directory.
  • Confirm the worker can create a file in the session directory.
  • Check existing sess_* ownership and ACL if only existing users fail.
  • Confirm no matching SELinux/AppArmor denial appears.
  • Check systemd/chroot restrictions if shell and PHP behavior differ.
  • Confirm the backing filesystem or volume is writable and mapped correctly.
  • Start a test session and explicitly close/write it.
  • Repeat the request with the same cookie and verify the counter increases.
  • Recheck PHP, FPM, web-server, and security logs.
  • Test the original login, cart, admin, or application workflow.
  • Remove diagnostic scripts, traces, test files, and cookie jars.

Know when the permission investigation is finished

The permission issue is resolved when the correct PHP worker can access the intended storage, no relevant denial appears in the logs, and session data survives another request. If all three are true, another chmod is unlikely to fix an application that still loses authentication.

At that point the evidence says to move higher in the stack. That is a useful result too: troubleshooting is not only about finding a broken permission. It is also about proving when permissions are no longer the problem.

Frequently asked questions
With the files session handler, PHP usually cannot open, create, or update the session file using the permissions and security context of the active web worker.
Check ini_get('session.save_path') from a temporary script served by the affected website. CLI PHP may load a different version or configuration.
N sets the directory depth for file sessions, MODE can set the session-file mode, and the final component is the base storage path.
No. First identify the actual PHP worker, session path, blocking directory or file, ACL, and security policy, then change only the layer that is wrong.
Check parent-directory traversal, ACLs, supplementary groups, SELinux/AppArmor, PHP-FPM chroot, systemd restrictions, mounts, and the exact FPM pool serving the site.
Yes. The new runtime may use another session path or worker UID, while existing sess_* files can retain ownership created by the previous pool.
Start a test session, write a counter, close the session, then repeat the request with the same cookie. The session ID should persist and the counter should increase without new errors.
Related articles
Fixing session_start(): Permission Denied (13) — Failed to Read Session Data in PHP
Fixing session_start() failed: Permission denied (13) — A System Administrator's Guide