1. 为什么需要多密钥:先把自己的困境说清楚
先说我自己的情况。我同时维护着两个 GitHub 账号(一个工作号、一个个人号),一个 GitLab 账号,还有公司内网一台自建的 GitLab。以前我图省事,把所有仓库的 SSH 公钥全部配到同一个密钥上,结果某天公司安全策略要求强制轮换密钥,内网 GitLab 也换了密钥算法,我的 ~/.ssh 目录里开始堆出 id_rsa、id_rsa_work、id_rsa_personal、id_ed25519_gitlab 一堆文件,然后噩梦就来了——git push 的时候,SSH 总是先尝试第一个密钥,权限被拒了才轮到下一个,经常被 GitHub 直接回一句 Permission denied (publickey),或者提示 git@github.com: Permission denied (publickey).。
这种问题在圈子里太常见了。你随手一搜,满地都是“GitHub 多账号配置”的帖子,但大多数帖子只解决了某一半问题:要么只告诉你不同域名用不同密钥,要么只告诉你用 git config --global core.sshCommand 改密钥,碰到“同一台机器上多个 GitHub 账号”“公司仓库和个人仓库混在同一个目录树里”这种稍微复杂点的场景,就没人能讲明白了。
这篇实战笔记想做的事很简单:把 SSH 客户端真正的工作机制讲透,然后给出三套可落地的配置方案——不同域名、不同仓库、不同目录,分别对应什么思路,配置文件怎么写,踩坑点在哪。适合手上已经有多把 SSH 密钥、被 Permission denied 折磨过、或者马上要配多账号 Git 环境的同学。读完你能直接照着抄,并且知道为什么这么写。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SSH 客户端的选择逻辑:你以为的匹配和实际机制差距很大
很多人配置多密钥失败的根源,不是配置文件写错,而是根本不知道 SSH 客户端是怎么决定“用哪个密钥”的。这里必须先把底层逻辑掰开揉碎讲清楚。
2.1 Host 匹配是第一道关口
当你执行 git clone git@github.com:user/repo.git 时,OpenSSH 客户端会读取 ~/.ssh/config,把命令里的 github.com 拿去做匹配。匹配的关键不是看 URL 里的完整域名,而是看 Host 字段——这个字段可以写完整域名,也可以写一个你自己起的别名。
比如下面的配置:
text复制Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_github
当 SSH 解析 git@github.com 时,它会逐行扫描 config 文件,找到第一条 Host 能匹配 github.com 的条目,然后把这个条目下的所有参数加载进来。这里有个很多人不知道的细节:SSH 的配置匹配是“第一条命中的生效”,不是“后面命中的覆盖前面”。也就是说,config 文件里写在前面的匹配规则优先级最高,一旦匹配上,后面的同类型规则就不再生效了。
这也解释了为什么很多人会把密钥配错:如果你在 config 文件前面写了一个 Host * 的通用规则,并且指定了 IdentityFile ~/.ssh/id_rsa,那后面针对 github.com 写的独立规则反而可能被通用规则抢先——因为 Host * 也能匹配 github.com。所以多密钥配置的第一条军规是:通用规则放最后,具体规则放前面。
2.2 IdentityFile 与 ssh-agent 的优先级之争
第二个关键机制是密钥的候选顺序。当一个 Host 条目里写了多个 IdentityFile,或者你同时启用了 ssh-agent(绝大多数桌面系统默认会开启),SSH 客户端尝试密钥的顺序大致是:
- 先看 config 条目里显式声明的
IdentityFile; - 再看 ssh-agent 里已经加载的密钥列表;
- 如果都没成功,再看默认位置的默认文件名(
~/.ssh/id_rsa、~/.ssh/id_ed25519等)。
注意,这个“尝试”是逐个来的:第一个密钥认证失败,服务端返回 Permission denied,客户端再拿下一个密钥试。问题在于,GitHub 这类服务端对失败尝试有次数限制,如果一次性试了五六个密钥,很可能在正确的密钥出现之前,服务端已经把你暂时拉黑了,你会看到 git@github.com: Permission denied (publickey) 的同时,浏览器里 GitHub 的安全日志还会多一条失败记录。
所以这里必须提到一个极易被忽略的参数:IdentitiesOnly。
text复制Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_github
IdentitiesOnly yes
IdentitiesOnly yes 的意思是:我只用 config 里显式指定的 IdentityFile,不要去 ssh-agent 里翻其他密钥,也不要去默认位置乱找。这个参数是解决多密钥冲突的关键开关。没有它,即使你 config 写对了,ssh-agent 里如果加载了别的密钥,客户端依然会拿出去试一通,既浪费时间又可能触发服务端的失败计数。
2.3 关键参数速查:配置前先弄清楚每个参数干什么
下面这几个参数在做多密钥管理时一定会用到,建议先有个整体概念:
| 参数 | 作用 | 我的建议 |
|---|---|---|
Host |
匹配别名,可以是域名也可以是自定义名称 | 具体条目用实际域名或别名,通配符规则放最后 |
HostName |
实际连接的服务器域名/IP | 只有 Host 是别名时才必须写 |
User |
登录用户名 | Git 场景固定为 git |
IdentityFile |
指定私钥路径 | 显式写到具体密钥文件 |
IdentitiesOnly |
只用显式指定的密钥 | 多密钥场景建议全部开启 |
AddKeysToAgent |
认证成功后把密钥加入 ssh-agent | 配合 ssh-add 使用,省去重复输入密码 |
UseKeychain |
macOS 专用,把密钥密码存入钥匙串 | macOS 用户建议开启 |
这三板斧理解到位了,后面所有配置方案都是在它们的基础上做文章。
3. 配置方案一:不同域名映射不同密钥,最稳妥的基础配置
这个场景最典型:GitHub 一个密钥,GitLab 一个密钥,公司自建 Git 服务器一个密钥,互不干扰。做法就是给每个域名写一个独立的 Host 条目。
3.1 完整配置模板
先生成密钥,这一步建议用 ed25519 算法,性能好、密钥短、兼容性也足够:
bash复制ssh-keygen -t ed25519 -C "work@example.com" -f ~/.ssh/id_ed25519_github_work
ssh-keygen -t ed25519 -C "personal@example.com" -f ~/.ssh/id_ed25519_github_personal
ssh-keygen -t ed25519 -C "me@company.com" -f ~/.ssh/id_ed25519_gitlab_company
-C 是注释,建议写成邮箱或用途,方便以后辨认;-f 指定密钥文件名,强烈建议不要用默认文件名,因为默认文件名是 SSH 兜底查找的对象,你一旦用了多个非默认文件名,就可以靠 config 完全接管密钥选择逻辑。
然后编辑 ~/.ssh/config:
text复制Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_github_work
IdentitiesOnly yes
AddKeysToAgent yes
Host gitlab.com
HostName gitlab.com
User git
IdentityFile ~/.ssh/id_ed25519_gitlab_company
IdentitiesOnly yes
AddKeysToAgent yes
Host git.company.com
HostName git.company.com
User git
IdentityFile ~/.ssh/id_ed25519_company_internal
IdentitiesOnly yes
AddKeysToAgent yes
Host *
AddKeysToAgent yes
UseKeychain yes
这里有几个细节值得解释。
一是为什么 Host 直接写真实域名。因为 Git 命令里的 remote URL 写的是 git@github.com:user/repo.git,SSH 需要用 github.com 去匹配,所以真实域名的条目能直接被命中。如果你起了个别名,比如 Host github-work,那你还得把 remote URL 改掉,比较麻烦,后面讲同一域名的场景时我会展开这个技巧。
二是 Host * 这个通用规则。我在这里只放所有主机通用的参数(比如 AddKeysToAgent、macOS 上的 UseKeychain),不要放 IdentityFile。原因前面说了,Host * 会匹配一切,如果里面写了密钥,它就变成了全局默认密钥,特定域名的规则反而会被干扰。
三是 IdentitiesOnly yes 必须开。这一行能让 config 里的指定密钥成为唯一候选,避免 ssh-agent 里的其他密钥跑出来抢戏。
3.2 验证与调试技巧
配置写完,先不要急着去 clone 仓库,用以下命令验证:
bash复制ssh -T git@github.com
ssh -T git@gitlab.com
ssh -T git@git.company.com
GitHub 会返回类似 Hi username! You've successfully authenticated, but GitHub does not provide shell access. 的消息。GitLab 会返回 Welcome to GitLab, @username!。
如果失败,用 -v 参数看详细过程:
bash复制ssh -vT git@github.com
抓住输出里的关键行:
text复制debug1: Offering public key: /home/user/.ssh/id_ed25519_github_work ED25519 SHA256:xxx
debug1: Server accepts key: /home/user/.ssh/id_ed25519_github_work ED25519 SHA256:xxx
Offering public key 表示客户端在尝试哪个密钥,Server accepts key 表示服务端接受了哪个密钥。如果只看到尝试没有接受,说明这个密钥没有被服务端信任——去 GitHub 的 Settings -> SSH and GPG keys 里核对一下公钥是否配置正确。
另一种更快的调试方式是用 ssh -G 只打印生效配置,不实际连接:
bash复制ssh -G git@github.com | grep -E "identityfile|hostname|user"
这条命令会告诉你 SSH 客户端针对 github.com 最终采用了哪些参数,非常适合排查“配置写了很多条,但实际生效的是哪条”这类问题。
4. 配置方案二:同一域名下不同仓库匹配不同密钥
不同域名用不同密钥很好理解,真正的硬骨头是同一个域名下的多账号场景。最常见的例子:你有两个 GitHub 账号,一个工作号一个个人号,两个账号下的仓库都要在本地开发,push 的时候 GitHub 是根据 SSH 公钥识别身份的,同一个公钥不可能同时属于两个账号,所以你必须有两个密钥。
4.1 SSH 协议根本不知道“仓库”的存在
这是理解整章的关键。SSH 协议层没有“仓库”这个概念,当 Git 通过 SSH 连接 GitHub 时,它只能告诉 SSH 客户端“我要连 github.com”,SSH 客户端只能根据 github.com 这个 Host 去匹配密钥。GitHub 收到请求后,根据你出示的公钥判断你是哪个账号。所以同一域名下,没法靠 SSH 协议本身做仓库级别的区分。
那怎么办?思路很简单:既然实际域名都是 github.com,但 SSH 匹配靠的是 Host 字段,那我们可以在 config 里给同一个 HostName 起多个不同的 Host 别名,然后在 Git 的 remote URL 里用别名代替真实域名。SSH 连接时通过 HostName 找到真实域名,通过 Host 别名匹配不同的密钥。
4.2 Host 别名 + insteadOf 改写:标准解法
先看一个完整示例。假设你有两个 GitHub 账号:work-user 和 personal-user。
~/.ssh/config 中配置两个别名条目:
text复制Host github-work
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_github_work
IdentitiesOnly yes
Host github-personal
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_github_personal
IdentitiesOnly yes
这里我把 Host 写成了 github-work 和 github-personal 两个别名,HostName 都是 github.com。当 git clone git@github-work:work-user/repo.git 时,SSH 会拿 github-work 做匹配,命中第一条,然后用 id_ed25519_github_work 去认证 github.com。
问题来了:你总不能在 clone 的时候手动敲别名吧?正常 clone 命令拿到的是 git@github.com:xxx.git。所以要用 Git 的 URL 改写机制 insteadOf。
在 ~/.gitconfig 中增加:
text复制[url "git@github-work:"]
insteadOf = git@github.com:work-user/
[url "git@github-personal:"]
insteadOf = git@github.com:personal-user/
这段配置的意思是:当你写出 git@github.com:work-user/xxx.git 这样的 URL 时,Git 会自动把它改写成 git@github-work:work-user/xxx.git,SSH 就能命中别名规则。这样你的日常操作完全不用变:工作仓库 clone 下来后 remote URL 照样显示 git@github.com:work-user/xxx.git,push 时 Git 自动改写,SSH 自动选择正确密钥,整个过程对你透明。
实测验证一下:
bash复制git clone git@github.com:work-user/private-project.git
cd private-project
git remote -v
remote 显示的仍是原始 URL,但实际连接用的是改写后的地址。如果你不放心,可以执行:
bash复制git config --get-regexp "remote\..*\.url"
再看 ssh -G 的输出也可以验证:
bash复制ssh -G github-work | grep -E "hostname|identityfile"
应该能看到 hostname github.com 和对应的 work 密钥。
4.3 双 GitHub 账号场景的完整演示
我再把操作链路完整走一遍,方便直接照抄。
第一步,为两个账号分别生成密钥并添加公钥到各自 GitHub 账号的 SSH keys 列表。
第二步,写入 ~/.ssh/config 和 ~/.gitconfig(上面已经给了完整内容)。
第三步,测试两个别名的连接:
bash复制ssh -T git@github-work
ssh -T git@github-personal
注意,这里用别名测试,GitHub 返回的欢迎消息会对应到不同的账号名,正好验证是否匹配正确。
第四步,日常使用。老仓库如果已经存在,可以改 remote URL:
bash复制git remote set-url origin git@github.com:work-user/old-repo.git
如果之前 clone 的 URL 本身就带有用户名路径(比如 git@github.com:personal-user/xxx.git),insteadOf 会把它改写成别名形式,不需要手动改 remote。
这里有一个很常见的坑:如果你之前的仓库是用 HTTPS 协议 clone 的,remote URL 是 https://github.com/xxx.git,那 insteadOf 对 git@github.com: 的规则完全不生效。这种情况你需要先手动切换协议:
bash复制git remote set-url origin git@github.com:personal-user/xxx.git
或者直接在 ~/.gitconfig 里加一条 HTTPS 的改写规则:
text复制[url "git@github-personal:"]
insteadOf = https://github.com/personal-user/
同理,工作账号加对应规则。两条规则同时存在也不冲突,Git 会按前缀长度匹配更具体的规则。
5. 配置方案三:不同目录自动切换密钥
第三种场景是“同一个域名、同一个账号体系,但不同目录下用不同密钥”。最典型的例子是你既有公司项目又有个人项目,公司的仓库可能托管在公司的 GitLab 上,也可能 GitHub 企业版上,你希望只要进入公司项目目录,Git 就自动用公司密钥;进入个人项目目录,就自动用个人密钥。
这种场景用纯 SSH config 不好解决,因为 SSH 不感知当前目录。真正的解法在 Git 这一层。
5.1 gitconfig 的 includeIf 条件包含
Git 的全局配置 ~/.gitconfig 支持条件包含,语法是:
text复制[includeIf "gitdir:~/work/"]
path = ~/.gitconfig-work
[includeIf "gitdir:~/personal/"]
path = ~/.gitconfig-personal
这里 gitdir: 是目录前缀匹配,只要仓库所在路径匹配 ~/work/ 开头(注意 gitdir: 后面的路径是绝对路径或 ~ 展开路径),Git 就会额外加载 ~/.gitconfig-work 这个文件。
拆分出来的分配置可以这样写。
~/.gitconfig-work:
text复制[user]
name = Your Work Name
email = work@company.com
[core]
sshCommand = ssh -i ~/.ssh/id_ed25519_github_work -o IdentitiesOnly=yes
~/.gitconfig-personal:
text复制[user]
name = Your Personal Name
email = personal@example.com
[core]
sshCommand = ssh -i ~/.ssh/id_ed25519_github_personal -o IdentitiesOnly=yes
这里的核心是 core.sshCommand。Git 执行 SSH 操作时会调用这个命令,而不是直接用默认的 ssh,这样我们就能在命令里指定 -i 参数强制使用某个密钥,同时用 -o IdentitiesOnly=yes 避免 ssh-agent 里的其他密钥干扰。
5.2 core.sshCommand 按目录指定密钥
core.sshCommand 的优先级规则需要说清楚:它是在 Git 层面设置的,和 SSH config 的 Host 匹配是两层逻辑。如果你在某个目录下同时命中了 includeIf 加载的 sshCommand 和全局配置里的 sshCommand,那更具体的、后加载的生效。为了避免混乱,我的建议是:全局不要设置 core.sshCommand,只在需要区分的目录分配置里设置,这样逻辑最清晰。
还有一点需要注意:includeIf 的 gitdir: 匹配的是仓库的 .git 目录所在位置。如果仓库放在 ~/work/project-a 和 ~/work/project-b,都能命中 ~/work/ 前缀。如果你的目录结构是嵌套的,比如 ~/work/oss/mirror 下面也有仓库,但你希望这些镜像仓库不要走公司配置,你可以在 includeIf 里用 gitdir/i:(大小写不敏感)或者用更长的前缀覆盖:
text复制[includeIf "gitdir:~/work/oss/"]
path = ~/.gitconfig-oss
放在后面的 includeIf 会覆盖前面的同名配置项,利用这个特性可以做细粒度控制。
5.3 ssh config 的 Match 指令:更灵活的条件控制
如果你不想拆多个 gitconfig 文件,也可以在 SSH config 里用 Match 指令实现条件匹配。Match 支持多种条件,常见的有 Host、User、OriginalHost,还有一个利器 exec——可以执行任意命令,根据命令退出码决定是否生效。
比如下面这种写法,让 SSH 根据当前所在目录切换密钥:
text复制Match host github.com exec "[ $(pwd) = /home/user/work ]"
IdentityFile ~/.ssh/id_ed25519_github_work
IdentitiesOnly yes
Match host github.com exec "[ $(pwd) = /home/user/personal ]"
IdentityFile ~/.ssh/id_ed25519_github_personal
IdentitiesOnly yes
这个写法有个大前提:SSH 连接时的 pwd 是执行 git push 命令时的当前目录,所以它确实能按目录切换。但我不太推荐把 Match exec 作为主力方案,原因有两个:一是它依赖 shell 语法,不同系统下 pwd、[ ] 的行为可能有差异,移植性差;二是 debug 时心智负担重,遇到问题你还得同时思考 SSH 的匹配顺序和目录判断逻辑。相比之下,Git 的 includeIf 是官方推荐、行为明确的条件机制,我更建议作为首选。
Match exec 更适合的场景是那些“没法用目录区分的临时需求”,比如你需要在某个脚本里临时指定密钥:
bash复制GIT_SSH_COMMAND="ssh -i ~/.ssh/id_ed25519_github_work -o IdentitiesOnly=yes" git push
环境变量 GIT_SSH_COMMAND 是 core.sshCommand 的临时替代品,单次命令生效,不改任何配置,应急预案特别好用。
6. 多密钥管理的日常维护与避坑清单
配置方案讲完了,但真正让多密钥方案长时间稳定运行下去的,是日常维护和一些细节习惯。这里把我踩过、以及帮别人排查过的坑汇总一下。
6.1 ssh-agent 的密钥列表会让你前功尽弃
多密钥配置最常见的翻车点就是 ssh-agent。很多桌面发行版和 macOS 默认会启动 ssh-agent,如果你曾经执行过 ssh-add ~/.ssh/id_rsa 或者某些 GUI 工具自动加载了密钥,agent 里会长期驻留一批密钥。即便你的 ~/.ssh/config 写得很完美,只要对应的 Host 条目没有开 IdentitiesOnly yes,SSH 客户端仍会把 agent 里的密钥挨个试一遍。
排查方法很简单:
bash复制ssh-add -l
这条命令列出 agent 当前加载的所有密钥指纹。如果看到不想关的密钥,用 ssh-add -d <path> 删除指定密钥,或 ssh-add -D 清空全部。但更一劳永逸的做法还是给 config 里的每个条目都加上 IdentitiesOnly yes,让配置文件说了算,agent 只负责缓存密码。
6.2 权限、文件名、注释这些细节
三个最容易犯的错:
第一,~/.ssh 目录权限必须是 700,私钥文件权限必须是 600,公钥文件 644。如果权限过宽,OpenSSH 会直接拒绝使用这个密钥,而且报错信息不明显,通常就是一句 Permissions 0644 for 'xxx' are too open。养成习惯,每次添加新密钥后顺手执行:
bash复制chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_*
第二,密钥文件名尽量不要带空格和奇怪的符号,路径里也不要出现中文。IdentityFile 支持 ~ 展开,但不支持环境变量展开,所以写 ~/.ssh/xxx 是安全的,写成 $HOME/.ssh/xxx 可能失效。
第三,公钥文件的命名不影响匹配,只有私钥路径被 IdentityFile 引用时才关键。公钥只是给服务端用的,本地 SSH 认证时不会读公钥文件内容(除非你用了 ssh-copy-id 等工具),所以公钥放错位置一般不影响认证。
6.3 调试三板斧:-v、-T、-G
遇到 Permission denied (publickey) 不要慌,按顺序用三个命令定位。
先看最终生效配置:
bash复制ssh -G git@github.com | grep -E "identityfile|identitiesonly|hostname"
确认 SSH 客户端认为应该用哪个密钥。如果这里显示的密钥和你预期不符,问题出在 config 的匹配顺序或别名上。
再看实际认证过程:
bash复制ssh -vT git@github.com 2>&1 | grep -E "Offering|Server accepts|Authenticated|denied"
如果 Offering 了一堆密钥但 Server accepts 一行都没有,说明服务端不认这些公钥,去网页端核对公钥。如果压根没 Offering 你预期中的密钥,说明 IdentityFile 没写对或 IdentitiesOnly 没生效。
最后检查 agent:
bash复制ssh-add -l
确认 agent 列表里没有多余密钥干扰。
6.4 不同系统的差异要点
我这个流程里所有命令在 Linux/macOS 上通用,Windows 用户要额外注意几点。
Windows 10/11 自带的 OpenSSH 支持同样的 ~/.ssh/config 语法,路径一般是 C:\Users\用户名\.ssh\config。但 Windows 下默认 shell 可能是 PowerShell 或 CMD,~/.gitconfig 的路径也略有差异。另外 Windows 的 OpenSSH 默认不自动加载 ssh-agent 服务,你需要手动启动:
powershell复制Get-Service ssh-agent | Set-Service -StartupType Automatic
Start-Service ssh-agent
ssh-add ~/.ssh/id_ed25519_github_work
macOS 上要注意两点:一是 UseKeychain yes 可以把密钥密码存入钥匙串,避免每次重启后都要重新输入,但要注意钥匙串同步可能带来跨设备一致性问题;二是 macOS 的 /etc/ssh/ssh_config 系统级配置优先级低于用户级 ~/.ssh/config,除非你在用户级配置文件里用了 Include 之外的方式覆盖,一般不用担心系统级配置干扰。
Include 指令值得一提。如果你有大量机器需要统一配置,比如所有开发机都要复用同一套 GitHub 规则,可以把公共配置拆到 ~/.ssh/config.d/ 目录下,在 ~/.ssh/config 里写:
text复制Include ~/.ssh/config.d/*
注意 Include 必须在常规配置之前生效,所以一般放在文件第一行。这个技巧在管理多台开发机时非常有用,我自己的做法是把公司相关配置和日常个人配置拆成两个文件,换电脑时只拷贝需要的部分,避免把所有凭据都铺到新机器上。
最后再分享一个我自己的使用习惯:我会在 ~/.ssh/config 每个条目后面都写一行注释,用 # 标明这个密钥对应的账号名和用途,比如:
text复制# work GitHub account (work@company.com)
Host github-work
HostName github.com
...
几年后再回来改配置时,注释能帮你少掉不少头发。密钥这东西,配好一次能安稳用很久,但一旦失效,排查链路往往比配置本身麻烦得多。把上面这套逻辑吃透,以后无论再新增几个域名、几个账号,都只是往 config 里添条目的事,不会再被 SSH 的“密钥乱试”折磨了。
