刚接触 Linux 服务器的人,几乎都会被同一个报错按在地上摩擦:ssh: permission denied (publickey)。我当年第一次看到这行字,是在深夜部署一台 CentOS 服务器的时候,手里只有一长串密钥参数,试了三四次都是同样的结果,差点以为服务器被我锁死了。后来系统弄明白了 SSH 的认证流程和文件权限规则,才意识到这个报错其实是一套非常明确的“选择题”:要么你的密钥没放对,要么权限不对,要么服务端根本没给你认证的机会。
这篇文章就把我踩过的坑和排查心得整理成一套可以直接抄作业的流程。不管你是在 VSCode 里连接远程开发环境,还是在 GitLab 上配置密钥,或者是几十台机器批量做免密登录,核心思路都一样:先搞清楚 SSH 认证的顺序,再按顺序查日志、查密钥、查权限。搞懂了这一点,“permission denied (publickey)” 基本就能一次解决。
1. 这个报错到底在说什么
1.1 一条报错背后的完整链路
很多人看到ssh: permission denied (publickey)就以为是“密码不对”,其实完全不是。这个错误代表连接已经建立到了 SSH 服务端,但服务端在密钥认证这一环节把你拒了。关键点在于:你的 TCP 连接是通的,SSH 服务是活的,问题出在“认证身份”这个阶段。
SSH 连接建立之后,服务端会按顺序尝试几种认证方式:先是 none(空认证),然后是 publickey(密钥认证),如果服务器允许,还可以用 keyboard-interactive 或 password(密码认证)。当客户端只提供了密钥认证,而密钥验证又没通过时,服务端最终返回的就是permission denied (publickey)。注意,这里的报错末尾括号里写的是publickey,而不是password,说明服务器根本没打算让你用密码登录,或者你的密码认证尝试压根没被触发。
有一次我在内网环境排障,对方问我“为什么我 root 用户连不上”,我让他先看一眼服务端日志再说话。结果日志里写得很清楚:
Connection from 192.168.1.10 port 54321 Failed publickey for root from 192.168.1.10 port 54321这就说明网络没问题,密钥认证确实失败了。如果日志里压根没有这一行,反而说明请求可能被防火墙挡了,或者 SSH 服务根本没监听那个端口。所以别急着改配置,先去定位是“没到 SSH 这一层”还是“到了但认证失败”,方向不一样,排查手段完全不一样。
1.2 认清 SSH 的认证顺序
SSH 服务端的配置文件/etc/ssh/sshd_config里有两个参数决定了你能不能绕过密钥问题:PubkeyAuthentication和PasswordAuthentication。如果服务端把PubkeyAuthentication yes注释掉或改成 no,那无论你密钥多正确都会吃闭门羹。同理,如果PasswordAuthentication no,那么你试密码也会直接被忽略,报错里只会出现publickey。
我见过一个特别误导人的场景:服务器其实开了密码认证,但客户端这边用了ssh -i somekey user@host,并且命令行里没有给密码的机会,客户端直接按密钥流程走,失败之后没有回落到密码输入,于是也报了permission denied (publickey)。很多人不知道,OpenSSH 客户端如果指定了密钥文件且认证失败,默认不一定会自动提示你输密码,要看配置里PreferredAuthentications的顺序。所以排查的第一步,永远是确认“你希望用哪种方式登录”“服务端允许哪种方式”“客户端实际尝试了哪种方式”这三件事是不是对齐的。
判断服务端到底开放了哪些认证方式,有两条路。一条是看/etc/ssh/sshd_config,但很多发行版会通过Include /etc/ssh/sshd_config.d/*.conf引入额外配置,容易被忽略。另一条更直接,用ssh -v user@host看详细输出,里面有一行类似:
debug1: Authentications that can continue: publickey,password这一行就是服务端当前允许的认证方式列表。如果里面只有 publickey,说明密码被关了;如果只有 password,说明密钥被关了。先把这个信息拿到手,后面所有排查才有意义。
2. 排查顺序:先看日志,再查权限
2.1 服务端日志怎么看
遇到任何 SSH 认证问题,我的第一反应永远是打开服务端日志,而不是反复重试。对 systemd 系发行版,用journalctl -u sshd -f或者journalctl -u ssh -f实时跟踪;对传统 sysvinit 系发行版,看/var/log/auth.log(Debian/Ubuntu)或者/var/log/secure(CentOS/RHEL)。日志里出现Failed publickey for后面跟着用户名和 IP,这就拿到了最直接的证据。
有一次我在帮朋友看家里一台旧 Ubuntu 机器,他坚持说“密钥已经放进 authorized_keys 了”,但日志里反复出现bad permissions之类的提示。我让他检查 authorized_keys 和 .ssh 目录的权限,结果发现整个/home/user/.ssh是 777,authorized_keys 是 664。按 OpenSSH 的要求,.ssh 目录权限建议是 700,密钥私钥是 600,公钥和 authorized_keys 是 644。权限太宽松,SSH 为了保证安全会直接忽略这个 key 文件,宁可认证失败也不冒险用。这就是“看似没问题,实际完全不通”的典型。
日志还有一个容易被忽略的细节:No more authentication methods to try。这行说明服务端把所有允许的认证方式都试完了,全部失败,连接即将关闭。如果你在日志里同时看到Failed publickey和这行,基本可以百分之百确定问题出在密钥本身或权限上,而不是网络。反过来,如果日志里连 failed 记录都没有,多半是请求没到 SSH 进程,这时候就该查防火墙、端口监听和 SELinux 了。
2.2 客户端排查三板斧
服务端日志不是随时都能看到的,尤其你只有客户端权限的时候,得靠客户端自己一步一步排。我日常用的是三板斧:-v看全过程,-vvv看变态级细节,-o指定临时参数避免改全局配置。
第一板斧是确认用的哪个密钥:
ssh -v user@host注意看输出里Offering public key: /home/you/.ssh/id_rsa这一行。如果你的私钥文件路径不对,这里弹出来的根本不是服务器认识的那把钥匙,那后面必然失败。很多人把 GitHub 的密钥和公司的密钥都放一起,ssh-agent 顺序乱了,导致老是拿错钥匙去开门,报错自然就是 permission denied。
第二板斧是指定密钥并测试:
ssh -i ~/.ssh/id_ed25519 -v user@host如果你确信密钥路径没问题,那就直接指定它,看服务端返回什么。正常流程里你会看到Server accepts key,然后进入下一步握手;如果看到Authentications that can continue: publickey或者Permission denied,说明这把钥匙没过服务器的验签。
第三板斧是强制密码认证测试,用来判断是不是密钥环节的问题:
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no user@host如果这样能登上服务器,说明网络、账号、SSH 服务都是好的,问题百分百出在密钥认证链路。这个测试特别有用,因为它能把一大片可疑区域直接切掉。
2.3 权限问题详解
SSH 对权限的要求非常苛刻,目录权限多一个组写权限都会导致密钥被忽略。标准要求如下:
| 路径 | 推荐权限 | 说明 |
|---|---|---|
~/.ssh目录 | 700 | 用户私有目录,禁止组和其他用户访问 |
~/.ssh/authorized_keys | 600 或 644 | 存放公钥列表,至少不能被组写 |
~/.ssh/id_rsa私钥 | 600 | 私钥必须仅自己可读写 |
~/.ssh/id_rsa.pub公钥 | 644 | 公钥本身可以公开 |
/etc/ssh/ssh_host_*主机密钥 | 600 | 服务器主机密钥,一般不用动 |
Windows 上更容易踩这个坑。VSCode 连接远程服务器时,如果用的是 OpenSSH for Windows,C:\Users\你的用户名\.ssh\config文件的权限不对,会直接报bad owner or permissions on C:\Users\thinkpad/.ssh/config。这个报错是 Windows 版 OpenSSH 特有的,它不喜欢权限太宽松的配置文件。解决办法是在 PowerShell 里用 icacls 把 config 文件的所有者设为当前用户,并移除继承权限:
icacls.exe "C:\Users\你的用户名\.ssh\config" /inheritance:r /grant:r "%USERNAME%:F"注意路径里的.ssh和config是分开校验的,家庭版 Windows 可能还要先确认你运行的是不是管理员终端。这个问题我帮同事处理过三次,每次都有人觉得“我在 Windows 上怎么可能有权限问题”,结果都是默认权限带了 Users 组的读写,一家伙就报了这个错。遇到这种提示别慌,先把 config 文件权限收紧,再重新连接。
3. 真正理解 SSH 密钥认证
3.1 公钥放哪里,私钥放哪里
密钥认证的原理可以用一个生活场景类比:你家的门锁相当于公钥,装在服务器上;开门的钥匙相当于私钥,留在你的电脑上。锁可以随便发给别人,因为只有钥匙能开这把锁;但如果钥匙被别人拿走了,就等于别人能进你家门。SSH 里的公钥存放在服务器上,路径是~/.ssh/authorized_keys;私钥存放在你本机,路径通常是~/.ssh/id_ed25519或者~/.ssh/id_rsa。
很多人刚开始搞反了,把公钥当私钥往自己机器里塞,或者把私钥往服务器上拷。这么做不仅登录失败,还容易把私钥泄露到服务器上。私钥一旦离开了你自己的电脑,这密钥就等于废了。正确做法是:通通本地生成密钥对,公钥追加到服务器 authorized_keys,私钥留在本地绝不外传。
还有一点:authorized_keys 文件支持一个公钥一行,所以你可以在一台服务器上放多个公钥,对应多台电脑。GitLab 和 GitHub 的做法也类似,它们是把你的公钥存到平台的账号体系里,用来验证 git 操作。这也是为什么就算你密码记不住,只要密钥对得上,git push 就能过。
3.2 用 ssh-keygen 生成正确的密钥
生成密钥的标准命令:
ssh-keygen -t ed25519 -C "your_email@example.com" -f ~/.ssh/id_ed25519为什么推荐 ed25519 而不是 RSA?因为 ed25519 密钥更短、性能更高、安全性也足够。老一点的服务器如果内核或 OpenSSH 版本太旧,不支持 ed25519,那你就用 RSA,但位数别低于 2048,建议 4096:
ssh-keygen -t rsa -b 4096 -C "your_email@example.com" -f ~/.ssh/id_rsa执行后系统会问你要不要设置 passphrase(口令)。我的建议是:个人开发机一定要设 passphrase,且把私钥加入 ssh-agent 管理,这样每次只需要输一次口令。不设 passphrase 虽然方便,但私钥文件一旦被拷走,对方可以直接冒充你登录所有服务器。如果你做自动化批处理或 CI/CD,需要免交互,那 passphrase 只能是空,但这种情况一定要把私钥权限锁死,最好存到专用的密钥管理系统里。
生成完的产物是两个文件:一个不带.pub后缀的私钥,一个带.pub后缀的公钥。可以用cat ~/.ssh/id_ed25519.pub查看公钥内容,这一整段就是你要放到服务器上的字符串。千万别把私钥内容发给任何人,哪怕对方是所谓的“服务器管理员”。
3.3 免密登录的完整配置
配置免密登录的标准做法是把本地公钥追加到服务器 authorized_keys。最简单的命令:
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@host这命令会自动把公钥追加到目标服务器的~/.ssh/authorized_keys,并且保持权限正确。如果你不能用 ssh-copy-id(比如服务器禁密码登录,且你还没有任何密钥能进去),那就只能手动操作:本地把公钥内容复制出来,通过服务器控制台或其他已有通道写进~/.ssh/authorized_keys文件。手动操作时注意别把公钥换行写坏了,建议先备份原有 authorized_keys:
cp ~/.ssh/authorized_keys ~/.ssh/authorized_keys.bak echo "你的公钥内容" >> ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys chmod 700 ~/.ssh配置完成后,务必新开一个终端测试,别把当前连接断开。万一新配置有问题,你还有一条旧连接可以做回滚。这个习惯我每次都会强调,因为已经见过太多人改完配置顺手关了当前窗口,然后发现自己被锁在服务器外,只能跑去云控制台用 VNC 或救援模式恢复。别问,问就是心疼工时。
4. 实操案例:把密钥问题一次解决
4.1 场景一:GitHub 或 GitLab 认证失败
用 git 时遇到permission denied (publickey)的经典场景是:先配了 HTTPS 方式,后来改了远程仓库 URL 成 SSH 方式,但本地没有任何能被远端验证的密钥。GitHub 的提示通常会带一句Could not read from remote repository,然后告诉你Permission denied (publickey)。这时候第一件事是检查本地密钥是不是已经加到了 GitHub 账号:
- 打开 GitHub Settings -> SSH and GPG keys,看看有没有对应的公钥;
- 本地执行
ssh -T git@github.com测试连通性,GitHub 会回复一条带用户名的欢迎语。
如果返回的是Hi yourname! You've successfully authenticated,说明密钥没问题,问题在 git 的 remote URL 上。用git remote -v查看,如果显示的是 HTTPS 链接,就改回 SSH:
git remote set-url origin git@github.com:username/repo.gitGitLab 的排查同理:在个人设置里加 SSH 密钥,然后ssh -T git@gitlab.com测试。有一回我在 GitLab 上认证失败,日志显示服务端收到了 pubkey 但设备类型 Hash 不对,后来发现是我把两台机器的公钥弄混了。所以在 GitLab/GitHub 上添加公钥前,先cat .pub确认是本机生成的,别顺手把别人的或服务器的公钥粘了上去。
还有个小细节:如果你的私钥设置了 passphrase,而且没有加载进 ssh-agent,那么每次 git 操作都会弹出密码框。VSCode 环境下如果卡在输入 passphrase,可能是终端没有继承 GUI 的 ssh-agent 环境变量。可以手动启动:
eval $(ssh-agent -s) ssh-add ~/.ssh/id_ed25519然后再试 git push。这个解法能解决一大堆“VSCode 里 git 连不上 SSH 服务器”的诡异问题。
4.2 场景二:VSCode 远程开发连接失败
VSCode 的 Remote-SSH 扩展是日常开发利器,但它的坑也不少。常见的情况是:终端里明明能 SSH 连上,VSCode 里却报permission denied (publickey)。原因是 VSCode 的 Remote-SSH 会尝试用自己的 SSH 配置路径,加上 Windows 上 OpenSSH 客户端的影响,密钥和 config 文件容易被忽略。
第一步,打开 VSCode 的 Remote-SSH 配置,确认 Host 指向没问题。配置一般在~/.ssh/config,Windows 上是C:\Users\你的用户名\.ssh\config。一个典型配置:
Host myserver HostName 192.168.1.100 User root Port 22 IdentityFile ~/.ssh/id_ed25519 StrictHostKeyChecking no注意IdentityFile建议写绝对路径或~展开路径。之前有人配置里写IdentityFile id_ed25519,导致 VSCode 在当前工作目录找密钥,找不到就一直报 permission denied。改成~/.ssh/id_ed25519马上就好了。
第二步,检查 VSCode 是否加载了 ssh-agent。打开命令面板(Ctrl+Shift+P),执行Remote-SSH: Open SSH Configuration File就能看配置文件。如果一切正常还是失败,干脆在 VSCode 自带的终端里手动执行一次ssh -v user@host,把输出里的Offering public key路径记下来,看跟 config 里声明的是不是同一个。很多人在这时候会发现,VSCode Windows 原生 SSH 用的密钥跟 WSL 里的密钥压根不是同一套,连接失败完全正常。
第三步,注意 config 文件的换行符。Windows 下用记事本编辑过的 config 文件可能是 CRLF 换行,旧版 OpenSSH 对 CRLF 容忍度还行,新版在某些场景会报Bad configuration option或者读不到。我一般都用 VSCode 打开 config,右下角把行尾改成 LF,再保存。这个小细节救过我好几次。
4.3 场景三:多台服务器批量管理
如果你手上有几十台服务器,一台台输入密码肯定受不了,常见的方案是配置 SSH 免密登录 + 批量脚本。多台服务器的密钥管理有一个原则:一台客户端对应一把私钥,公钥分发到所有服务器。私钥只在客户端存着一份,其他任何服务器上都不放私钥。
批量分发公钥可以写个循环:
for host in 192.168.1.101 192.168.1.102 192.168.1.103; do ssh-copy-id -i ~/.ssh/id_ed25519.pub user@$host done更专业的做法是用 Ansible 的authorized_key模块管理 authorized_keys,这样一致性更高,不会出现某台服务器漏拷贝。Ansible 里写:
- name: Deploy SSH key authorized_key: user: root state: present key: "{{ lookup('file', 'pubkey.pub') }}"批量登录之后,很多人会遇到“连上了但找不到要执行的脚本”的问题。我的经验是:先在本地把脚本 scp 到所有机器,再在远端统一执行。如果有自动化传输需求,scp走的就是 SSH 通道,密钥认证配好了,传输自然就免密了。Windows 和 Ubuntu 之间传文件也一样,Windows 自带 OpenSSH 后可以直接 scp,不用再装第三方工具。
如果每台服务器还需要不同的账号登录,SSH config 的 Host 别名可以大大简化:
Host web01 HostName 10.0.0.1 User deploy IdentityFile ~/.ssh/id_ed25519_web Host web02 HostName 10.0.0.2 User deploy IdentityFile ~/.ssh/id_ed25519_web这样一来,ssh web01和ssh web02就是两条非常简洁的命令,密钥也不用反复指定。团队规模大了以后,还可以结合跳板机(ProxyJump)统一入口,但因此引入的 “connection timed out” 和 “handshake failed: eof” 问题就属于另一类排查了。总之,把这些配置整理好,批量管理就会顺手很多。
5. 常见问题速查表与避坑经验
5.1 高频故障速查表
我把这些年遇到的高频问题整理成一张表,适合直接贴在笔记里。
| 症状 | 最常见原因 | 解决方案 |
|---|---|---|
Permission denied (publickey),日志无记录 | 请求没到 SSH 层,被防火墙拦 | 检查 22 端口、安全组、iptables |
Failed publickey,日志有记录 | 密钥不匹配或 authorized_keys 没配置 | 重新追加公钥,确认 authorized_keys 内容 |
bad owner or permissions on .../.ssh/config | Windows 下 config 权限带了继承组 | 用 icacls 收紧权限,移除继承 |
Offering public key路径不是预期私钥 | 客户端用了默认密钥而非指定密钥 | 配置 IdentityFile 或使用-i |
| 密钥权限 664 导致被忽略 | authorized_keys 权限过宽 | chmod 600 authorized_keys,chmod 700 .ssh |
Permission denied (publickey,password) | 密码登录被禁,或客户端未触发密码认证 | 确认 PasswordAuthentication yes,或用-o PreferredAuthentications=password |
| 重启后连接失败,私钥要重新输入口令 | ssh-agent 未自动加载 | eval $(ssh-agent -s)并 ssh-add |
Handshake failed: eof | 服务端断开,可能是协议不匹配或连接被中途拦截 | 看服务端日志,确认 SSH 版本,考虑关闭 GSSAPI 或指定 Ciphers |
Git push 报Permission denied (publickey) | 本地私钥未添加至 Git 平台 | ssh -T git@github.com测试,重新添加公钥 |
这里单独说一下Permission denied (publickey,password)这个变体。报错里同时出现 publickey 和 password,说明服务端允许两种方式,但两种都失败了。很多人只盯着 publickey,忽略了 password 部分,结果漏掉了“密码试错锁账号”的情况。被 fail2ban 短时封禁时,密码和密钥都会被拒,但日志显示方式不同。所以看到这种报错,先问自己:我到底试过密码没有?如果密码也被拒绝,优先怀疑账号被锁或 IP 被封,而不是继续纠结密钥。
5.2 几个容易被忽视的细节
第一,StrictHostKeyChecking和 known_hosts 是另一层“坑”。第一次连接时系统会在 known_hosts 里记录主机公钥指纹,如果服务器重装系统导致主机密钥变化,OpenSSH 会拒绝连接,并提示REMOTE HOST IDENTIFICATION HAS CHANGED。这个时候 sshd 日志往往不显示认证失败,因为连接在认证之前就断掉了。处理方法是把旧的主机密钥从 known_hosts 里删掉:
ssh-keygen -R 192.168.1.100然后再重新连接。这个报错虽然不叫 permission denied,但很多人会误以为也是密钥问题,所以也值得记下来。
第二,SSH 日志分析是排查一切 SSH 问题的地基。我建议把sshd -T这条命令记牢,它可以直接打印当前实际生效的 sshd 配置:
sshd -T | grep -E 'pubkey|password|permit'这比看 sshd_config 更可靠,因为它能把 Include 进来的所有配置文件合并后的结果展示出来。比如你明明在/etc/ssh/sshd_config里看到PasswordAuthentication yes,但实际生效的是 no,那多半是sshd_config.d下有个文件把它覆盖了。Debian 系尤其常见。
第三,如果服务器特别老,比如 CentOS 6 或旧版 Ubuntu,可能不支持ssh-ed25519公钥算法,此时你会看到no matching key exchange method found或no mutual signature algorithm之类的新报错。我碰到过一次 CentOS 6.10 升级 SSH 的场景,旧版本 OpenSSH 5.3 不支持新客户端的 HostKeyAlgorithms,需要在客户端显式指定老算法才能连上。但顺着 permission denied 排查时,这类报错也很容易被误判。解决方案是对老服务器用 RSA 4096 密钥,并在客户端加上-o PubkeyAcceptedAlgorithms=+ssh-rsa(OpenSSH 8.8 以后默认禁用了 ssh-rsa 签名)。
5.3 建立自己的 SSH 排障习惯
总结一套固定排障流程能省很多时间。我现在每次遇到 SSH 认证失败,基本按这五步走:
- 先看客户端
ssh -v输出,确认连接走到了认证阶段还是被提前掐断; - 如果能看服务端日志,立刻翻
/var/log/auth.log或journalctl -u sshd; - 检查 authorized_keys 文件是否存在、内容是否正确、权限是否为 600;
- 检查 .ssh 目录权限是否为 700,父目录属主是否为当前用户;
- 检查 sshd_config 里 PubkeyAuthentication 和 PasswordAuthentication 的实际生效值。
第五步很多人会跳过,但恰恰是它决定了大方向。如果 pubkey 被禁了,你花再多时间调密钥都没用。而如果 pubkey 启用了但认证就是不过,那再回到密钥内容、权限、主机密钥和 known_hosts 上继续细化。
我在实际运维中发现,超过一半的permission denied (publickey)问题最终都是权限惹的祸,而不是密钥内容错误。很多人习惯cp或者用 WinSCP 拖拽文件,导致文件所有者和权限混乱。SSH 对权限的敏感程度超过了大多数人的预期,因为它本身就是一个安全敏感的通道,宁可拒绝也不冒险。理解了这一层,你就明白为什么报错这么“无情”——它其实是在保护你。
最后再分享一个小技巧:当你多台机器切换不过来时,把ssh -vT git@github.com、ssh -v user@host、ssh-keygen -R host这三个命令记在手机备忘录里。绝大多数 SSH 认证问题,都是在这三条命令的帮助下找到突破口的。排查多了你会发现,SSH 报错的逻辑其实特别清晰,只是第一次遇到时容易乱了阵脚。希望大家看到这篇文章后,下次再撞上 permission denied,能从容不迫地打开日志,一步步把这个老朋友送走。