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 configurationcomes from aRequiredirective and is included in the index precisely because it is the message most often answered withchmod 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.
| 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.
| 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)
verbatim format string: | 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: | 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: | 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: | 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: | 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: | 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: | 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: | 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: | 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: | 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
verbatim format string: | 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.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 -ldat 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.