- All posts
- Enforce SSH instead of HTTPS for Git operations
Enforce SSH instead of HTTPS for Git operations
GitHub stopped accepting passwords for Git in August 2021. A token in a credential helper solves the typing but not the expiry; a key solves both, and reaches the HTTPS URLs you did not write yourself.
Ten days ago GitHub stopped accepting account passwords for Git operations over HTTPS. If you push over HTTPS you now paste a personal access token instead: a longer secret, kept wherever the password was, expiring on a schedule you have to remember.
A credential helper takes the typing out of that — osxkeychain, Git
Credential Manager, gh auth login — and if tokens suit you, that is a fine
answer. What it cannot take out is the expiry. Every few months the token
lapses and you are back on the settings page, generating another one and
trying to remember which scopes it had.
An SSH key has no expiry date. You generate a pair once, keep the private half on your machine, hand the public half to the host, and the arrangement holds until you revoke it. It also reaches somewhere a credential helper cannot: the HTTPS URLs you did not write, in repositories and lockfiles belonging to other people.
The first two steps below are in every guide. The two after them are the ones that make this stick.
Generate a key
ssh-keygen -t ed25519 -C "personal github" -f ~/.ssh/personal-github
Ed25519 rather than RSA: shorter keys, faster handshakes, and no key-size
argument to get wrong. For anything older than OpenSSH 6.5, -t rsa -b 4096
is the fallback.
Name the key after what it unlocks — personal-github, work-gitlab — and run
ssh-keygen once per account. GitHub refuses a public key already registered
to another account, so two accounts force two keys whether you planned for them
or not. Generate separately on each machine as well, and keep every private
half where it was made: a key that exists in one place is a key you can revoke
in one place. id_ed25519_2 tells you nothing on the day you need to revoke it.
Use a passphrase. Loaded into ssh-agent it costs one prompt per session
rather than one per push.
Add the public key to the host
Copy the public half, the file ending in .pub. The one without an extension
stays where it was generated:
# macOS
pbcopy < ~/.ssh/personal-github.pub
# Linux
xclip -selection clipboard < ~/.ssh/personal-github.pub
Then paste it into your account settings: GitHub, GitLab, or Bitbucket.
Tell SSH which key to use
Left to itself, SSH tries the default names — id_ed25519, id_rsa — so a key
called personal-github is never offered. In ~/.ssh/config:
Host github.com
User git
IdentityFile ~/.ssh/personal-github
IdentitiesOnly yes
AddKeysToAgent yes
Host gitlab.com
User git
IdentityFile ~/.ssh/work-gitlab
IdentitiesOnly yes
AddKeysToAgent yes
IdentitiesOnly yes earns its line. Without it SSH offers every key the agent
holds, and GitHub authenticates you as whichever account matches the first one
that fits — so a repository you have access to comes back as “not found”. It
reads as a permissions bug and is not one.
On macOS, UseKeychain yes in each block keeps the passphrase in the login
keychain across restarts. It belongs to Apple’s OpenSSH rather than upstream,
so if Homebrew’s openssh is ahead on your PATH it will refuse to start.
Load the key into the keychain once with ssh-add -K ~/.ssh/personal-github.
Connect once, both to check the key and to answer the question SSH asks the first time it meets a host:
ssh -T git@github.com
The prompt shows the server’s host key fingerprint. Compare it against the
fingerprints GitHub publishes before answering yes: that prompt is the whole
of SSH’s server authentication, and it is the easiest thing on this page to
answer by reflex. After it, a greeting with your username means the key works.
If the connection hangs instead, the network is probably blocking port 22. GitHub answers SSH on 443 as well:
Host github.com
Hostname ssh.github.com
Port 443
# ...the rest of the block as above
Worth setting before you need it. After the next step, HTTPS is no longer available as a fallback.
Rewrite HTTPS URLs to SSH
A key does nothing while your remotes still say https://. Rather than fixing
them one repository at a time, tell Git to substitute, in ~/.gitconfig:
[url "ssh://git@github.com/"]
insteadOf = https://github.com/
[url "ssh://git@gitlab.com/"]
insteadOf = https://gitlab.com/
[url "ssh://git@bitbucket.org/"]
insteadOf = https://bitbucket.org/
Every HTTPS URL for those hosts is now rewritten before the request leaves your
machine, including the URLs you did not write yourself. Submodules are the case
that pays for the whole thing: a .gitmodules committed by someone else,
pointing at HTTPS, in a repository you have no say over. The same goes for any
dependency that names a Git repository by HTTPS URL — a pod with a :git =>
line, a go get under GOPRIVATE, an npm dependency spelled git+https://.
Two things it leaves alone, both correctly. Package managers that fetch an
archive rather than clone never hand Git a URL at all: CocoaPods moved to a CDN
in 1.8, the Go proxy serves public modules, and npm’s github:user/repo
shorthand resolves to a codeload tarball. And this is ~/.gitconfig on your
machine, so a CI runner with its own home directory will never see it. That is
the outcome you want. A build server should authenticate with a scoped token
over HTTPS, not with your personal key.
Check the rewrite
Substitution happens inside Git, before the network, so you can watch it without connecting to anything:
git ls-remote --get-url https://github.com/omaralbeik/SwifterSwift.git
If that prints ssh://git@github.com/omaralbeik/SwifterSwift.git, the rewrite
is live. Reach for this rather than cloning a public repository as a test:
public clones over HTTPS succeed without credentials either way, so a clean
clone proves nothing about whether the substitution fired.
That is the whole arrangement — Git rewrites the URL, SSH picks the key that belongs to the host. Nothing expires, and nothing to paste.