Note4 min read

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.

Topics