Andrew Welch · Insights · #craftcms #devops #security

Published , updated · 5 min read ·

Please consider 🎗 sponsoring me 🎗 to keep writing articles like this.

Hardening Craft CMS Permissions

An impor­tant part of hard­en­ing Craft CMS from a secu­ri­ty point of view is get­ting the file per­mis­sions right


Update: This arti­cle has been updat­ed to cov­er both Craft CMS 2.x and Craft CMS 3.x

Part of hard­en­ing Craft CMS is ensur­ing that the file per­mis­sions are as strict as pos­si­ble, while still allow­ing for the prop­er func­tion­ing of Craft CMS itself. File per­mis­sions are just one part of the larg­er dis­cus­sion of Secur­ing Craft.

We want the web­serv­er to be able to write to spe­cif­ic direc­to­ries so that things like asset upload­ing works, but we don’t want the web­serv­er to be able to mod­i­fy things that it should­n’t. If a secu­ri­ty exploit hap­pens, we want to mit­i­gate and con­tain the dam­age as much as pos­si­ble. Addi­tion­al­ly, prop­er per­mis­sions are need­ed for Craft CMS to even work.

Before we get into the nit­ty grit­ty, let’s review Unix file permissions.

Link Unix File Permissions Primer

Here is an info­graph­ic show­ing Unix file permissions:

Unix Permissions

Unix file permissions

With stan­dard Unix POSIX per­mis­sions, every file/​directory has dif­fer­ent per­mis­sions for the file owner, group, and all (every­one else). So for exam­ple, 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 oth­er users might not be able to access it at all.

You don’t need to know the gory details, but here’s how the per­mis­sions are expressed numerically:

Unix Permissions Numbers

Unix file per­mis­sions expressed numerically

For for exam­ple, this file:

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

…is write­able & read­able by the owner admin, but can only be read by the user in the group nginx, and all oth­ers sim­i­lar­ly can only read it. No one can execute it (run it as a script or oth­er exe­cutable bina­ry). Expressed numer­i­cal­ly, the per­mis­sions would be 644.

Here’s a direc­to­ry with sim­i­lar permissions:

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

You’ll notice that the execute per­mis­sion is set for the direc­to­ry owner, group, and all oth­ers. The x flag for direc­to­ries sim­ply means that those with per­mis­sion can list the files in that direc­to­ry. Expressed numer­i­cal­ly, the per­mis­sions 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 knowl­edge to Craft CMS per­mis­sions so that our Craft install is secure, but still func­tions properly.

The owner of our entire Craft CMS install should be a user oth­er than the web­serv­er user. It might be the admin account, it might be the user account you access the serv­er with, or it might be forge if you’re using serv­er pro­vi­sion­ing soft­ware like Lar­avel 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 web­serv­er group. We allow it to read any of the files in our Craft install so that it can serve up our web­site, but it can only write to a few spe­cif­ic directories.

Final­ly, all oth­er users can only read the files in our Craft install. If you’re real­ly para­noid, you could dis­al­low even read­ing, but it seems a bit overkill unless you’re using a shared host­ing envi­ron­ment (which you real­ly should­n’t be these days).

Noth­ing in our Craft CMS install (oth­er than direc­to­ries, and any shell scripts you might be using) needs to be executable. This is because .php files aren’t actu­al­ly exe­cut­ed, they are read in and parsed by either php or php-fpm.

Craft CMS 2.x and Craft CMS 3.x both have very sim­i­lar per­mis­sions require­ments, but the fold­er struc­ture is slight­ly dif­fer­ent. Check out the Set­ting up a New Craft CMS 3 Project arti­cle for details on the differences.

Link Craft CMS 2.x Permissions

The web­serv­er group needs to be able to write to:

  • craft/storage for Craft’s nor­mal operation
  • Any des­ig­nat­ed asset direc­to­ries, so that the client can upload images & oth­er assets

That’s it! The Installing Craft CMS 2.x Instruc­tions state that the web­serv­er also needs to be able to write to craft/config and craft/app, how­ev­er write access to craft/config is only need­ed to install the license.key file, and write access to craft/app is only need­ed to allow for one-click updates.

Instead, I rec­om­mend that you install the license.key file in local dev, and use what­ev­er deploy­ment tool you use to push it to your staging and live pro­duc­tion servers. Sim­i­lar­ly, I rec­om­mend that you update & test any Craft CMS updates in local dev, and then push them to staging and live pro­duc­tion. Then dis­able one-click updates on staging and live pro­duc­tion by adding this to your craft/config/general.php file:

'allowAutoUpdates' => false,

Yes, auto-updates are con­ve­nient; and you can still do them in local dev. But we real­ly want a way to test updates before deploy­ing them to live pro­duc­tion. And giv­ing the web­serv­er write access to the craft/app and craft/config direc­to­ries poten­tial­ly allows some as-yet-undis­cov­ered exploit to do bad things to our website.

If you pre­fer or require that craft/app and craft/config are write­able, that’s fine. Just go into it with eyes wide open.

Link Craft CMS 3.x Permissions

The web­serv­er group needs to be able to write to the fol­low­ing directories:

  • storage/ — for Craft’s nor­mal operation
  • vendor/ — this is where Com­pos­er puts its PHP pack­ages for your project
  • web/cpresources/ — this is a cache direc­to­ry for AdminCP resources
  • Any des­ig­nat­ed asset direc­to­ries, so that the client can upload images & oth­er assets

Then due to Craft CMS 3 using Com­pos­er, it also needs to be able to write to a few spe­cif­ic files as well:

  • .env — for your envi­ron­ment-spe­cif­ic vari­ables like pass­words, etc.
  • composer.json — a list of Com­pos­er pack­ages that your project requires
  • composer.lock — a list of Com­pos­er pack­ages that are installed
  • config/license.key — your Craft CMS 3 license file

That’s it! You can check out the Craft CMS 3 Instal­la­tion Instruc­tions in more depth if you like. I con­tin­ue to rec­om­mend that you don’t allow updates to be done on live pro­duc­tion or stag­ing servers, via the fol­low­ing in your config/general.php file:

'allowUpdates' => false,

This is cov­ered in-depth in the Set­ting up a New Craft CMS 3 Project arti­cle, but the basic premise is that we update and test in local devel­op­ment, and once we know every­thing works, we deploy the updates to live pro­duc­tion and/​or staging.

Link Shell Scripts to Make it Simple!

Don’t wor­ry, you’re not going to have to do all of this by hand. I’ve cre­at­ed some handy craft-scripts shell scripts to make set­ting Craft CMS install per­mis­sions easy. To use them, you’ll need to do the following:

  1. Down­load or clone the craft-scripts git repo
  2. Copy the scripts fold­er into the root direc­to­ry of your Craft CMS project
  3. Dupli­cate the file, and rename it to
  4. Add to your .gitignore file
  5. Then open up the file into your favorite edi­tor, and replace REPLACE_ME with the appro­pri­ate settings.

There are a num­ber of set­tings in this file, but we only need to con­cern our­selves with the fol­low­ing for set­ting 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 trail­ing / after it.

LOCAL_ASSETS_PATH is the path to your assets direc­to­ries rel­a­tive to LOCAL_ROOT_PATH, with a trail­ing / after it.

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

LOCAL_CHOWN_GROUP is the local web­serv­er group, usu­al­ly either nginx or apache.

LOCAL_WRITEABLE_DIRS is a quot­ed list of direc­to­ries rel­a­tive to LOCAL_ROOT_PATH that should be write­able by your webserver.

So for exam­ple, 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 rea­son that both the owner and the group are both forge is because there is both a forge user, and a forge group when using Lar­avel Forge.

You might won­der why all of this is in a file, rather than in the script itself. The rea­son is so that the same scripts can be used in mul­ti­ple envi­ron­ments such as local dev, staging, and live pro­duc­tion with­out mod­i­fi­ca­tion. We just cre­ate a file in each envi­ron­ment, and keep it out of our git repo via .gitignore.

Tan­gent: For a more in-depth dis­cus­sion of mul­ti­ple envi­ron­ments, check out the Mul­ti-Envi­ron­ment Con­fig for Craft CMS article.

Alright, now that we have our all filled out, to set our file per­mis­sions we just ssh into our serv­er, cd to the scripts direc­to­ry, and type:


That’s it! If it com­plains about per­mis­sion errors, you might need to type sudo ./ instead (and you will need to type your sudo pass­word to authenticate).

For the curi­ous, 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 cre­ate any direc­to­ries you spec­i­fied in LOCAL_WRITEABLE_DIRS if they don’t exist, which is handy because craft/storage, for instance, should always be exclud­ed from your git repo via .gitignore, but Craft won’t func­tion unless it exists (and is writeable).

Once you have a set up for each envi­ron­ment, you can set the per­mis­sions 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 per­mis­sions on your remote serv­er, you may encounter git com­plain­ing about overwriting existing local changes when you try to deploy. This is because git con­sid­ers chang­ing the exe­cutable flag to be a change in the file, so it thinks you changed the files on your serv­er (and the changes are not checked into your git repo).

To fix this, we just need to tell git to ignore per­mis­sion changes on the serv­er. You can change the fileMode set­ting for git on your serv­er, telling it to ignore per­mis­sion changes of the files on the server:

git config --global core.fileMode false

See the git-con­fig man page for details.

The oth­er way to fix this is to set the per­mis­sion using in local dev, and then check the files into your git repo. This will cause them to be saved with the cor­rect per­mis­sions in your git repo to begin with.

The down­side to the lat­ter approach is that you must have match­ing user/​groups in both local dev and on live production.