ChmodCalc

“Permission denied” — which one?

The phrase is printed by at least six different programs for at least six different reasons, and escalating the mode fixes some of them and breaks others. Set the mode you have below and the calculator names the rules it fails; the index further down maps each real error string to the line of source that emits it.

chmod — file mode
whoreadwriteexecutedigit
Owner6
Group4
Others4
special bits — the fourth digit

ls -l → -rw-r--r--

chmod 644 ./deploy.sh
chmod u=rw,g=r,o=r ./deploy.sh

Absolute symbolic form — identical result, and the only form that can name the setuid, setgid and sticky bits.

chmod a-x ./deploy.sh

Relative form: what changed from 755. It leaves every other bit alone, which is what you want on a file you did not create.

what 644 grants on this file

  • Ownerrw-read the contents and change the contents
  • Groupr--read the contents
  • Othersr--read the contents

programs that refuse this mode

  • OpenSSH client (ssh, scp, sftp, git over ssh)

    Permissions 0644 for './deploy.sh' are too open.

    fix: chmod 600 ./deploy.sh

  • Git

    hint: The './deploy.sh' hook was ignored because it's not set as executable.

    fix: chmod +x ./deploy.sh

  • Any POSIX shell (bash, dash, zsh, sh)

    ./deploy.sh: Permission denied (exit status 126)

    fix: chmod +x ./deploy.sh

Start here: what your message tells you before you change anything

Three quick discriminations save most of the time people spend on this.

  • Exit status 126 or 127? 126 means the command was found and could not be run — a mode problem. 127 means it was not found — a path problem. Both print something that reads like a permission error.
  • Does the message name a directory or a file? Apache and OpenSSH both tell you which, and the distinction points at completely different fixes. A directory needs the search bit; a file needs read or execute.
  • Does the message contain the word "configuration"? If so, no chmod will help. Apache's client denied by server configuration comes from a Require directive and is included in the index precisely because it is the message most often answered with chmod 777.

Ten modes against every rule in the index

Ten modes people actually type, crossed with every mode-driven rule in the index. Each cell is that rule's own guard expression evaluated at that mode, so the grid is arithmetic and no part of it is opinion. Read down a column to see which modes a given program rejects; read across a row to see what a mode costs you.

✗ means the rule fires — the program refuses. Blank means it does not. The rules are the 10 mode-driven entries from the index below; the 2 entries that no mode can change are excluded, because a column of identical cells says nothing.
Mode ssh-privkey-too-open ssh-config-bad-owner sshd-authkeys-file sshd-authkeys-dir httpd-search-permissions nginx-open-failed-eacces cron-bad-file-mode sudoers-world-writable git-hook-ignored shell-exit-126 Fails
000 · · · · ✗ ✗ ✗ · ✗ ✗ 5
400 · · · · ✗ ✗ · · ✗ ✗ 4
600 · · · · ✗ ✗ · · ✗ ✗ 4
644 ✗ · · · ✗ · · · ✗ ✗ 4
664 ✗ ✗ ✗ ✗ ✗ · ✗ · ✗ ✗ 8
666 ✗ ✗ ✗ ✗ ✗ · ✗ ✗ ✗ ✗ 9
700 · · · · ✗ ✗ ✗ · · · 3
755 ✗ · · · · · ✗ · · · 2
775 ✗ ✗ ✗ ✗ · · ✗ · · · 5
777 ✗ ✗ ✗ ✗ · · ✗ ✗ · · 6

Rules apply to different targets — a mode is only judged by the rules that govern the kind of file it is on — so the "fails" column is not a quality score. What it does show is the shape of the space: 755 fails the fewest (2 ), and 777 fails 6 rules where 000 fails 5. Escalating permissions is not a monotonic move toward "more likely to work" — past a point it is a move away from it.

The index

Each row: the message as the program prints it, the file and function it comes from, the condition that fires it, and the fix. Nothing here was written from memory — all 26 sources were opened and read, and every row links back to the file so you can check it. Where a message is assembled from more than one source file, both are named.

Every string below was copied out of the named source file, all of them read on 2026-08-15. The “fires on” column quotes the guard where it is a single expression, and describes the branch where the message is reached some other way. Either way the mode rule it encodes is evaluated across all 4096 modes and counted — it is not an estimate.
What you see Where it comes from Fires on Fix
Permissions 0644 for '/home/deploy/.ssh/id_ed25519' are too open.

OpenSSH client (ssh, scp, sftp, git over ssh) · stderr, before the connection is attempted

shown for mode 644 on ~/.ssh/id_ed25519 (a private key)

  • @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
  • @ WARNING: UNPROTECTED PRIVATE KEY FILE! @
  • @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
  • It is required that your private key files are NOT accessible by others.
  • This private key will be ignored.

verbatim format string: Permissions 0%3.3o for '%s' are too open.

OpenSSH (portable) — authfile.c · sshkey_perm_ok()

(st.st_uid == getuid()) && (st.st_mode & 077) != 0

quoted verbatim from the source

4032 of the 4096 modes fire it.

chmod 600 /home/deploy/.ssh/id_ed25519

least-privilege mode 600

Bad owner or permissions on /home/deploy/.ssh/config

OpenSSH client (ssh) · stderr; ssh exits immediately (fatal)

shown for mode 664 on ~/.ssh/config

verbatim format string: Bad owner or permissions on %s

OpenSSH (portable) — readconf.c · read_config_file_depth(), SSHCONF_CHECKPERM branch

((sb.st_uid != 0 && sb.st_uid != getuid()) || (sb.st_mode & 022) != 0)

quoted verbatim from the source

3072 of the 4096 modes fire it.

chmod 600 /home/deploy/.ssh/config

least-privilege mode 600

Authentication refused: bad ownership or modes for file /home/deploy/.ssh/authorized_keys

OpenSSH server (sshd) · the server’s log — /var/log/auth.log or journalctl -u sshd

shown for mode 664 on ~/.ssh/authorized_keys on the server

verbatim format string: bad ownership or modes for file %s

OpenSSH (portable) — misc.c · safe_path()

OpenSSH (portable) — auth2-pubkeyfile.c · auth_openfile()

safe_path() in misc.c writes this into an error buffer; auth_openfile() in auth2-pubkeyfile.c prints the buffer with logit("Authentication refused: %s", line).

(!platform_sys_dir_uid(stp->st_uid) && stp->st_uid != uid) || (stp->st_mode & 022) != 0

quoted verbatim from the source

3072 of the 4096 modes fire it.

chmod 600 /home/deploy/.ssh/authorized_keys

least-privilege mode 600

Authentication refused: bad ownership or modes for directory /home/deploy/.ssh

OpenSSH server (sshd) · the server’s log — /var/log/auth.log or journalctl -u sshd

shown for mode 775 on ~/.ssh, and every directory above it up to the home directory

verbatim format string: bad ownership or modes for directory %s

OpenSSH (portable) — misc.c · safe_path()

OpenSSH (portable) — auth2-pubkeyfile.c · auth_openfile()

Same two-file assembly as the file variant: safe_path() fills the buffer while walking upward from the key file, auth_openfile() prefixes "Authentication refused: ".

stat(buf, &st) == -1 || (!platform_sys_dir_uid(st.st_uid) && st.st_uid != uid) || (st.st_mode & 022) != 0 — inside the loop that walks each parent in turn

quoted verbatim from the source

3072 of the 4096 modes fire it.

chmod 700 /home/deploy/.ssh

least-privilege mode 700

access to /index.html denied (filesystem path '/var/www/html/index.html') because search permissions are missing on a component of the path

Apache HTTP Server · error_log, tagged AH00035

shown for mode 750 on a directory on the path to the document root

verbatim format string: access to %s denied (filesystem path '%s') because search permissions are missing on a component of the path

Apache HTTP Server — server/request.c · ap_directory_walk(), APLOGNO(00035)

APR_STATUS_IS_EACCES(rv) inside ap_directory_walk() — a branch on the errno the kernel returned while resolving a component, not an expression over st_mode

described, not quoted — the message is not guarded by one expression

2048 of the 4096 modes fire it.

Assumes that the Apache worker user is neither the owner nor in the owning group, so it is judged by the "others" digit. If the worker is in the group, read the group digit instead.

chmod 755 /var/www/html

least-privilege mode 755

client denied by server configuration: /var/www/html/members/report.pdf

Apache HTTP Server · error_log, tagged AH01630

shown for mode 644 on not a file mode at all

verbatim format string: client denied by server configuration: %s%s

Apache HTTP Server — modules/aaa/mod_authz_core.c · authorize_user_core(), APLOGNO(01630)

the AUTHZ_DENIED / AUTHZ_NEUTRAL branch of authorize_user_core() — a control-flow branch on the authorisation result, with no mode test anywhere in it

described, not quoted — the message is not guarded by one expression

No mode fires this — chmod cannot change the outcome.

edit the Require directive — no chmod will change this
open() "/var/www/html/index.html" failed (13: Permission denied)

nginx · error.log; the client gets HTTP 403

shown for mode 640 on a static file under the document root

verbatim format string: %s "%s" failed

nginx — src/http/modules/ngx_http_static_module.c · ngx_http_static_handler()

nginx — src/core/ngx_log.c · ngx_log_errno()

ngx_http_static_handler() logs the format above with of.failed ("open()") and the path; ngx_log_errno() then appends " (%d: ", the C library’s description of the errno, and ")". EACCES is 13 on Linux.

the case NGX_EACCES: arm of switch (of.err), which sets rc = NGX_HTTP_FORBIDDEN — a switch branch on the errno open() returned, with no st_mode test anywhere in it

described, not quoted — the message is not guarded by one expression

2048 of the 4096 modes fire it.

Assumes that the nginx worker user is neither the owner nor in the owning group, so the "others" digit decides. A file owned by the worker user needs only the owner read bit.

chmod 644 /var/www/html/index.html

least-privilege mode 644

(deploy) BAD FILE MODE (/var/spool/cron/deploy)

cron (cronie) · syslog / journalctl -u crond

shown for mode 700 on the crontab file itself, e.g. /var/spool/cron/deploy

verbatim format string: (%s) %s (%s)%s%s

cronie — src/database.c · check_open()

cronie — src/misc.c · log_it()

check_open() in database.c calls log_it(uname, pid, "BAD FILE MODE", tabname, 0); log_it() in misc.c formats it for syslog with the format above, and the two trailing conversions stay empty because the err argument is 0.

(statbuf.st_mode & 07533) != 0400

quoted verbatim from the source

4088 of the 4096 modes fire it.

chmod 600 /var/spool/cron/deploy

least-privilege mode 600

(deploy) WRONG FILE OWNER (/var/spool/cron/deploy)

cron (cronie) · syslog / journalctl -u crond

shown for mode 600 on the crontab file itself

verbatim format string: (%s) %s (%s)%s%s

cronie — src/database.c · check_open()

Same log_it() format, with the event string "WRONG FILE OWNER".

statbuf.st_uid != ROOT_UID && (pw == NULL || statbuf.st_uid != pw->pw_uid || strcmp(uname, pw->pw_name) != 0)

quoted verbatim from the source

No mode fires this — chmod cannot change the outcome.

chown deploy /var/spool/cron/deploy
/etc/sudoers.d/deploy is world writable

sudo · stderr, and sudo refuses to run

shown for mode 646 on /etc/sudoers or a file in /etc/sudoers.d/

verbatim format string: %s is world writable

sudo — plugins/sudoers/sudoers.c · open_sudoers()

sudo — lib/util/secure_path.c · sudo_check_secure()

sudo_check_secure() in lib/util/secure_path.c classifies the mode and returns SUDO_PATH_WORLD_WRITABLE; open_sudoers() in plugins/sudoers/sudoers.c turns that return value into the message above.

sb->st_mode & S_IWOTH → SUDO_PATH_WORLD_WRITABLE, in sudo_check_secure()

quoted verbatim from the source

2048 of the 4096 modes fire it.

chmod 440 /etc/sudoers.d/deploy

least-privilege mode 440

hint: The '.git/hooks/pre-commit' hook was ignored because it's not set as executable.

Git · stderr, as advice during commit/push

shown for mode 644 on .git/hooks/pre-commit and friends

  • hint: You can disable this warning with `git config set advice.ignoredHook false`.

verbatim format string: The '%s' hook was ignored because it's not set as executable.\nYou can disable this warning with `git config set advice.ignoredHook false`.

Git — hook.c · find_hook()

Git — advice.c · vadvise()

find_hook() in hook.c passes the format above to advise(); vadvise() in advice.c splits it on newlines and prints each line through fprintf(stderr, _("%shint:%s%.*s%s\n"), …), so every line reaches the terminal with a "hint: " prefix that is not part of the format string.

found_hook = access(path.buf, X_OK) >= 0; … if (!found_hook) { if (errno == EACCES && advice_enabled(ADVICE_IGNORED_HOOK))

quoted verbatim from the source

512 of the 4096 modes fire it.

chmod +x .git/hooks/pre-commit

least-privilege mode 755

./deploy.sh: Permission denied (exit status 126)

Any POSIX shell (bash, dash, zsh, sh) · stderr, plus exit status 126

shown for mode 644 on a script you are trying to run, e.g. ./deploy.sh

verbatim, quoted from the standard: POSIX: "Otherwise, if the command name is found, but it is not an executable utility, the exit status shall be 126."

POSIX.1-2024 (IEEE Std 1003.1-2024) — XCU §2.8.2 Exit Status for Commands · Shell Command Language

Linux kernel — include/uapi/asm-generic/errno-base.h · EACCES / EPERM

specified by POSIX, with no single expression guarding it: the command name resolves but is not an executable utility. Each shell implements it its own way

described, not quoted — the message is not guarded by one expression

512 of the 4096 modes fire it.

Assumes that the caller is the owner, which is the usual case for a script in your own checkout. Otherwise read the group or other digit.

chmod +x ./deploy.sh

least-privilege mode 755

ssh-privkey-too-open — The check only runs when the key is owned by the user running ssh. A key owned by somebody else is not mode-checked at all — the comment in the source says so explicitly. Note also that the printed number is st_mode & 0777, so a set-user-ID bit on the key never appears in the message.

ssh-config-bad-owner — This one is fatal: ssh stops on the spot instead of ignoring the file and carrying on.

sshd-authkeys-file — The client sees only "Permission denied (publickey)". The sentence above exists only in the server’s log, which is why this failure is so often misdiagnosed as a wrong key.

sshd-authkeys-dir — The mask is 022, not 077. sshd does not require ~/.ssh to be 700 — 755 passes this test. 700 is the mode ssh(1) recommends, which is a different statement from the one sshd enforces.

httpd-search-permissions — Apache names the failing URI and the filesystem path separately, which is the fastest way to see that a parent directory is at fault when you had been staring at the file you were trying to serve.

httpd-authz-denied — If your error line says "client denied by server configuration", stop changing modes and read the <Directory> block.

nginx-open-failed-eacces — The message is assembled from two pieces: the module supplies the operation name and path, and the logger appends the numeric errno with the C library’s description. 13 is EACCES on Linux.

cron-bad-file-mode — The whole block is skipped when cron is built or configured with PermitAnyCrontab. Note what the mask permits: 0644 passes, and so does 0400. What fails is anything executable, anything group- or world-writable, and — the one that catches people — 0700.

cron-wrong-file-owner — Editing a crontab with `crontab -e` sets both owner and mode correctly. Files copied into the spool directory by hand are where this appears.

sudoers-world-writable — Two mode conditions, not one: the next branch, ISSET(sb->st_mode, S_IWGRP) && (gid == (gid_t)-1 || sb->st_gid != gid), rejects a group-writable file too unless its group is the one sudo expects — so 664 fails here for a different reason than 666 does. Fix these with visudo, which validates before replacing the file.

git-hook-ignored — The advice is printed at most once per hook name per process, and only when errno is EACCES — a hook that is missing entirely produces nothing at all. If you are grepping a log for this, grep for the text after the prefix: the "hint: " comes from the advice printer, not from the message.

shell-exit-126 — The wording of the message before the exit status is the shell’s own and varies — bash prints "bash: ./deploy.sh: Permission denied". What is specified, and therefore what you can rely on in a script, is the 126.

Two rules that no chmod will help with

Of the 12 rows, 2 fire on no mode at all: httpd-authz-denied and cron-wrong-file-owner. One is an Apache authorisation decision, made by mod_authz_core before the filesystem is consulted; the other is file ownership, which chmod does not touch. Both are included because they produce symptoms indistinguishable from a mode problem — a 403 and a cron job that never runs — and both are routinely "fixed" by widening permissions that were never the cause.

The diagnostic commands worth knowing

namei -l /path/to/file
Prints every component of the path with its mode and owner, one per line. This is the single most useful command for a 403 or a refused SSH key, because it shows the whole chain at once instead of one ls -ld at a time.
sudo -u www-data test -r /var/www/html/index.html; echo $?
Asks the question directly, as the user that matters. 0 means readable. This settles arguments about which permission class the worker falls into faster than reasoning about group membership.
stat -c '%a %U:%G %n' /path/to/file
Mode in octal, owner, group, name — the four things every one of these rules reads.
journalctl -u sshd -n 50 / tail -50 /var/log/auth.log
Where the server-side SSH reason lives. The client never prints it; it only ever says Permission denied (publickey).

One habit is worth more than all four: read the message before changing the mode. Every rule in the index above names either a file or a directory, and most of them say which test failed. The information needed to fix the problem in one command is usually already on your screen — it is just not phrased as a mode.

## Questions people actually ask

How do I fix “Permission denied”?

First find out which permission. The phrase is printed by shells, servers, cron, Git and SSH for different reasons, and only some of them are file modes. Match your exact message against the index on this page: each row gives the guard expression from the program's own source, the mode that trips it, and the command that clears it. Of the 12 rows here, 2 cannot be fixed by chmod at all.

What does exit status 126 mean?

The command was found and could not be executed. POSIX states it exactly: "Otherwise, if the command name is found, but it is not an executable utility, the exit status shall be 126." Compare 127, which POSIX defines as "If the command is not found, the exit status shall be 127". 126 is a chmod problem; 127 is a path or spelling problem. In a CI log those two digits save you ten minutes.

The web server returns 403 but the file is 644. What now?

Check the directories, not the file. A path is resolved component by component and each component needs the search bit; the file's own mode is consulted last. Run namei -l /var/www/html/index.html to print the mode of every component at once. Apache states the cause in as many words in its error log — the string is in the index below.

My cron job never runs and there is nothing in the log.

Look for BAD FILE MODE or WRONG FILE OWNER in the cron log, both of which name the crontab file and not your script. The test in cronie — the cron whose source this site read — is (st_mode & 07533) != 0400, which rejects any crontab with an execute bit or a group/other write bit. Other cron implementations check different things or nothing at all. A crontab tightened to 700 for "security" stops working, and the log line does not use the word "permission".

Should I just use sudo?

Running the command as root will get past a mode check, and it will also create whatever files the command writes as root — which produces the next permission problem, one directory deeper, with no obvious cause. If the correct answer is that a service account needs access, give it access; if the correct answer is that you need root, use sudo deliberately, not as an error-suppression tool.

Where did these error strings come from?

From the source code of the programs that print them: OpenSSH, Apache httpd, nginx, cronie, sudo, Git, systemd, the Linux kernel and the POSIX standard — 26 sources in all. Every row links to the file and names the function, so a claim you doubt is one click from being checked.

elsewhere on this site

other tools we run