SSH keys, tokens & the GitHub CLI
- Tell an HTTPS remote from an SSH remote by its URL, say what each authenticates with, and switch a clone with
git remote set-url - Create an Ed25519 key with a passphrase, let the agent and Keychain hold it, and give GitHub only the
.pubhalf - Read
ssh -Tandssh -Goutput (exit 1 is success) and order~/.ssh/configso a second account'sHostalias offers the right key - Choose a fine-grained token,
gh auth loginorGH_TOKENfor a person vs a script, and explain why logins and passphrases belong in your own terminal - Run the clone → branch → PR → review loop with
gh repo clone,gh pr create,gh pr checkoutandgh pr view
Read
Two ways git proves who you are to GitHub
FlagCloning a public repo over HTTPS needs no identity. Pushing — or reading a private repo — does (and SSH asks for a key even for public repos). Git talks to GitHub in one of two ways, and the remote URL decides which:
% git remote -v
origin https://github.com/acme/api.git (fetch) # HTTPS
origin git@github.com:acme/api.git (fetch) # SSH (one or the other, not both)
| HTTPS | SSH | |
|---|---|---|
| URL | https://github.com/acme/api.git | git@github.com:acme/api.git |
| You prove identity with | a token (typed where git asks for a password) | a key pair on your machine |
| Stored by | a credential helper (macOS Keychain) | ~/.ssh/ + the ssh agent |
| Typical blocker | a corporate proxy that allows only HTTPS | a network that blocks port 22 |
Your GitHub password doesn't work for either: GitHub removed password authentication for git operations. Over HTTPS, the "password" git asks for is a personal access token.
The key pair, in one paragraph
ssh-keygen makes two files. id_ed25519 is the private key: it never leaves your
machine and is never pasted anywhere. id_ed25519.pub is the public key: you paste that
into GitHub (Settings → SSH and GPG keys). When you connect, your machine proves it holds the
private key that matches a public key GitHub has on file — and GitHub knows which account
that public key belongs to. That's how it decides who you are.
% cat ~/.ssh/id_ed25519.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA… ada@example.com # safe to share
Switching an existing clone
The protocol isn't baked into the repo, only into the remote's URL:
git remote set-url origin git@github.com:acme/api.git # HTTPS → SSH
git remote -v # check
Nothing else changes — same branches, same history. Pick one protocol and use it everywhere; mixing them is how you end up typing a token for one repo and a passphrase for the next.
An SSH key on macOS, line by line
FlagGitHub's macOS instructions, with what each step actually does. Run them in your own terminal (the transcripts are OpenSSH 10.2 on macOS 26; older versions word the prompts a little differently).
1. Make the key pair
% ssh-keygen -t ed25519 -C "ada@example.com"
Generating public/private ed25519 key pair.
Enter file in which to save the key (/Users/ada/.ssh/id_ed25519):
Enter passphrase for "/Users/ada/.ssh/id_ed25519" (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /Users/ada/.ssh/id_ed25519
Your public key has been saved in /Users/ada/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:… ada@example.com
-t ed25519— the key type GitHub's instructions use (an Ed25519 public key is one short line).-C— a comment stored at the end of the public key. Without it, OpenSSH usesuser@hostname, which puts your machine's name into every server you register the key with.- The passphrase encrypts the private key on disk. A stolen laptop backup or a leaked dotfiles repo then holds a file that's useless without it. Use one.
ssh-keygen writes the private key readable only by you (-rw-------). Copy keys around by hand
and loosen that, and OpenSSH refuses them: WARNING: UNPROTECTED PRIVATE KEY FILE! …
Permissions 0644 for '…' are too open. chmod 600 the private key to fix it.
2. Let the agent remember the passphrase
Typing the passphrase on every git push would get old. The ssh-agent holds unlocked keys
in memory, and on macOS the Keychain can store the passphrase so the agent unlocks the key for
you:
# ~/.ssh/config
Host github.com
AddKeysToAgent yes
UseKeychain yes
IdentityFile ~/.ssh/id_ed25519
ssh-add --apple-use-keychain ~/.ssh/id_ed25519 # asks for the passphrase once, saves it
macOS already runs an agent for your login session (echo $SSH_AUTH_SOCK shows its socket);
GitHub's guide also starts one explicitly with eval "$(ssh-agent -s)". UseKeychain and
--apple-use-keychain are Apple additions — a non-Apple OpenSSH (Homebrew's, say) stops at
Bad configuration option: usekeychain, which is what GitHub's IgnoreUnknown UseKeychain
workaround is for. (Before macOS 12, the flag was -K.)
3. Give GitHub the public half, and test
Paste the contents of ~/.ssh/id_ed25519.pub into GitHub → Settings → SSH and GPG keys (or
gh ssh-key add ~/.ssh/id_ed25519.pub). Then:
% ssh -T git@github.com
Hi ada! You've successfully authenticated, but GitHub does not provide shell access.
% echo $?
1
That is success. -T asks for no terminal, GitHub refuses to give you a shell, and the
command exits 1 — GitHub's docs say to expect exactly that. The line that matters is the
username: it's the account your key belongs to.
Failure looks different:
git@github.com: Permission denied (publickey). # exit 255 — ssh itself failed
That means GitHub accepted none of the keys offered — usually the public key was never added,
or ssh offered a different key file than the one you registered.
Finally, point existing clones at SSH: git remote set-url origin git@github.com:acme/api.git.
~/.ssh/config — first value wins, and two GitHub accounts
FlagGitHub identifies you by which key authenticates. Two accounts on one laptop (personal and
work) therefore means two keys, and a way to make ssh offer the right one. The tool is
~/.ssh/config — and its one rule surprises everyone who expects "later lines override".
The rule
The ssh_config(5) manual: "for each configuration directive, the first specified value will be
used", so "more host-specific declarations should be given near the beginning of the file, and
general defaults at the end." Every Host block whose pattern matches contributes, top to
bottom; a later block can't change a value an earlier one already set. IdentityFile is the
exception that accumulates: every matching one is added to the list, in file order, and
ssh tries them in that order.
ssh -G <host> prints the configuration ssh would use, without connecting. With -F it
reads only the file you give it:
# config-bad: defaults first
Host *
User ada
IdentityFile ~/.ssh/id_ed25519
Host github-work
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_work
IdentitiesOnly yes
% ssh -G -F config-bad github-work | grep -E '^(user|hostname|identityfile) '
user ada
hostname github.com
identityfile ~/.ssh/id_ed25519
identityfile ~/.ssh/id_ed25519_work
User git lost to the earlier User ada, and the personal key is offered first. Move
the github-work block above Host * and the same command prints user git and the work key
first. (A git URL like git@github-work:… names the user itself, so the User line doesn't
bite there — the key order does.)
Two accounts that actually work
# ~/.ssh/config — specific first, defaults last
Host github-work
HostName github.com
IdentityFile ~/.ssh/id_ed25519_work
IdentitiesOnly yes
Host github.com
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
git clone git@github-work:acme/api.git # the alias picks the work key
git remote set-url origin git@github-work:acme/api.git # for an existing clone
ssh -T git@github-work # Hi ada-work! …
github-work isn't a real host: HostName maps it to github.com, and the alias is just a
handle that selects a block. IdentitiesOnly yes matters when the agent holds both keys —
without it, ssh may offer agent keys too, and GitHub signs you in with whichever it accepts
first. GitHub's "Managing multiple accounts" page shows this same pattern (for a managed enterprise
account), plus a git setting, url."git@github-work:acme/".insteadOf "git@github.com:acme/", so
URLs copied from the website are rewritten to the alias automatically.
Reading the symptom
ERROR: Permission to acme/api.git denied to ada-personal.
fatal: Could not read from remote repository.
GitHub's docs: this "means the key you are pushing with is attached to an account which does
not have access to the repository." Authentication succeeded — as the wrong account. That's
a key-order problem, not a permissions one: check with ssh -T against the same host alias the
remote uses, and look at the name in the greeting.
Quiz
git remote -v shows origin git@github.com:acme/api.git. When you push, what proves who you are?
GitHub's "New SSH key" form wants a key. What do you paste?
Flagssh -T git@github.com prints Hi ada! You've successfully authenticated, but GitHub does not provide shell access. and echo $? prints 1. Is SSH set up correctly?
Your ~/.ssh/config starts with Host * → IdentityFile ~/.ssh/id_ed25519 (personal), then Host github-work → HostName github.com, IdentityFile ~/.ssh/id_ed25519_work, IdentitiesOnly yes. Pushing to git@github-work:acme/api.git fails with ERROR: Permission to acme/api.git denied to ada-personal. Why?
A small script will add labels to issues in acme/api, and nothing else. What credential should it use?
A CI job needs to run gh pr comment. How should it authenticate?
A teammate's PR #42 comes from their fork. You want to run it locally. Quickest correct move?
FlagFill in the blank
Generate the key type GitHub's instructions use, with your email as the comment.
Flagssh-keygen -t -C "ada@example.com"
On macOS, load the key into the agent automatically and keep its passphrase in the Keychain.
FlagHost github.com yes UseKeychain yes IdentityFile ~/.ssh/id_ed25519 # once, in your own terminal: ssh-add ~/.ssh/id_ed25519
Point an existing HTTPS clone at the SSH URL instead.
Flaggit remote origin git@github.com:acme/api.git
Give a second account its own alias that connects to GitHub and offers only the work key.
FlagHost github-work github.com IdentityFile ~/.ssh/id_ed25519_work yes
Open a draft PR from your branch using the commit messages, then check out a teammate's PR #42.
Flaggh pr --draft --fill gh pr 42
Predict the output
% cat config-bad Host * User ada IdentityFile ~/.ssh/id_ed25519 Host github-work HostName github.com User git IdentityFile ~/.ssh/id_ed25519_work % ssh -G -F config-bad github-work | grep -E '^(user|identityfile) '
# run from a script: no terminal attached % true | ssh-keygen -q -t ed25519 -f ./ci_key -C ci Enter passphrase for "./ci_key" (empty for no passphrase): Enter same passphrase again: % echo $? % ssh-keygen -y -P "" -f ./ci_key > /dev/null && echo "opens with an empty passphrase"
Further reading
- GitHub Docs: About remote repositories
- GitHub Docs: Generating a new SSH key and adding it to the ssh-agent
- GitHub Docs: Testing your SSH connection
- GitHub Docs: Error: Permission to user/repo denied to other-user
- GitHub Docs: Managing multiple accounts
- GitHub Docs: Managing your personal access tokens
- GitHub Docs: Caching your GitHub credentials in Git
- GitHub CLI manual: gh auth login
- GitHub CLI manual: environment variables
- GitHub CLI manual: gh pr create
- GitHub CLI manual: gh pr checkout
- OpenBSD manual: ssh_config(5)
- OpenBSD manual: ssh-keygen(1)
- OpenBSD manual: readpassphrase(3) (reads /dev/tty, falls back to standard input)