'Permission Denied' Usually Is Not About Permissions
Updated 2026-09-23

The word in the error is the wrong word
“Permission denied” sounds like a permission problem, so people go and change permissions. On a good day that does nothing. On a bad day it works, for the wrong reason, and quietly removes a protection.
The reliable read is different. In most of these cases the system has decided you are somebody other than who you think you are, or a policy unrelated to file permissions is refusing before permissions are ever consulted.
So the first move is never to grant anything. It is to make the system state, out loud, which identity it matched you to.
MySQL: access denied for user
MySQL has the clearest example of this in all of computing, and almost nobody knows it.
A MySQL user is not a username. It is a username and a host, together, as
one identity. 'app'@'localhost' and 'app'@'%' are two different accounts.
They can have different passwords and different grants. Knowing “the password
for app” is therefore not a complete thought.
The one query that ends the guessing
SELECT USER(), CURRENT_USER();
These return different things, and the difference is the entire diagnosis.
USER()is who you claimed to be when connecting.CURRENT_USER()is the account MySQL actually matched and is enforcing.
When those disagree, you have found your bug without touching a single permission. You are being authorized as an account you did not intend to use.
Why they disagree: most specific host wins
MySQL sorts its accounts and matches the most specific host first. A literal
localhost beats the wildcard %.
The classic trap is the anonymous user. Some installations ship a row with an
empty username and host localhost. It matches any username arriving from
localhost, and because localhost is more specific than %, it wins over
your real 'app'@'%' account. You then get access denied using a password
that is completely correct, for an account you are not being matched to.
Look at the actual account list:
SELECT user, host, plugin FROM mysql.user ORDER BY user, host;
An empty user value in that output is the anonymous account, and on a server
you control it should generally not exist.
localhost and 127.0.0.1 are not the same thing
This surprises people every time. In MySQL:
localhostmeans connect over a Unix socket, ignoring the network.127.0.0.1means connect over TCP to the loopback address.
They are different transports, and they match different grant rows. So
'app'@'localhost' and 'app'@'127.0.0.1' are separate accounts, and a
connection string that works from one machine can fail from another purely
because one spelled it as a name and the other as an address.
If you need TCP explicitly:
mysql --protocol=TCP -h 127.0.0.1 -u app -p
Reading the rest of the message
Access denied for user 'app'@'localhost' (using password: NO)
using password: NO means no password was sent at all, which is a
different problem from sending the wrong one. Usually the client never picked
one up: a missing -p, an empty environment variable, or a config file that
was not read.
using password: YES means the password was sent and rejected, which is when
you start looking at the account list above.
Authentication plugin mismatch produces its own version of this. Modern
MySQL defaults to caching_sha2_password, and older clients and drivers
cannot negotiate it. The plugin column in the query above tells you which
one an account uses. A client that fails while another client succeeds with
identical credentials points here.
PowerShell: the execution policy is not a permission
cannot be loaded because running scripts is disabled on this system
This is the most misdiagnosed message in the Windows world, because the standard advice is to disable the policy machine wide, and that is almost never the right fix.
Execution policy is not a security boundary. Microsoft documents it as such. It exists to stop you running a script by accident, not to stop anyone determined, and it is trivially bypassed by design. Treating it as protection and then turning it off is the worst of both readings.
See every scope at once, because one of them is overriding the others:
Get-ExecutionPolicy -List
Scopes apply in a fixed order of precedence: MachinePolicy, then UserPolicy,
then Process, then CurrentUser, then LocalMachine. A Group Policy setting at
the top cannot be overridden locally, and if that is what you are hitting, no
amount of Set-ExecutionPolicy will help. The output tells you which scope to
argue with.
The fix people skip: unblock the file
Very often the policy is fine and the file is marked. Windows attaches a Mark of the Web to anything downloaded from the internet or extracted from a downloaded archive, and PowerShell treats marked files more strictly.
Unblock-File -Path .\script.ps1
That removes the mark on a file you have decided to trust, and leaves the policy intact for everything else. It is the correct fix for “I downloaded a script and it will not run”, and it is far better than loosening the setting globally so that every future download runs too.
If you do need to change the policy, scope it
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
Process scope lasts only for the current window and disappears when you
close it. Compare that with the usual internet advice of setting
Unrestricted for LocalMachine, which is permanent, affects every user, and
outlives the reason you did it by several years.
Linux: when the permissions genuinely do look correct
Here the message is frequently accurate, but only after you have ruled out the cases where it is not.
Check what you are looking at
ls -l /path/to/file
id
id is the equivalent of MySQL’s CURRENT_USER(). Running under a different
user or a different group set than you assume, particularly inside sudo,
su, a service unit or a container, explains a large share of these.
A recently added group does not apply to your current session. Group
membership is read at login. Adding yourself to docker or www-data and
then immediately retrying will fail, and the fix is to log out and back in,
not to change permissions. Compare id with the group list in
/etc/group to confirm.
Execute permission on directories is not what people assume. For a
directory, x means “may traverse into”, not “may run”. You need x on every
directory along the path. A file readable by everyone, sitting inside a
directory with no traversal permission, is unreachable, and the error blames
the file.
Permissions look perfect and it still fails. This is normally SELinux or AppArmor refusing after the ordinary check passed. Standard tooling shows nothing wrong because nothing is wrong at that layer.
ls -Z /path/to/file
sudo ausearch -m avc -ts recent
If ausearch produces entries mentioning your process, stop editing
permissions. The denial is coming from somewhere else entirely, and the log
line names the exact rule.
Immutable files are a rarer but very confusing case. A file can be locked against modification even for root:
lsattr /path/to/file
An i in that output means immutable, and it will refuse writes no matter how
generous the mode bits are.
Docker: the two permission errors worth knowing
Permission denied on the Docker socket means your user is not in the
docker group, or is but has not logged out since being added, per the group
caveat above. Worth knowing before you reach for it: membership in that group
is effectively equivalent to root access on the host, because it lets you
start a container that mounts the entire filesystem. It is a reasonable choice
on your own machine and a considered one on a shared server.
Permission denied on a mounted volume is a user ID mismatch. The container has its own idea of which numeric UID owns what, and a file owned by UID 1000 on the host is owned by whatever UID 1000 means inside the image, which may be a different account or none at all. The file did not change. The interpretation of its owner did.
docker run --user "$(id -u):$(id -g)" ...
And the case that is not a permission error at all: a private image that reports “not found” rather than “access denied”. Registries hide the existence of repositories you cannot see, so an authentication failure arrives dressed as a missing image. Covered in our companion guide on why “not found” errors lie.
Windows: access denied on a file
Before touching an ACL, rule out the common impostor: the file is in use. Windows reports a sharing violation in terms that read like a permission problem. If a program has the file open, no permission change will help, and granting yourself more access teaches you nothing about which program it is.
When it really is permissions, look before you change anything:
icacls "C:\path\to\file"
Ownership and permission are different things. An administrator who cannot read a file can usually take ownership of it and then grant access, which is two operations rather than one. If a single permission change appears not to work, this distinction is often why.
The short version
Do not start by granting. Start by asking the system who it thinks you are.
- MySQL:
SELECT USER(), CURRENT_USER();and stop as soon as they disagree. - PowerShell:
Get-ExecutionPolicy -Listto find the scope, thenUnblock-Filerather than loosening the policy. - Linux:
idfirst, then remember that a new group needs a new login, and that SELinux denies silently after the normal check passes. - Docker: socket errors are group membership, volume errors are UID mismatch, and “not found” on a private image is authentication.
The permissions are usually correct. The identity is usually not.
☕ Coffee Corner
Compiling, deploying, or waiting on a render? Here's what to brew while you wait.
🏠 Smart Home Picks
Same hobbyist care applied to your network and your front door.




