Andrew Welch

Andrew Welch · Insights · #craftcms #devops #security

Making the web better one site at a time, with a focus on performance, usability & SEO

· 5 min read ·

Hardening Craft CMS Permissions

An important part of hardening Craft CMS from a security point of view is getting the file permissions right


Part of hardening Craft CMS is ensuring that the file permissions are as strict as possible, while still allowing for the proper functioning of Craft CMS itself. File permissions are just one part of the larger discussion of Securing Craft.

We want the webserver to be able to write to specific directories so that things like asset uploading works, but we don’t want the webserver to be able to modify things that it shouldn’t. If a security exploit happens, we want to mitigate and contain the damage as much as possible. Additionally, proper permissions are needed for Craft CMS to even work.

Before we get into the nitty gritty, let’s review Unix file permissions.

Link Unix File Permissions Primer

Here is an infographic showing Unix file permissions:

Unix Permissions

Unix file permissions

With standard Unix POSIX permissions, every file/directory has different permissions for the file owner, group, and all (everyone else). So for example, the owner of the file might be able to read & write it, users in the file’s group file might just be able to read it, and all other users might not be able to access it at all.

You don’t need to know the gory details, but here’s how the permissions are expressed numerically:

Unix Permissions Numbers

Unix file permissions expressed numerically

For for example, this file:

-rw-r--r--  1 admin nginx     9275 Nov 18 17:50 gulpfile.js

…is writeable & readable by the owner admin, but can only be read by the user in the group nginx, and all others similarly can only read it. No one can execute it (run it as a script or other executable binary). Expressed numerically, the permissions would be 644.

Here’s a directory with similar permissions:

drwxr-xr-x 12 admin nginx     4096 Nov 18 18:21 public

You’ll notice that the execute permission is set for the directory owner, group, and all others. The x flag for directories simply means that those with permission can list the files in that directory. Expressed numerically, the permissions would be 755.

Link A Permissions Strategy for Craft CMS

Still with me? Okay, great. Now let’s look at how we might apply this knowledge to Craft CMS permissions so that our Craft install is secure, but still functions properly.

The owner of our entire Craft CMS install should be a user other than the webserver user. It might be the admin account, it might be the user account you access the server with, or it might be forge if you’re using server provisioning software like Laravel Forge.

The owner should be the only user that is able to write to every file in your Craft CMS install.

The group of our entire Craft CMS install should be the webserver group. We allow it to read any of the files in our Craft install so that it can serve up our website, but it can only write to a few specific directories.

Finally, all other users can only read the files in our Craft install. If you’re really paranoid, you could disallow even reading, but it seems a bit overkill unless you’re using a shared hosting environment (which you really shouldn’t be these days).

Nothing in our Craft CMS install (other than directories, and any shell scripts you might be using) needs to be executable. This is because .php files aren’t actually executed, they are read in and parsed by either php or php-fpm.

The webserver group needs to be able to write to:

  • craft/storage for Craft’s normal operation
  • Any designated asset directories, so that the client can upload images & other assets

That’s it! The Installing docs for Craft CMS state that the webserver also needs to be able to write to craft/config and craft/app, however write access to craft/config is only needed to install the license.key file, and write access to craft/app is only needed to allow for one-click updates.

Instead, I recommend that you install the license.key file in local dev, and use whatever deployment tool you use to push it to your staging and live production servers. Similarly, I recommend that you update & test any Craft CMS updates in local dev, and then push them to staging and live production. Then disable one-click updates on staging and live production by adding this to your craft/config/general.php file:

        'allowAutoUpdates' => false,

Yes, auto-updates are convenient; and you can still do them in local dev. But we really want a way to test updates before deploying them to live production. And giving the webserver write access to the craft/app and craft/config directories potentially allows some as-yet-undiscovered exploit to do bad things to our website.

If you prefer or require that craft/app and craft/config are writeable, that’s fine. Just go into it with eyes wide open.

Link Shell Scripts to Make it Simple!

Don’t worry, you’re not going to have to do all of this by hand. I’ve created some handy craft-scripts shell scripts to make setting Craft CMS install permissions easy. To use them, you’ll need to do the following:

  1. Download or clone the craft-scripts git repo
  2. Copy the scripts folder into the root directory of your Craft CMS project
  3. Duplicate the file, and rename it to
  4. Add to your .gitignore file
  5. Then open up the file into your favorite editor, and replace REPLACE_ME with the appropriate settings.

There are a number of settings in this file, but we only need to concern ourselves with the following for setting file permissions:

# Local path constants; paths should always have a trailing /

# Local user & group that should own the Craft CMS install

# Local directories that should be writeable by the $CHOWN_GROUP

LOCAL_ROOT_PATH is the absolute path to the root of your local Craft install, with a trailing / after it.

LOCAL_ASSETS_PATH is the path to your assets directories relative to LOCAL_ROOT_PATH, with a trailing / after it.

LOCAL_CHOWN_USER is the local user that is the owner of your entire Craft install, as discussed previously.

LOCAL_CHOWN_GROUP is the local webserver group, usually either nginx or apache.

LOCAL_WRITEABLE_DIRS is a quoted list of directories relative to LOCAL_ROOT_PATH that should be writeable by your webserver.

So for example, here’s what part of my looks like for this webserver:

# The path of the `craft` folder, relative to the root path; paths should always have a trailing /

# Local path constants; paths should always have a trailing /

# Local user & group that should own the Craft CMS install

# Local directories relative to LOCAL_ROOT_PATH that should be writeable by the $CHOWN_GROUP

The reason that both the owner and the group are both forge is because there is both a forge user, and a forge group when using Laravel Forge.

You might wonder why all of this is in a file, rather than in the script itself. The reason is so that the same scripts can be used in multiple environments such as local dev, staging, and live production without modification. We just create a file in each environment, and keep it out of our git repo via .gitignore.

Tangent: For a more in-depth discussion of multiple environments, check out the Multi-Environment Config for Craft CMS article.

Alright, now that we have our all filled out, to set our file permissions we just ssh into our server, cd to the scripts directory, and type:


That’s it! If it complains about permission errors, you might need to type sudo ./ instead (and you will need to type your sudo password to authenticate).

For the curious, here’s what the script looks like:


# Set Permissions
# Set the proper, hardened permissions for an install
# @author    nystudio107
# @copyright Copyright (c) 2017 nystudio107
# @link
# @package   craft-scripts
# @since     1.1.0
# @license   MIT

# Get the directory of the currently executing script
DIR="$(dirname "${BASH_SOURCE[0]}")"

# Include files
    if [ -f "${DIR}/${INCLUDE_FILE}" ]
        source "${DIR}/${INCLUDE_FILE}"
        echo 'File "${DIR}/${INCLUDE_FILE}" is missing, aborting.'
        exit 1

# The permissions for all files & directories in the Craft CMS install
GLOBAL_DIR_PERMS=755     # `-rwxr-xr-x`
GLOBAL_FILE_PERMS=644    # `-rw-r--r--`

# The permissions for files & directories that need to be writeable
WRITEABLE_DIR_PERMS=775  # `-rwxrwxr-x`
WRITEABLE_FILE_PERMS=664 # `-rw-rw-r--`

# Set project permissions
echo "Setting base permissions for the project ${LOCAL_ROOT_PATH}"
find "${LOCAL_ROOT_PATH}" -type f ! -name "*.sh" -exec chmod $GLOBAL_FILE_PERMS {} \;

        if [ -d "${FULLPATH}" ]
            echo "Fixing permissions for ${FULLPATH}"
            chmod -R $WRITEABLE_DIR_PERMS "${FULLPATH}"
            find "${FULLPATH}" -type f ! -name "*.sh" -exec chmod $WRITEABLE_FILE_PERMS {} \;
            echo "Creating directory ${FULLPATH}"
            mkdir "${FULLPATH}"
            chmod -R $WRITEABLE_DIR_PERMS "${FULLPATH}"

# Normal exit
exit 0

Note that it will create any directories you specified in LOCAL_WRITEABLE_DIRS if they don’t exist, which is handy because craft/storage, for instance, should always be excluded from your git repo via .gitignore, but Craft won’t function unless it exists (and is writeable).

Once you have a set up for each environment, you can set the permissions in each the exact same way.

So grab craft-scripts and give ’em a whirl. Now relax, and enjoy.

Link Permissions and Git

If you use git, and change file permissions on your remote server, you may encounter git complaining about overwriting existing local changes when you try to deploy. This is because git considers changing the executable flag to be a change in the file, so it thinks you changed the files on your server (and the changes are not checked into your git repo).

To fix this, we just need to tell git to ignore permission changes on the server. You can change the fileMode setting for git on your server, telling it to ignore permission changes of the files on the server:

    git config --global core.fileMode false

See the git-config man page for details.

The other way to fix this is to set the permission using in local dev, and then check the files into your git repo. This will cause them to be saved with the correct permissions in your git repo to begin with.

The downside to the latter approach is that you must have matching user/groups in both local dev and on live production.

${ category } · ${ blog.postDate }

${ blog.title }

#${ tag.title }