Mert Özen Further With Every Line
DevOps Git & GitHub — #13

Connecting to GitHub: HTTPS, Personal Access Tokens and SSH

Mert Özen Nov 6, 2026 10 min 10 views
Connecting to GitHub: HTTPS, Personal Access Tokens and SSH

From creating a repo on GitHub to the first push: why passwords stopped working in 2021, fine-grained versus classic personal access tokens, how credential helpers work, ed25519 SSH keys, gh auth login and transfer.credentialsInUrl, which catches tokens embedded in URLs.

Version note: The commands in this post were tested with Git 2.54; the git config get/set form has existed since Git 2.46.

Guess First

The bookstore repo is ready on your machine with three commits. You created an empty repo on GitHub and connected your local repo to it:

$ git log --oneline
7f7eeec Add cart total
61419c1 Add book list
5102cb8 Add README and .gitignore
$ git remote add origin https://github.com/ada/bookstore.git
$ git remote -v
origin	https://github.com/ada/bookstore.git (fetch)
origin	https://github.com/ada/bookstore.git (push)

Next up is git push -u origin main. The terminal asks for your username, then your password. You type your GitHub account password and press Enter. Does the push go through?

It doesn't. Since August 13, 2021, GitHub no longer accepts account passwords for Git operations. It announced the change in December 2020, warned with two short brownouts and switched passwords off on that date. The "Password" prompt in the terminal is Git's generic prompt; GitHub expects a token there, not your password. This post fills that gap: creating a repo on GitHub, using a personal access token over HTTPS, a credential helper so you don't type the token every time, SSH keys and the GitHub CLI.

Creating a Repo on GitHub

You create the repo in the web UI. From the plus menu in the top right you pick the new repository option. In the form you choose the owner (your own account or an organization you belong to), the repo name, an optional description and the visibility (public or private). At the bottom of the form there are options to initialize the repo with a README, a .gitignore template and a license. Since October 1, 2020, new repos on GitHub use main as their default branch; that's why we set init.defaultBranch to main in the second post.

If you already have a local repo, leave those three options empty. For an empty repo GitHub shows you a few lines of commands for pushing an existing repo; they're the same remote add and push -u lines as above. What happens if you tick the options? GitHub starts the repo with its own first commit, and a second history is born that has nothing in common with yours. I reproduced this locally with a bare repo that holds a single README commit:

$ git remote add origin ../github-bookstore.git
$ git push -u origin main
To ../github-bookstore.git
 ! [rejected]        main -> main (fetch first)
error: failed to push some refs to '../github-bookstore.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. This is usually caused by another repository pushing to
hint: the same ref. If you want to integrate the remote changes, use
hint: 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
$ git pull --no-rebase origin main
From ../github-bookstore
 * branch            main       -> FETCH_HEAD
 * [new branch]      main       -> origin/main
fatal: refusing to merge unrelated histories
$ git pull --no-rebase --allow-unrelated-histories origin main
From ../github-bookstore
 * branch            main       -> FETCH_HEAD
Auto-merging README.md
CONFLICT (add/add): Merge conflict in README.md
Automatic merge failed; fix conflicts and then commit the result.

The push was rejected, the pull refused to join the two histories, and when you forced it, the README had been created separately on both sides, so you got an add/add conflict. It's not a dead end, but never getting there is much easier: if you have a local repo, create the GitHub repo empty.

HTTPS or SSH?

You reach a GitHub repo through one of two addresses. The HTTPS address looks like https://github.com/ada/bookstore.git, the SSH address like git@github.com:ada/bookstore.git. Both point to the same repo; the only difference is how you prove who you are. You can switch the remote to the other one at any time:

$ git remote set-url origin git@github.com:ada/bookstore.git
$ git remote -v
origin	git@github.com:ada/bookstore.git (fetch)
origin	git@github.com:ada/bookstore.git (push)

Two options and a short tradeoff. HTTPS works on every network because it uses the same port as web traffic; it's fine behind corporate proxies too. In return you manage a token, and tokens expire. With SSH you generate a key once, add it to GitHub and then leave it alone for years; but some company networks block port 22, which SSH uses. On your own machine SSH, and in CI environments and restricted networks HTTPS, is usually the path with the least friction.

Personal Access Tokens: Instead of the Password

What you type at the password prompt when pushing over HTTPS is a personal access token (PAT). You create one on GitHub by going from the profile menu to settings, then to the developer settings and the personal access tokens section. There are two kinds.

A fine-grained token belongs to a single owner: either your account or an organization. You pick the repos it can reach one by one and grant each permission separately, for example only read and write access to bookstore's contents. Organizations can require approval for these tokens. They arrived as a beta in October 2022, became generally available on March 18, 2025, and GitHub recommends them wherever possible. A classic token works with broad scopes and opens every repo you can reach. There are a few things fine-grained tokens still can't do: contributing to public repos you're not a member of, working as an outside collaborator, reaching several organizations at once, Packages and the like. In those cases you may have to fall back to a classic token.

When you create a token, pick an expiration and grant the narrowest permissions. You see the token only once; after you close the page it isn't shown again. GitHub removes tokens that haven't been used for a year on its own.

Credential Helpers: Type the Token Once

You don't want to paste a token on every push. Git leaves that to a credential helper: when Git needs to send credentials to a server it asks the helper first, if the helper knows nothing it asks you, and it has the helper store whatever worked. To see the mechanism I set up the simplest helper, store, for a demo, writing to a file:

$ git config set credential.helper "store --file=../demo-credentials"
$ printf 'protocol=https\nhost=github.com\nusername=ada\npassword=example-token\n' | git credential approve
$ cat ../demo-credentials
https://ada:example-token@github.com
$ printf 'protocol=https\nhost=github.com\n' | git credential fill
protocol=https
host=github.com
username=ada
password=example-token
$ git config unset credential.helper

git credential approve is exactly what Git tells the helper after a successful push: "this worked, keep it". git credential fill is the question asked on the next push: "what do you have for github.com?" The answer is the protocol, host, username and token. But look at the cat output: store writes the token to disk in plain text. That's why we don't use it for real work.

For real work you pick the helper that uses the operating system's vault. On macOS that's osxkeychain; the token lives in the Keychain. The Git that Apple ships with Xcode already turns it on in its built-in config, and git config get --show-origin credential.helper shows you where it comes from. On Windows the Git for Windows installer brings Git Credential Manager (GCM); GCM opens a GitHub sign-in in the browser on the first push, and you never deal with the token yourself. On Linux you can install GCM or a libsecret-based helper; if you don't want to install anything, the cache helper keeps credentials only in memory, for 900 seconds by default.

A third route is the GitHub CLI. gh isn't installed locally, so I give the commands without output:

# signs in to GitHub in the browser, asks whether to use HTTPS or SSH
$ gh auth login
# shows which account you're signed in with
$ gh auth status
# sets gh up as Git's credential helper
$ gh auth setup-git

gh auth login completes the sign-in in the browser and stores the token itself. With gh auth setup-git, Git asks gh for credentials whenever it goes to GitHub over HTTPS. That gathers creating, copying and tracking the expiry of tokens into a single tool.

Setting Up an SSH Key

On the SSH side there's no password or token, there's a key pair. The private key stays on your computer and you give the public key to GitHub. Think of a lock and key: you can mount the lock (the public key) on any door you like, and the key (the private key) never leaves your pocket. These commands write into your own ~/.ssh folder, so I don't show their output:

# generates an ed25519 key pair, asks for a file path and a passphrase
$ ssh-keygen -t ed25519 -C "ada@example.com"
# starts ssh-agent
$ eval "$(ssh-agent -s)"
# on macOS adds the key to the agent and saves the passphrase in the Keychain
$ ssh-add --apple-use-keychain ~/.ssh/id_ed25519

ssh-keygen first asks for the file path; press Enter and you get ~/.ssh/id_ed25519 and ~/.ssh/id_ed25519.pub. Then it asks for a passphrase. Don't leave it empty: if your laptop is stolen, it's the only thing protecting the private key. If you're dealing with an old system that doesn't support ed25519, GitHub's suggested fallback is ssh-keygen -t rsa -b 4096. On macOS versions older than Monterey you use -K instead of --apple-use-keychain. To avoid adding the key again after every restart on macOS, put this in ~/.ssh/config:

Host github.com
  AddKeysToAgent yes
  UseKeychain yes
  IdentityFile ~/.ssh/id_ed25519

Then you copy the contents of ~/.ssh/id_ed25519.pub and add it as a new key on the SSH and GPG keys page under your GitHub settings. The one ending in .pub. The file without an extension is your private key and gets pasted nowhere. You test the connection with ssh -T git@github.com; on the first connection it asks you to confirm the server's fingerprint, then GitHub greets you by your username and says it doesn't provide shell access. Once you get that answer you can switch the remote to the SSH address and push.

Don't Embed the Token in the URL

In old posts and CI scripts you'll see this shortcut: writing the token straight into the remote address. It works, but now the token sits in plain text in .git/config, shows up in command-line arguments and travels to other machines with your backups. Since Git 2.37 there's a setting that catches this: transfer.credentialsInUrl. Its value can be allow (the default), warn or die:

$ git remote set-url origin https://ada:example-token@github.com/ada/bookstore.git
$ git config set --global transfer.credentialsInUrl die
$ git fetch origin
fatal: URL 'https://ada:<redacted>@github.com/ada/bookstore.git' uses plaintext credentials
$ git remote set-url origin https://github.com/ada/bookstore.git
fatal: URL 'https://ada:<redacted>@github.com/ada/bookstore.git' uses plaintext credentials
$ git config set remote.origin.url https://github.com/ada/bookstore.git
$ git remote -v
origin	https://github.com/ada/bookstore.git (fetch)
origin	https://github.com/ada/bookstore.git (push)

In die mode the fetch stopped without ever touching the network and masked the token as <redacted> in the error message. The interesting part is the fourth command: the git remote set-url you ran to clean the address stopped with the same error, because it reads the existing remote first. The way out is writing the address directly through config: git config set remote.origin.url. Setting this to die globally is a good habit; you catch a script that embeds a token in a URL the first time it runs.

A Bare Repo Instead of GitHub in the Examples

The examples in this series don't really push to GitHub. Instead of the repo on GitHub I use a local bare repo: a repo without a working tree that holds only Git data. That's exactly how your repo is stored on the GitHub server too. Push, fetch and pull behave identically; the only thing that changes is the address:

$ git init --bare ../bookstore.git
Initialized empty Git repository in .../bookstore.git/
$ git remote set-url origin ../bookstore.git
$ git push -u origin main
To ../bookstore.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.
$ git branch -vv
* main 7f7eeec [origin/main] Add cart total

When you push to GitHub you may also see lines starting with remote: under this output, written by GitHub itself. Those are messages from the server; Git's own output is the same as above.

Common Pitfalls

The first pitfall is typing your account password at the password prompt. People assume they mistyped and try three more times. The problem isn't the password; a token goes there.

The second is an expired token. One day push suddenly starts failing with an authentication error, because the helper keeps sending the old token. You need to create a new token and update the entry in the helper: on macOS you delete the github.com entry in the Keychain, and on the next push Git asks for the new token and stores it.

The third is sharing the wrong key. The file you add to GitHub is always the one ending in .pub. If you pasted the private key somewhere, consider it leaked, generate a new pair and remove the old one from GitHub.

The fourth is Permission denied (publickey) over SSH. Usually either the key wasn't added to the agent, or the public half of a different key was added to GitHub. ssh-add -l lists the fingerprints of the keys in the agent; compare them with the fingerprints shown in GitHub's key list.

The fifth is writing the token into the repo: in a URL, in a .env file or in a script. If the token was committed, deleting it isn't enough; it stays in the history. Revoke the token right away and create a new one.

A Small Experiment

★☆☆ Run git config get --show-origin --all credential.helper on your own machine. Which helpers are defined, and which file does each come from? If a repo has several helpers defined, Git tries them in order.

★★☆ In an empty folder create two bare repos; push a single README commit into one of them from a separate clone. Try pushing your local repo first to the empty one, then to the populated one, and compare the two outputs. On the populated one, merge with --allow-unrelated-histories and resolve the add/add conflict.

★★★ In a scratch repo set transfer.credentialsInUrl to warn. Write the remote address so it points to the bare repo with a fake token embedded, as in file://ada:example-token@/full/path/bookstore.git, then run git fetch. Does the fetch continue after the warning? Next, write the same address as remote.origin.pushurl and try git push. Why is there no warning? Look for the answer in git help config.

You can talk to GitHub now. In the next post we turn that connection into teamwork: creating a branch and pushing commits, opening a pull request, answering review with new pushes, choosing between GitHub's three merge methods and keeping a fork in sync with upstream in open source.