WARNING: UNPROTECTED PRIVATE KEY FILE! — SSH Fix
OpenSSH silently refuses to load a private key whose mode grants any group or other permission bit, and what you get is WARNING: UNPROTECTED PRIVATE KEY FILE! followed by Load key "…": bad permissions. The key is not damaged and the passphrase is not wrong — the client simply skipped the file and moved on, so the real symptom usually shows up one step later as Permission denied (publickey).
TL;DR: chmod 600 key (or 400) is the whole fix. The parts people get wrong are the edges: 0640 fails even though it looks safe, the check does not run at all until the TCP connection succeeds — so a refused or firewalled port hides the warning — and a mode of 0200 produces a completely different message that gets misdiagnosed as a broken key.
Everything below was measured on this host: OpenSSH_10.0p2 Debian-7+deb13u4, OpenSSL 3.5.6, Linux 6.17, uid 10000. Quoted output is pasted from the runs, byte for byte. The only exception is the sshd-side branch in the "client, agent, server" section, which is quoted from the OpenSSH source and labelled as such — this machine has no sshd binary, so that branch was read, not executed.
What the client is actually checking
The check lives in sshkey_perm_ok() (authfile.c) and it is cruder than people assume. It is not "can anyone else read this file" — it is "does st_mode & 0o077 have any bit set at all". Group read, group write, group execute, other read, other write, other execute: any single one of those is a rejection. There is no exception for a key inside a 0700 directory, and no exception for a key whose group contains exactly one trusted alias.
Ownership is the second half: unless the effective uid is 0, OpenSSH also requires that you own the key. Running as root skips the ownership test, which is why sudo ssh -i /home/deploy/.ssh/id_ed25519 … can work while the same command as deploy fails.
Reproduce it in ten seconds:
mkdir -p /tmp/keyperm && cd /tmp/keyperm
ssh-keygen -q -t ed25519 -N '' -f ./id_ed25519 -C 'lab@c2cz'
chmod 644 ./id_ed25519
ssh-keygen -y -f ./id_ed25519
The third command is the cheapest way to trigger the loader without needing a server. On OpenSSH 10.0p2 the output is:
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: UNPROTECTED PRIVATE KEY FILE! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
Permissions 0644 for './id_ed25519' are too open.
It is required that your private key files are NOT accessible by others.
This private key will be ignored.
Load key "./id_ed25519": bad permissions
Note the phrase this private key will be ignored. That is the whole failure mode: no abort, no non-zero exit before the network stage, just a public-key authentication attempt with one fewer identity than you thought you had. On a jump host with several keys in ~/.ssh/config, one badly-permissioned key simply disappears from the offer list.
The same banner is emitted by ssh-add (verified below) and by ssh-keygen -p when you try to change a passphrase on a too-open key. If you have seen it and assumed it was a version quirk, it is not: it has been the same code path for years and the message is stable across current releases, though the surrounding verbose output is not.
The fix, and how to prove it worked
Two modes pass, and only two families of modes pass: owner read without group/other bits. In practice that means 600 or 400.
chmod 600 ~/.ssh/id_ed25519 # owner read+write
chmod 400 ~/.ssh/id_ed25519 # owner read only, immune to accidental edits
chmod 700 ~/.ssh # the directory, for a different reason
# prove it: exit 0 and the public key back on stdout, no banner
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null && echo "key loads"
ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes user@host true
400 is the better default for keys that never need to change. It costs nothing (OpenSSH only reads the file) and it makes an accidental in-place rewrite fail loudly instead of succeeding quietly.
The directory mode matters too, but for a different reason: ssh warns about a group-writable ~/.ssh because another account could swap config or authorized_keys under you, whereas the server refuses to honour authorized_keys at all in that state (StrictModes, bottom of this article). The key file bits are what produce the banner you searched for.
The permission matrix, measured
"It looks safe" is where most of the confusion comes from. I scripted the whole matrix — chmod, run ssh-keygen -y, record exit code and whether the banner appeared:
#!/usr/bin/env python3
"""For each mode: chmod the key, load it, record the verdict."""
import os
import subprocess
KEY = "./id_ed25519"
MODES = ["600", "400", "640", "604", "660", "644", "664", "666", "606", "200", "777"]
BANNER = "WARNING: UNPROTECTED PRIVATE KEY FILE"
print("%-6s %-6s %-8s %s" % ("mode", "exit", "banner", "signal"))
print("-" * 62)
for m in MODES:
os.chmod(KEY, int(m, 8))
p = subprocess.run(["ssh-keygen", "-y", "-f", KEY], capture_output=True, text=True)
out = (p.stdout or "") + (p.stderr or "")
lines = [l for l in out.splitlines() if l.strip()]
note = "loaded pubkey OK" if p.returncode == 0 else (lines[4] if len(lines) > 4 else lines[-1])
print("%-6s %-6d %-8s %s" % (m, p.returncode, "YES" if BANNER in out else "no", note[:44]))
os.chmod(KEY, 0o600)
Output on OpenSSH 10.0p2:
mode exit banner signal
--------------------------------------------------------------
600 0 no loaded pubkey OK
400 0 no loaded pubkey OK
640 255 YES It is required that your private key files a
604 255 YES It is required that your private key files a
660 255 YES It is required that your private key files a
644 255 YES It is required that your private key files a
664 255 YES It is required that your private key files a
666 255 YES It is required that your private key files a
606 255 YES It is required that your private key files a
200 255 no Load key "./id_ed25519": Permission denied
777 255 YES It is required that your private key files a
Three rows deserve attention.
640 fails. This is the one that catches people. A file that is rw-r----- is readable by exactly the owner and one group, which feels like a deliberate, minimal configuration — and it is still rejected, because the check is not about who can reach the file today, it is about whether any bit outside the owner triad is set. Same for 604, 606 and 660: 606 grants group nothing at all and is still refused, because the other bits are set. If your automation writes group-readable keys on purpose, it is writing keys OpenSSH will never use.
400 passes. Read-only for the owner is accepted and is the safest mode for a key that never changes.
200 is a different error. No banner at all — just Load key "…": Permission denied, exit 255. There is nothing for the permission checker to complain about because it cannot even read the file: open() fails with EACCES before the mode check runs. If your search started with "Load key ... Permission denied" rather than the banner, you are in this branch — the fix is to add owner read (chmod 600), not to remove group bits.
Why you often never see the warning
The most common reason the banner is "missing" is that the client never reached the point where it loads the key. OpenSSH parses the identity paths from your config before connecting, but it does not read, decrypt or permission-check the key material until public-key authentication begins — which happens after the TCP connection, the version exchange and key exchange.
Measured, with ssh -v -i ./id_ed25519 against a closed port and against a port that accepts but stays silent:
== closed port, key too open (port 22)
bad_perm_warning False
connect_refused True
last lines: debug1: Connecting to 127.0.0.1 [127.0.0.1] port 22.
debug1: connect to address 127.0.0.1 port 22: Connection refused
ssh: connect to host 127.0.0.1 port 22: Connection refused
== open-but-silent port, key too open (port 2222)
bad_perm_warning False
connect_refused False
last lines: debug1: identity file ./id_ed25519-cert type -1
debug1: Local version string SSH-2.0-OpenSSH_10.0p2 Debian-7+deb13u4
Connection timed out during banner exchange
In both cases the key was 0644 and in both cases the loader never ran. You can see the identity being registered (identity file ./id_ed25519-cert type -1) with no load and no complaint. Practical consequences:
- A connection-refused, DNS failure, or a firewall that drops packets produces no permission warning, no matter how bad the key mode is. Fixing your network will suddenly "introduce" an error you had all along.
ssh -vvvon a working connection is the only way to be sure the key was considered. Look forTrying private keylines; if the identity never appears there, it was discarded before use.- An
sshinvocation that is failing for an unrelated reason tells you nothing about key permissions. When a host is unreachable, verify the key locally withssh-keygen -y -finstead of reading the ssh transcript.
Where else the same check fires
The banner is not unique to ssh. Three different binaries perform the identical test, and a fourth does it on the server side.
ssh-agent. ssh-add refuses a too-open key with the same three-line banner, and offers nothing else. Measured on this host:
ssh-agent -a /tmp/lab/agent.sock > /tmp/lab/agent.out
chmod 644 ./id_ed25519
SSH_AUTH_SOCK=/tmp/lab/agent.sock ssh-add ./id_ed25519
chmod 600 ./id_ed25519
SSH_AUTH_SOCK=/tmp/lab/agent.sock ssh-add ./id_ed25519
SSH_AUTH_SOCK=/tmp/lab/agent.sock ssh-add -l
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: UNPROTECTED PRIVATE KEY FILE! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
Permissions 0644 for './id_ed25519' are too open.
It is required that your private key files are NOT accessible by others.
This private key will be ignored.
Identity added: ./id_ed25519 (lab@c2cz)
256 SHA256:JJnsoHeq7WkzYGO9cXEvJiN0MNcW0xV091T75GWcPSs lab@c2cz (ED25519)
This is the silent-ignoring pattern in its purest form: ssh-add prints the banner to stderr, adds nothing, and returns success in scripts that only check the exit status. If your automation does ssh-add ~/.ssh/id_* && …, a single bad mode means the agent holds fewer keys than you think and the failure appears later at the remote end.
ssh-keygen. Loading (-y, -l, -p) applies the same rule, which is why the reproduction above uses -y: it needs no server and no credentials.
sshd, on the far side. The server does not read your private key, but it applies a comparable strictness to the files it trusts — authorized_keys, ~/.ssh and the home directory. With the default StrictModes yes, a world- or group-writable home directory or authorized_keys causes authentication to be refused and this line to be written to the server log (quoted from the OpenSSH source, auth.c; this host has no sshd, so it was read rather than executed):
Authentication refused: bad ownership or modes for file /home/deploy/.ssh/authorized_keys
The symptom pairing is what matters operationally: client-side you get Permission denied (publickey) with no explanation, and the explanation exists only in the server's log. If the client offers a key that demonstrates the right fingerprint, permissions on the server side are the next thing to check.
The leak OpenSSH cannot see — and the one it can
Because the check reads mode bits only, a natural question is whether a POSIX ACL can grant read access to a second account while leaving the mode at 0600. This host has no setfacl, so I built the ACL xattr directly through Python's os.setxattr — a named-user entry with r--, mask set to r:
import os
import struct
import subprocess
KEY = "./id_ed25519"
ACL = "system.posix_acl_access"
entry = lambda tag, perm, ident=0xFFFFFFFF: struct.pack("<HHI", tag, perm, ident)
os.chmod(KEY, 0o600)
if ACL in os.listxattr(KEY): # idempotent: always start from a clean key
os.removexattr(KEY, ACL)
print("before: mode=%o xattr=%s"
% (os.stat(KEY).st_mode & 0o7777, ACL in os.listxattr(KEY)))
acl = (struct.pack("<I", 2) + entry(0x01, 6) + entry(0x02, 4, os.geteuid() + 1)
+ entry(0x04, 0) + entry(0x10, 4) + entry(0x20, 0))
os.setxattr(KEY, ACL, acl)
print("after : mode=%o xattr=%s len=%d"
% (os.stat(KEY).st_mode & 0o7777, ACL in os.listxattr(KEY),
len(os.getxattr(KEY, ACL))))
print("group/other bits set:", bool(os.stat(KEY).st_mode & 0o077))
p = subprocess.run(["ssh-keygen", "-y", "-f", KEY], capture_output=True, text=True)
print("ssh-keygen -y exit=%d warned=%s" % (p.returncode,
"WARNING: UNPROTECTED PRIVATE KEY FILE" in (p.stdout + p.stderr)))
before: mode=600 xattr=False
after : mode=640 xattr=True len=44
group/other bits set: True
ssh-keygen -y exit=255 warned=True
The ACL is not a blind spot, and the reason is structural rather than lucky: the kernel stores the ACL mask in the group permission bits. Grant any named user read access and st_mode becomes 0640 — so OpenSSH sees it and refuses. Keep the mode at 0600 and the mask is empty, which masks the named-user entry off, so the grant does not work either. There is no configuration where a readable key shows 0600 to stat().
What is invisible to this check is everything outside the filesystem: a copy of the key in a backup tarball, in a .tar.gz pushed to object storage, in shell history via a heredoc, in a Docker image layer, or in a git commit made before .gitignore existed. Mode bits answer "who can open this path", not "does this private key exist elsewhere". That distinction is worth stating in any review: a passing permission audit is evidence about one file, not about one secret.
A fleet audit that matches the real rule
Because the rule is a single mask, an audit is small — and it is worth writing one that mirrors 0o077 rather than "is it 600 exactly", so it does not produce false positives on legitimate 0400 keys. This script also catches the opposite failure (a key the owner cannot read, which yields the Load key … Permission denied variant) and refuses to follow symlinks, whose 0777 mode is meaningless:
#!/usr/bin/env python3
"""audit-ssh-key-perms.py - find private keys OpenSSH will silently ignore.
Exit codes: 0 = clean, 1 = findings, 2 = usage error. Default root: ~/.ssh
"""
from __future__ import annotations
import os
import stat
import sys
GROUP_OTHER = 0o077 # the mask OpenSSH applies
KEY_MARKER = b"PRIVATE KEY" # every OpenSSH private key contains this
KEY_NAME_HINTS = ("id_", ".pem", ".key", "_rsa", "_ed25519")
def looks_like_private_key(path: str) -> bool:
"""Content sniff, with an EACCES fallback for keys nobody can read."""
try:
with open(path, "rb") as fh:
return KEY_MARKER in fh.read(8192)
except OSError:
return any(h in os.path.basename(path) for h in KEY_NAME_HINTS)
def findings_for(path: str, euid: int = os.geteuid()) -> list[str]:
try:
st = os.lstat(path) # lstat: never follow a symlink into a check
except OSError as exc:
return [f"unreadable: {exc.strerror}"]
if not stat.S_ISREG(st.st_mode):
return []
problems = []
mode = stat.S_IMODE(st.st_mode)
if mode & GROUP_OTHER:
problems.append(f"mode {mode:04o} has group/other bits set "
f"({oct(mode & GROUP_OTHER)}); OpenSSH requires {mode & ~GROUP_OTHER:04o}")
if euid != 0 and st.st_uid != euid:
problems.append(f"owned by uid {st.st_uid}, but you are uid {euid}")
if not problems and not os.access(path, os.R_OK):
problems.append("owner may not read the key (mode 0200/0000): EACCES on load")
return problems
def main(argv: list[str]) -> int:
roots = argv[1:] or [os.path.expanduser("~/.ssh")]
for root in roots:
if not os.path.isdir(root): # a mistyped root must not report "clean"
print(f"usage: {os.path.basename(argv[0])} [dir ...]", file=sys.stderr)
print(f"not a directory: {root}", file=sys.stderr)
return 2
scanned = flagged = 0
for root in roots:
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
dirnames[:] = [d for d in dirnames
if not stat.S_ISLNK(os.lstat(os.path.join(dirpath, d)).st_mode)]
for name in sorted(filenames):
if name.endswith(".pub") or name in {"known_hosts", "config", "authorized_keys"}:
continue
full = os.path.join(dirpath, name)
if stat.S_ISLNK(os.lstat(full).st_mode):
continue
if not looks_like_private_key(full):
continue
scanned += 1
problems = findings_for(full)
if problems:
flagged += 1
mode = stat.S_IMODE(os.lstat(full).st_mode)
print(f"FAIL {full}")
for p in problems:
print(f" {p}")
print(f" fix: chmod {mode & 0o700 | 0o400:04o} {full}")
else:
print(f"ok {full} (mode {stat.S_IMODE(os.lstat(full).st_mode):04o})")
print(f"\n{scanned} private key(s) scanned, {flagged} would be ignored by OpenSSH")
return 1 if flagged else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
Run against a deliberately mixed tree — one clean key, 0644, 0640, 0200, a symlink and a config file:
mkdir -p fixture/{good,bad,link}
ssh-keygen -q -t ed25519 -N '' -f fixture/good/id_ed25519 -C 'good@c2cz'
ssh-keygen -q -t ed25519 -N '' -f fixture/bad/id_open -C 'open@c2cz'
cp fixture/bad/id_open fixture/bad/id_groupread
cp fixture/bad/id_open fixture/bad/id_noread
chmod 600 fixture/good/id_ed25519
chmod 644 fixture/bad/id_open
chmod 640 fixture/bad/id_groupread
chmod 200 fixture/bad/id_noread
ln -sf ../bad/id_open fixture/link/id_link
python3 audit-ssh-key-perms.py fixture; echo "exit=$?"
ok fixture/good/id_ed25519 (mode 0600)
FAIL fixture/bad/id_groupread
mode 0640 has group/other bits set (0o40); OpenSSH requires 0600
fix: chmod 0600 fixture/bad/id_groupread
FAIL fixture/bad/id_noread
owner may not read the key (mode 0200/0000): EACCES on load
fix: chmod 0600 fixture/bad/id_noread
FAIL fixture/bad/id_open
mode 0644 has group/other bits set (0o44); OpenSSH requires 0600
fix: chmod 0600 fixture/bad/id_open
4 private key(s) scanned, 3 would be ignored by OpenSSH
exit=1
The symlink is skipped, config and .pub files are ignored, and the exit status is 1 when anything is flagged — which is the shape you want in CI or a scheduled check. Run it over ~/.ssh, /root/.ssh and any deploy-user home, then feed the fix: lines straight back into chmod. Two months of "the deploy key just stopped working" usually resolves to a provisioning script that re-created the file with the wrong umask.
Where bad modes actually come from
The mode is almost never typed by hand. It arrives from one of a handful of mechanisms, and knowing which one you are looking at tells you where the durable fix belongs.
- Umask at creation.
ssh-keygencreates keys0600, but a key materialised by a copy or a heredoc inherits the umask. A service account started withumask 0022that writes its own key produces0644every time — the fix isumask 0077in the unit or profile, not a chmod after the fact. - Copies that lose the mode.
cpwithout-p,scpinto a fresh path, a file extracted from a tarball, a secret written by a cloud-initwrite_filesblock without an explicit mode, a Kubernetes secret volume whose default is0644. Any of these produces a correct key with a wrong mode. - Group-based designs. Shared deploy accounts that depend on a
640key and a shared group cannot work with OpenSSH's client-side check. The supported pattern is a per-user key plus an agent (orProxyJump), not a group-readable private key. - Restores. A backup restored with
taras a different uid leaves the file owned by the wrong account, which trips the ownership half of the check even when the mode is0600. - Editors and agents. Some GUI editors and sync tools rewrite a file in place with default permissions. If a key periodically breaks after an unrelated edit session, that is the pattern to suspect.
Provisioning tools have the same trap: an Ansible copy task that omits mode: relies on the remote umask, so the key is correct on one host and unusable on the next. Setting mode: '0600' explicitly is the difference between a working fleet and a support ticket.
Hardening checklist
- Keys are
0600or0400; the rule ismode & 0o077 == 0, nothing looser. ~/.sshis0700and owned by the key's user; on the server,StrictModesis left at its default and the home directory is not group-writable.- Provisioning sets the mode explicitly (Ansible
mode:, cloud-initpermissions:, DockerfileCOPY --chmod=600) instead of trusting the umask. ssh-addis checked for its effect, not its exit code: assert thatssh-add -lshows the expected fingerprints after loading.- A permission audit runs on a schedule against every account that can authenticate; the script above exits non-zero so a cron job or CI step fails visibly.
- For anything that matters, the private key never leaves the host: move to an agent plus hardware-backed keys and the file-permission problem stops existing.
Related reading on this blog: if the client aborts before authentication with ssh_exchange_identification: Connection closed by remote host, that is a connection-layer failure rather than a key one; and if the client refuses to talk to a host you already trust, WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED walks the host-key branch. For the certificate equivalent of this permission problem on the TLS side, see self-signed certificate in certificate chain.
Last verified: 2026-09-21 on OpenSSH_10.0p2 Debian-7+deb13u4 / OpenSSL 3.5.6 / Linux 6.17, uid 10000. The permission matrix, the lazy-load measurement, the ACL experiment, the ssh-agent transcript and the audit output were all executed on this host; the sshd StrictModes log line is quoted from the OpenSSH source because no sshd binary is installed here.