Skip to content
System nominalUTC --:--:--Articles 071

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

Technical cover graphic for Uploads That 403, and the Permission Advice That Causes It
Generated schematic — INFRA series, REV 2.6.

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.

ArrangementWho owns the filesWhat 755/644 does
PHP runs as the account userThe PHP userWorks. The owner column has the write bit.
PHP runs as a separate user, same groupThe account userRemoves group write, so new files cannot be created.
PHP in a per-account isolated poolDepends on how the tree was copiedBreaks 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 seeWho said itWhere to look
403 with an HTML body from the servernginx or Apache rule on the request pathA location or rewrite rule matching async-upload.php or wp-admin
403 with -1 or a JSON errorWordPress, from a failed nonce or capabilitymedia-form nonce, the logged-in cookie, upload_files
403 only for uploads, never for readsA security module or WAF inspecting multipart bodiesModSecurity audit log, host WAF console
Operation not permitted in the PHP logopen_basedirThe 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

  1. Capture the failing response body and the status code before touching anything. The pair separates a filesystem refusal from a server rule.
  2. Find the PHP user with ps, then compare it with stat output for uploads. If they differ, the numbers in the blog post you just read do not apply.
  3. Move the tree into one group, add the PHP user to that group, and use 2775/664 rather than 755/644.
  4. If root is not available, use the panel's own ownership or PHP-handler control instead of widening the mode.
  5. Add the execution-deny rule for uploads, and confirm it with a request rather than by reading the file.
  6. Write down which identity owns the tree, in the runbook. The next person to run chmod -R 755 should 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

Related in Hosting & Infrastructure