Uploads That 403, and the Permission Advice That Causes It
The 755/644 advice is right in the abstract and wrong on most hosts. Here is the ownership model that decides whether uploads work in WordPress 7.0.7.
Martin Kiel · @mkiel
WordPress internals
- Topic
- INFRA
- Section
- Hosting & Infrastructure
- Published
- 09 OCT 2026
- Reading time
- 8 MIN
- Words
- 2164W
Revised 16 OCT 2026
A site stops accepting images on a Tuesday. The media library shows "The uploaded file could not
be moved to wp-content/uploads/2026/10", the browser network panel shows a 403 against
async-upload.php, and the developer who was asked to look at it has already run the
command that most search results recommend:
find wp-content/uploads -type d -exec chmod 755 {} \;
find wp-content/uploads -type f -exec chmod 644 {} \;
That command is not nonsense. For a WordPress install where one user owns every file and the PHP process runs as that same user, 755 on directories and 644 on files is exactly right, and it is what WordPress itself creates. Run it on a host where those two identities differ and it will break the thing it was meant to repair, quietly, and in a way that reads as a different problem.
755 and 644 describe one identity, and hosting has three
A POSIX mode is not a property of a file in isolation. It is a table of three columns, and which column applies depends on who is asking. The three columns are the file's owner, the file's group, and everyone else. A third entity, the process's effective user and group, chooses the column. The bits in that column decide the answer.
On a host where the PHP process owns the file, the owner column applies, the owner column is
usually rw-, and life is simple. On a host where it does not, the group column applies
if the process's effective group matches the file's group, and the other column applies if neither
matches. This is the whole model, and it explains why the same mode produces a working upload on one
server and a denied write on another.
The standard shared-hosting arrangement makes the two identities different. Files arrive owned by the account user, because that is who uploaded the site over SFTP, or because a deploy script ran as that user. PHP runs as a separate service account, or as an isolated per-account user created by the panel. When those differ, an ownership check is what decides the outcome, and it happens before permission bits matter at all.
| Arrangement | Who owns the files | What 755/644 does |
|---|---|---|
| PHP runs as the account user | The PHP user | Works. The owner column has the write bit. |
| PHP runs as a separate user, same group | The account user | Removes group write, so new files cannot be created. |
| PHP in a per-account isolated pool | Depends on how the tree was copied | Breaks on whichever subtrees the deploy step created. |
Only the first row matches the advice. The second is the most common on shared hosting and the most likely to produce a support ticket, because everything except writes keeps working.
Measured on a single machine
I could not build a second Unix user in the environment this was written in, so the cross-owner case below is derived from the rules and from what the code does, not from a two-account test. Everything that describes modes, writes and error strings is copied from real output on WordPress 7.0.7, PHP 8.5.6, macOS, under a single uid. The article says which parts are which.
The common advice removes group write, which is the thing you need
Look again at what 755 does to a directory. The owner gets read, write and execute. The group gets read and execute. Everyone else gets read and execute. So on a host where the PHP process is in the same group as the files but is not the owner, the group column applies, and the group column has no write bit.
The upload endpoint now fails at the point where it tries to add a directory entry, not at the point where it reads a file. That distinction matters because the two failures look different from the outside. Reading an existing image keeps working. Writing a new one does not. WordPress will happily serve every attachment you already have and refuse to accept a new one, which is why the complaint usually arrives phrased as "uploads are broken" rather than "the site is broken".
Now watch the same rules from PHP, in a scratch tree where the process and the file do have the same owner. This is real output from a script run under uid 502.
php process euid=502 egid=20
file created mode=0644 owner-uid=502 group-gid=0
directory mode=0755 owner-uid=502
after chmod 0555: dir is_writable=false
CREATE new file: false
file_put_contents(.../2026/10/photo-300x200.jpg): Failed to open stream: Permission denied
after chmod 0444 on the file: dir is_writable=true file is_writable=false
OVERWRITE existing file: false
file_put_contents(.../2026/10/photo.jpg): Failed to open stream: Permission denied
UNLINK file in a 0555 directory: false
unlink(.../2026/10/photo.jpg): Permission denied
Three things are worth taking from that block. Adding a new entry needs write permission on the
directory and gets Permission denied when it is missing. Overwriting an existing file
needs write permission on the file, and the directory does not save you. Removing a file is a
directory operation, so a perfectly writable file in a read-only directory cannot be deleted.
WordPress does all three during one upload: it writes the original, generates thumbnail and
intermediate sizes as new files, and deletes intermediates when an image is edited and re-saved.
There is a fourth fact in that output, and it is the one people skip. The group on the created
file is 0, not the process's group 20. The mode and the group are decided
by the filesystem, by the umask, and by the setgid bit on the parent directory. A directory with the
setgid bit set forces new entries into the directory's group; without it, they inherit the process's
group. Running chmod -R 755 across a directory tree does not touch that, and neither
does it touch who owns the entries.
WordPress uses the same rules when it creates the month directory, which is why the mode you
chose in a ticket is not always the mode you later find on disk. wp_mkdir_p() asks for
0777 when it has to create a path, then lets the umask reduce it, and when the parent already exists
it copies the parent's mode instead. On a host with a umask of 0022 that lands on 0755; on a host
with a umask of 0002 it lands on 0775. The number on the directory is therefore decided at creation
time, by configuration you may never look at.
What the failure looks like from inside WordPress
None of the above tells you whether the filesystem or the web server refused the request. The distinction is worth ten minutes, because the fixes have nothing in common.
Here is a real failed upload, against a working WordPress 7.0.7 install whose uploads tree I made
read-only. The request is authenticated, carries a valid media-form nonce, and posts a
74-byte PNG to the upload handler.
POST /wp-admin/async-upload.php HTTP/1.1
Content-Type: multipart/form-data
HTTP/1.1 200 OK
Content-Type: text/plain; charset=UTF-8
{"success":false,"data":{"message":"The uploaded file could not be moved to
wp-content\/uploads\/2026\/10.","filename":"probe.png"}}
A filesystem refusal surfaces as that message with an HTTP 200. The handler ran, the nonce was accepted, the upload was received into a temporary file, and the move into the uploads tree failed. When the destination directory itself cannot be created, core says so in different words, and those words are the diagnostic. I removed the month directory, set the uploads root to 0555, and repeated the exact request:
HTTP/1.1 200 OK
Content-Type: text/plain; charset=UTF-8
{"success":false,"data":{"message":"Unable to create directory
wp-content\/uploads\/2026\/10. Is its parent directory writable by the
server?","filename":"probe.png"}}
That prompt is aimed at the right layer. WordPress is telling you it called
wp_mkdir_p() and the filesystem said no. The mode on the directory it wanted to create
never came into it.
Both messages describe a filesystem or an ownership problem. Neither is a 403. That is the tell. A 403 on the same endpoint comes from the server refusing the request before WordPress gets to finish, and it usually comes from one of a short list of rules or settings:
| What you see | Who said it | Where to look |
|---|---|---|
| 403 with an HTML body from the server | nginx or Apache rule on the request path | A location or rewrite rule matching async-upload.php or wp-admin |
403 with -1 or a JSON error | WordPress, from a failed nonce or capability | media-form nonce, the logged-in cookie, upload_files |
| 403 only for uploads, never for reads | A security module or WAF inspecting multipart bodies | ModSecurity audit log, host WAF console |
| Operation not permitted in the PHP log | open_basedir | The vhost's open_basedir list |
The open_basedir case deserves its own line because it produces a write denial with
no permission problem behind it. I reproduced the error string directly:
$ php -d open_basedir=/tmp/mlab-wperr3/perms -r \
'var_dump( @file_put_contents( "/tmp/mlab-wperr3/other/x.jpg", "y" ) );'
bool(false)
file_put_contents(/tmp/mlab-wperr3/other/x.jpg): Failed to open stream: Operation not permitted
On this machine /tmp is a symlink to /private/tmp, which is why the
allowed path had to be the resolved one. On a host with a real /tmp, the same script
reads as written. Either way the message is Operation not permitted, not
Permission denied, and no chmod will change it.
The fix is a group and a mask, not a number
What you actually want is a directory tree the PHP process can write into, and a set of files it can rewrite and delete. On a host where the PHP user is not the file owner, that is an ownership change, and it needs root. The sequence that works, adjusted for the names your host uses, looks like this.
# who is PHP, and who owns the tree?
ps -o user= -p "$(pgrep -f 'php-fpm' | head -1)"
stat -c '%U:%G %a %n' wp-content/uploads wp-content/uploads/2026/10
# put the group on the tree, and let the group write.
# the setgid bit makes new entries inherit the group.
chgrp -R webgroup wp-content/uploads
find wp-content/uploads -type d -exec chmod 2775 {} \;
find wp-content/uploads -type f -exec chmod 664 {} \;
# the PHP user must be a member of webgroup
id phpuser
Two details carry the whole change. The leading 2 in 2775 is the setgid
bit, and it is what stops the tree from drifting back out of the group every time WordPress writes a
new month directory. And 664 on files keeps the group write bit that 644
removes. If you take nothing else from this article, take those two.
Where the host does not give you root, the alternative is to make the PHP process and the file
owner the same user instead. Some panels expose a PHP handler that runs as the account user, which
is the arrangement the 755/644 advice was written for. Others expose a per-account PHP-FPM pool and
expect you to use chgrp through the panel's file manager. The wrong move is
chmod -R 777. It works, immediately, and it hands write access to every account on the
machine, which on a shared server is a crowd worth not inviting.
Where this advice stops working
Ownership changes are the most invasive fix in this article. They affect every file in the tree,
including the ones a backup or a deploy script created with a particular owner, and they will be
undone by the next restore that does not preserve ownership. On hosts with immutable infrastructure,
where the application directory is mounted read-only and only a specific path is writable, the
correct answer is to point UPLOADS or upload_path at that path rather than
to fight the mount.
The permissions model also cannot tell you whether the web server is allowed to run PHP inside the uploads directory. That is a server configuration question, and it is the one upload hardening step that is worth copying early. Denying execution beats any mode you can set on a JPEG:
# nginx: do not execute anything under uploads
location ^~ /wp-content/uploads/ {
location ~ \.php$ {
return 403;
}
}
# Apache: the same thing in .htaccess
<Files *.php>
Require all denied
</Files>
Run the exact same test after the change. A read-only uploads tree that returns
{"success":false,...} with a 200 is a filesystem answer. A 403 with an HTML body is a
server rule, and the chmod you were about to run would have changed nothing.
What to do on Monday
- Capture the failing response body and the status code before touching anything. The pair separates a filesystem refusal from a server rule.
- Find the PHP user with
ps, then compare it withstatoutput foruploads. If they differ, the numbers in the blog post you just read do not apply. - Move the tree into one group, add the PHP user to that group, and use
2775/664rather than755/644. - If root is not available, use the panel's own ownership or PHP-handler control instead of widening the mode.
- Add the execution-deny rule for
uploads, and confirm it with a request rather than by reading the file. - Write down which identity owns the tree, in the runbook. The next person to run
chmod -R 755should find that note first.
Martin Kiel · @mkiel
WordPress internals
Writes about the load-bearing parts of WordPress: the options table, hook ordering, WP_Query behaviour and the database work that decides whether a plugin scales or falls over.
Continue