Linux ACLs: setfacl, getfacl and the Mask Explained

Standard Linux permissions give you three slots. Owner, group, everyone else. That covers most of what you need, right up until the afternoon somebody asks you to give one service account read access to one directory, and you realise there’s nowhere to put it.

You could add the service account to the owning group. Now it has access to everything else that group owns. You could loosen the “other” bits. Now the whole machine can read it. Neither answer is good, and both are how directories quietly end up world readable.

If the standard model is still new to you, Linux commands and directory structure covers the ground this article builds on. Access Control Lists solve this properly. They let you attach permissions for as many extra users and groups as you like, per file, without touching the existing ownership. The two commands are getfacl to read them and setfacl to change them.

Everything below was run on AlmaLinux 9.8 with acl 2.4.0, though none of it is distribution specific and it behaves the same on Ubuntu, Debian and Rocky. The output is copied from the terminal rather than typed from memory, which matters here because one of the behaviours involved surprises almost everyone the first time they meet it.

Checking your system is ready

The tools live in a package called acl on every major distribution:

# Debian and Ubuntu
sudo apt install acl

# RHEL, Rocky, AlmaLinux, Fedora
sudo dnf install acl

The filesystem has to support ACLs too, though on anything modern that’s already sorted. ext4, XFS and Btrfs all enable ACL support by default, so unless you’re on something unusual or a deliberately restricted mount, there’s nothing to configure. If setfacl comes back with “Operation not supported”, that’s when to go looking at your mount options.

Reading an ACL

Start with a plain file that has no ACL on it at all:

$ ls -l index.html
-rw-r--r-- 1 asif webteam 6 Aug 23 09:14 index.html

$ getfacl index.html
# file: index.html
# owner: asif
# group: webteam
user::rw-
group::r--
other::r--

Those three lines are just the normal permissions written differently. user::rw- is the owner, group::r-- is the owning group, other::r-- is everyone else. Same information ls -l gave you, different presentation.

Granting access to one user

Now give the nginx user read access, without changing ownership and without touching the group:

$ setfacl -m u:nginx:r index.html

$ ls -l index.html
-rw-r--r--+ 1 asif webteam 6 Aug 23 09:14 index.html

Look closely at the permission string. There’s a + on the end now. That plus sign is the only hint ls gives you that a file carries an ACL, and it’s easy to scroll straight past. When permissions on a server aren’t behaving the way the mode bits suggest they should, that + is the first thing worth looking for.

$ getfacl index.html
# file: index.html
# owner: asif
# group: webteam
user::rw-
user:nginx:r--
group::r--
mask::r--
other::r--

Two things changed. There’s a user:nginx:r-- entry, which is what we asked for. And a mask::r-- line appeared, which we didn’t ask for and which is about to become the most important line in this article.

The mask

The mask is a ceiling. The acl(5) manual page describes it as the maximum access rights that can be granted by named user entries, named group entries, and the owning group. Anything above that ceiling gets clipped.

Add a group entry with write permission and watch what the mask does:

$ setfacl -m g:apache:rw index.html

$ getfacl index.html
# file: index.html
# owner: asif
# group: webteam
user::rw-
user:nginx:r--
group::r--
group:apache:rw-
mask::rw-
other::r--

The mask widened to rw- on its own, because setfacl recalculates it as the union of everything you’ve granted. That’s usually what you want and it’s why the mask stays invisible most of the time.

Note that the file owner and “other” are not subject to the mask at all. It only constrains the entries in the middle.

The chmod trap

Here’s the part that catches people, and it’s worth understanding before you deploy ACLs anywhere that matters.

Once a file has an ACL with a mask, the group permission bits you see in ls -l are no longer the owning group’s permissions. They’re the mask. The manual is explicit about it: if the ACL has a mask entry, the group permissions correspond to the permissions of the mask entry. That single sentence in acl(5) explains most of the confusion people have with ACLs.

Which means a perfectly ordinary chmod does this:

$ chmod g-w index.html

$ getfacl index.html
# file: index.html
# owner: asif
# group: webteam
user::rw-
user:nginx:r--
group::r--
group:apache:rw-	#effective:r--
mask::r--
other::r--

The apache group still has rw- sitting there in the ACL. It just can’t use it. The mask dropped to r--, and getfacl is now annotating that entry with #effective:r-- to tell you the write permission is being clipped.

Nothing errored. Nothing warned you. The entry you carefully added is still visible in the ACL, which is exactly why this is so confusing to debug: you check the ACL, your grant is right there, and it still doesn’t work.

Any #effective: annotation in getfacl output means the mask is clipping something. Fix it by setting the mask back:

$ setfacl -m m::rw index.html

$ getfacl index.html
# file: index.html
# owner: asif
# group: webteam
user::rw-
user:nginx:r--
group::r--
group:apache:rw-
mask::rw-
other::r--

The annotation is gone and the grant works again. The practical rule: once a file has an ACL, stop reaching for chmod on it. Use setfacl for everything, including the mask.

Default ACLs and inheritance

An ACL on a directory only covers the directory itself. To have new files inside it pick up permissions automatically, you set a default ACL with -d:

$ setfacl -d -m u:nginx:rx uploads

$ getfacl uploads
# file: uploads
# owner: asif
# group: webteam
user::rwx
group::r-x
other::r-x
default:user::rwx
default:user:nginx:r-x
default:group::r-x
default:mask::r-x
default:other::r-x

The default: entries are a template. They don’t grant anything themselves, they get copied onto anything created inside afterwards:

$ touch uploads/new.html

$ getfacl uploads/new.html
# file: uploads/new.html
# owner: asif
# group: webteam
user::rw-
user:nginx:r-x	#effective:r--
group::r-x	#effective:r--
mask::r--
other::r--

The nginx entry came through. But there’s that #effective: annotation again, and this one trips people up badly, because nobody ran chmod here.

The reason is that touch creates files with mode 644, no execute bit. That creation mode narrows the mask down to r--, so the execute permission in the inherited entry gets clipped immediately. The default ACL was applied correctly. The file’s creation mode then trimmed it.

This is why granting execute through a default ACL rarely works the way people expect on regular files. For directories it’s fine, because directories are created with the execute bit set.

Existing files are not touched

Worth being clear about, because it’s a common misreading. Adding a default ACL changes nothing that already exists:

$ setfacl -d -m u:apache:rx uploads

$ getfacl uploads/older.html
# file: uploads/older.html
# owner: asif
# group: webteam
user::rw-
user:nginx:r-x	#effective:r--
group::r-x	#effective:r--
mask::r--
other::r--

No apache entry. The default only fires at creation time. For files already sitting there, apply the ACL directly and recursively:

$ setfacl -R -m u:apache:r uploads

$ getfacl uploads/older.html
# file: uploads/older.html
# owner: asif
# group: webteam
user::rw-
user:apache:r--
user:nginx:r-x
group::r-x
mask::r-x
other::r--

In practice you usually want both: -R to fix what exists, and -d so new files keep working. They’re two separate operations and skipping either one leaves you with a directory that half works.

Preview before you commit

On a live server, --test shows you the resulting ACL without writing anything:

$ setfacl --test -m u:mail:rwx index.html
index.html: u::rw-,u:mail:rwx,u:nginx:r--,g::r--,g:apache:rw-,m::rwx,o::r--,*

Notice the mask went to rwx in that preview. Running it for real would have widened the ceiling for every entry, not just the one you added. Ten seconds of --test before an -R across a directory tree is cheap insurance.

Removing entries

# Remove one named entry
$ setfacl -x u:nginx index.html

# Remove every extended entry, keep the base permissions
$ setfacl -b index.html

# Remove only the default ACL from a directory
$ setfacl -k uploads

Those three cover almost everything. The setfacl(1) manual page lists the rest, including --set, which replaces an ACL outright rather than modifying it.

After -b the file is back to plain permissions, mask and all:

$ getfacl index.html
# file: index.html
# owner: asif
# group: webteam
user::rw-
group::r--
other::r--

The + disappears from ls -l at that point too. Note that -x takes an entry without permissions: u:nginx, not u:nginx:r. Including them is a common syntax error.

Backing up and restoring ACLs

getfacl -R produces a text dump that setfacl --restore reads back. This is genuinely useful before any bulk permission change:

$ getfacl -R . > /tmp/acl-backup.txt

$ head -20 /tmp/acl-backup.txt
# file: .
# owner: asif
# group: webteam
user::rwx
group::r-x
other::r-x

# file: index.html
# owner: asif
# group: webteam
user::rw-
user:nginx:r--
group::r--
group:apache:rw-
mask::rw-
other::r--

Wipe everything, then put it back:

$ setfacl -R -b .

$ setfacl --restore=/tmp/acl-backup.txt
Warning: option --restore=file is unsafe without option -P (--physical) as it traverses symbolic links in pathnames

That warning is worth reading rather than dismissing. Without -P, the restore follows symbolic links, so a symlink pointing somewhere outside the tree you backed up can end up having permissions rewritten. On a directory tree you don’t fully control, use setfacl -P --restore=file.

Copying files without losing the ACL

This is where ACLs quietly disappear, usually during a backup or a deploy, and usually nobody notices until something breaks in production.

A plain cp drops them:

$ cp index.html plain-copy.html

$ getfacl plain-copy.html
# file: plain-copy.html
# owner: asif
# group: webteam
user::rw-
group::r--
other::r--

Gone. No warning, no error, and the copy looks fine in ls -l because there’s no + to notice missing. You need to ask for them explicitly:

$ cp --preserve=all index.html full-copy.html

$ getfacl full-copy.html
# file: full-copy.html
# owner: asif
# group: webteam
user::rw-
user:nginx:rw-
group::r--
mask::rw-
other::r--

Same story for the other tools, each with its own flag:

# tar
$ tar --acls -cf /tmp/site.tar index.html
$ tar --acls -xf /tmp/site.tar -C /tmp/untar

# rsync
$ rsync -A index.html rsync-copy.html

If rsync is part of your routine, it is worth reading alongside the rsync command in Linux, since the same flag question comes up with every sync. Both preserve the ACL correctly. The catch with tar is that --acls is needed on both the create and the extract. Get it right on one and forget it on the other and your ACLs still vanish, which is a frustrating way to discover this during a restore.

Tool Default Preserves ACLs with
cp drops them --preserve=all
rsync drops them -A (implies -p)
tar drops them --acls on create and extract
mv keeps them nothing needed on the same filesystem

Troubleshooting

Symptom Cause
Entry exists but access still denied The mask is clipping it. Look for #effective: in getfacl.
ACL stopped working after a routine change Someone ran chmod. It rewrites the mask.
New files not inheriting You set a normal ACL, not a default one. Needs -d.
Default ACL set but old files unaffected Expected. Defaults apply at creation only. Use -R for existing files.
Execute bit missing on inherited files The file creation mode narrowed the mask. Normal for regular files.
ACLs lost after a copy or restore cp, rsync and tar all drop them unless told otherwise.
Operation not supported Filesystem or mount doesn’t have ACL support.
setfacl -x rejects your syntax Remove the permissions from the entry. Use u:nginx, not u:nginx:r.
Can’t tell which files have ACLs Look for the + in ls -l.

When not to bother

ACLs are a good answer to a specific question: one or two extra identities needing access to a particular place. They’re a poor answer to “our permissions are a mess”.

If you find yourself adding named entries to dozens of directories, the real problem is usually your group design, and a well-chosen group would solve it more simply and be far easier for the next person to understand. ACLs are also invisible to anyone who only looks at ls -l, which makes them a genuine documentation burden on a shared system.

Use them where they earn their keep. A web server user that needs read access to one application directory is the textbook case, and it’s the case they handle beautifully.

Wrapping up

The commands themselves take about ten minutes to learn. getfacl to look, setfacl -m to grant, -x and -b to remove, -d for inheritance.

What takes longer is the mask, and it’s worth the effort, because nearly every “ACLs don’t work” problem turns out to be the mask doing exactly what it was designed to do. Watch for #effective: in the output, keep chmod away from files that carry an ACL, and remember that your copy tools throw ACLs away by default.

Get those three right and ACLs are reliable, boring infrastructure. Which is exactly what you want permissions to be.

For the other half of Linux file control, the attribute layer that sits underneath permissions entirely, see the chattr command. And file management in Linux covers the everyday operations these permissions apply to.

Avatar photo

Asif Khan

I have spent over 10 years working across IT systems, open source software, DevOps, Linux administration and cloud operations. Three things drive most of what I do: automation, security and resilience. Much of that work involves planning and building the platforms that sit behind services people rely on daily, which means designing for failure just as carefully as for load. Cloud computing held my attention early on, largely for its flexibility. Being able to scale up and then back down again means far less guessing about how much capacity you will need. Across projects I work with the full DevOps toolchain, from provisioning, orchestration and configuration management through to release management and microservices architecture.