Permission denied (publickey): 6 Reasons Git Push Fails Over SSH (and the Fix for Each)
Symptom: git refuses to talk to the remote
git@github.com: Permission denied (publickey).
fatal: Could not read from remote repository.
Please make sure you have the correct access rights
and the repository exists.
One-line diagnosis: SSH authentication failed — the server doesn't recognize the public key you presented. This has nothing to do with passwords (publickey auth doesn't use one). Either the key in your hand doesn't match any lock registered on the server, or you never handed over a key at all.
First step: test auth directly with ssh -T
Skip git and test the SSH channel itself — you'll get the cleanest error:
ssh -T git@github.com
# Success: Hi yourname! You've successfully authenticated...
# Failure: git@github.com: Permission denied (publickey).
# Add -v for the full story: which keys were offered, which auth the server accepts
ssh -vT git@github.com 2>&1 | grep -E 'Offering|identity file|denied'
The Offering public key: /Users/you/.ssh/id_xxx lines in the -v output are the key evidence — they list the keys actually presented. If the one you thought you were using isn't there, jump straight to cause 2.
Six causes, ranked by frequency
1. No key on this machine (most common on a new laptop)
ls ~/.ssh/
# id_ed25519 / id_ed25519.pub or id_rsa / id_rsa.pub → you have one
# nothing → generate one
ssh-keygen -t ed25519 -C "your_email@example.com"
# accept the defaults (or set a passphrase), then start the agent and load it
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
Then add the contents of the .pub file (public keys only — the private key never leaves your machine) to GitHub: Settings → SSH and GPG keys → New SSH key.
One permission note: ~/.ssh must be 700 and the private key 600 — OpenSSH refuses keys readable by group/others ("unprotected private key file"). If the numbers escape you, the chmod calculator turns checkboxes into the exact command.
2. Key exists, but the agent isn't offering it
ssh-add -l
# "The agent has no identities." → not loaded
ssh-add ~/.ssh/id_ed25519
On macOS, put the key metadata in ~/.ssh/config and skip the manual add forever:
Host github.com
AddKeysToAgent yes
UseKeychain yes
IdentityFile ~/.ssh/id_ed25519
3. Public key never added to the platform (or added to the wrong account)
GitHub keys are account-scoped: a key registered under account A gets rejected for account B's repos. Another trap: the same public key is already bound to a different GitHub account — GitHub doesn't allow one key on multiple accounts, and the same applies to deploy keys. For multi-account setups, see cause 4.
Company GitLab, Gitee, and self-hosted servers each have their own key-upload page — make sure you added it to the platform you're actually pushing to.
4. Multiple accounts/keys: the wrong key gets offered
With a work key and a personal key on the same machine, SSH tries them in order and often presents the wrong one. Route by host alias in ~/.ssh/config:
# Personal GitHub
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_personal
IdentitiesOnly yes
# Work GitHub account via an alias
Host github-work
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_work
IdentitiesOnly yes
IdentitiesOnly yes is the critical line — it forces SSH to offer only the specified key instead of trying every key it can find. Then use the alias in your remote URL:
git remote set-url origin git@github-work:company/repo.git
5. Deploy keys have a permission boundary
A public key added under a repo's Settings → Deploy keys is valid for that repo only: cloning a different repo with it, or pushing when the key was added without write access, both end in publickey rejection. The classic CI trap: one runner using one deploy key to pull many repos — use organization-level deploy keys or a machine-user account instead.
6. Wrong remote URL (https and ssh mixed up)
git remote -v
# origin git@github.com:user/repo.git → SSH; debug with the methods above
# origin https://github.com/user/repo.git → HTTPS; errors ask for a password/token, not publickey
Case mismatches, misspelled org names, and a missing .git suffix all blur the line between "repo not found" and "auth failed". Also note: if a GitHub organization enforces SSO, a personal key still needs a one-time authorization for that org (SSO → Authorize).
Why does it say publickey when I never configured keys?
The word in parentheses is the list of auth methods the server permits. If you see Permission denied (publickey,password), both methods were tried and both failed — SSH attempts each method the server allows before giving up. Read the parentheses to see which doors exist.
Windows OpenSSH: same protocol, different agent path
Git for Windows ships its own OpenSSH; Windows 10/11 also ship a system OpenSSH. Mixing them is a classic publickey ghost: you ssh-add into one agent while git invokes the other binary. Check:
where.exe ssh
Get-Service ssh-agent # start + set to Automatic if needed
ssh-add -l
Point Git at one stack (git config --global core.sshCommand "C:/Windows/System32/OpenSSH/ssh.exe") and load keys into that agent. Pageant/PuTTY keys are not visible to OpenSSH unless you bridge them — prefer a single agent story.
Host key changed vs publickey denied
REMOTE HOST IDENTIFICATION HAS CHANGED is not publickey failure — the server's host key no longer matches ~/.ssh/known_hosts. After a rebuild or IP reuse, remove the stale line (ssh-keygen -R hostname) only when you trust the new host fingerprint. Confusing the two wastes hours: fixing your user key never clears a host-key mismatch.
Agent forwarding: convenience with a blast radius
ssh -A / ForwardAgent yes lets a jump host use your local key for the next hop. Useful for bastions; dangerous when the bastion is shared — a compromised hop can request signatures from your agent. Prefer ProxyJump with a dedicated deploy key on the bastion, or short-lived certificates. If -v shows offerings you did not expect on the second hop, check whether forwarding is on.
Key type expectations (ed25519 first)
Most hosts accept ssh-ed25519 today; some older appliances still want rsa with ≥3072 bits. If -v shows the client offering ed25519 and the server closing without trying RSA, generate an RSA key for that host only and pin it with IdentityFile + IdentitiesOnly yes. Do not weaken global defaults for one legacy box. Hardware-backed keys (ed25519-sk) behave like normal keys once enrolled — failure modes are the same (not loaded / not registered), plus a missing FIDO touch.
Checklist
ssh -T git@github.com— direct test, cleanest error messagessh -vT ... | grep Offering— which keys were actually presented?ls ~/.ssh/+ssh-add -l— does the key exist? is it loaded?- Is the public key registered on the right platform, account (or repo deploy key)?
- Multiple keys:
~/.ssh/configwith per-host aliases +IdentitiesOnly yes git remote -v— check the URL spelling and protocol (ssh vs https)- Windows: one OpenSSH binary + matching agent; rule out known_hosts host-key errors before chasing user keys
Triage notes
The article's first step does not investigate git — it goes around it: ssh -T tests the auth channel directly and gets you the cleanest error message. The 'Offering' lines in -v output are the diagnostic core, listing the keys actually handed over; if the key you believe you are using is not on that list, jump straight to the multiple-keys entry. IdentitiesOnly yes is marked critical in multi-account setups precisely so SSH does not offer every key in turn. Host-key changes get their own clarification that they are not publickey failures — changing user keys will never clear a known_hosts conflict, and confusing the two wastes a whole debugging session.
Provided by ToolVault. Related tools: Linux Cheatsheet, Password Generator, JWT Decoder, chmod calculator. Related reading: npm registry mirror guide, Port already in use. See the homepage for more developer tools.
Related Tools
Related Articles
Linux Crontab in Practice: Commands, Logs, and Debugging
Crontab beyond syntax: three must-know commands, /etc/cron.d vs user crontabs, five-step debugging, PATH/timezone/% escaping, MAILTO, and what to do when you need second-level scheduling.
docker run to docker-compose: The Complete Migration Guide (with Flag Map)
Migrate docker run commands to docker-compose.yml: five advantages of compose over raw run, a flag-by-flag mapping table (ports, volumes, env, restart, networks), the classic pitfalls (bare -e passthrough, anonymous volumes, --rm semantics), and an online converter that runs locally in your browser.
npm ERR! ERESOLVE: peer dependency conflicts — the complete fix guide
npm install fails with ERESOLVE peer dependency conflicts? Understand what peer dependencies are, why npm 7+ enforces them, and 4 resolution strategies (force, legacy-peer-deps, overrides, manual) with trade-offs.