Git & GitHub Module 5 – GitHub and remotes
Set up GitHub: account, 2FA and SSH keys
Create a GitHub account, protect it with two-factor authentication, connect with an Ed25519 SSH key, check GitHub's host key and keep tokens out of URLs.
What you will learn
- Create a GitHub account, secure it with two-factor authentication and keep your email address private in commits
- Generate an Ed25519 SSH key, load it into the agent and add the public key to GitHub
- Verify GitHub's host key fingerprint before trusting a first SSH connection
- Use HTTPS with Git Credential Manager or GitHub CLI instead of typing secrets
- Create a fine-grained personal access token with the least access and an expiry date
Before you start
What you’ll learn · Before you start
What you will learn
- Create a GitHub account, secure it with two-factor authentication and keep your email address private in commits
- Generate an Ed25519 SSH key, load it into the agent and add the public key to GitHub
- Verify GitHub's host key fingerprint before trusting a first SSH connection
- Use HTTPS with Git Credential Manager or GitHub CLI instead of typing secrets
- Create a fine-grained personal access token with the least access and an expiry date
Before you start
On this page
An account that contributes code on GitHub has 45 days from GitHub’s notice, and a 7-day grace period after them, to
turn on two-factor authentication (2FA): Signing in with two different things: something you know, your password, and something you have, such as an authenticator app, a passkey or a security key. A stolen password alone then opens nothing. before it is locked out of github.com.
After that, every git push has to prove two things: that the server really is github.com, and that the person
pushing is you. SSH proves both with keys: GitHub’s host key: The key pair that identifies an SSH server. On every connection the server proves it holds the private half, and ssh compares the public half with the copy stored in known_hosts, or asks you to check its fingerprint the first time., which you check against a
published key fingerprint: A short digest of a public key, such as SHA256: followed by 43 characters, used to compare keys by eye. Any change to the key gives a completely different fingerprint., and a key pair of your own. HTTPS proves you with a token that
a credential helper: A program that Git asks for a user name and password when an HTTPS remote needs them. It keeps what it gets, such as a token, in a store, and erases it when the server refuses it. Git Credential Manager and GitHub CLI are two. keeps for you, so you never type or paste it.
You will protect the account, make an Ed25519 key, check GitHub’s host key before the first yes, get past a blocked
port 22, set up HTTPS without typing a secret, and see where a token in a URL ends up. The sessions ran with OpenSSH
10.3 and Git 2.54.0 in throwaway folders and never contact GitHub; what only a real connection shows is quoted from
GitHub’s documentation.
Two-factor authentication and a noreply address protect the account
Sign up on github.com with an email address you will keep, and pick your username with care: it appears in the
address of every repository you own, github.com/USERNAME/REPOSITORY. A new personal account starts with unlimited
public and private repositories (private ones with a smaller set of features), 2,000 minutes a month of GitHub
Actions, 120 GitHub Codespaces core hours a month and GitHub Pages for public repositories, according to GitHub’s
plans page.
Keep your email address private. Every commit records an email address, and anyone can read it from a public
repository. In your account’s email settings, turn on “Keep my email address private”. GitHub then gives you a
noreply address of the form ID+USERNAME@users.noreply.github.com, where ID is a number of your account, and uses
it for commits you make on the website. Set the same address for commits from your computer with
git config --global user.email "ID+USERNAME@users.noreply.github.com", using the exact address your email settings
show.
Turn on two-factor authentication before you push anything. GitHub requires it for accounts that contribute code, and gives such an account a fixed time to enrol. Walk through the phases, from GitHub’s email to a locked account:
- The notice
An email starts the enrolment period 45 days
GitHub emails an account that contributes code, and that email marks the beginning of a 45-day period in which to turn on two-factor authentication. Banners on github.com ask for it as well.
- Enrol
An authenticator app first, then a backup
Set up a time-based one-time password (TOTP) app as the main method, then add a passkey or a security key as a backup; GitHub Mobile is a good backup too. Save the 16 recovery codes: each one gets you in once, so keep them in a password manager, not beside your phone.
- Grace period
Seven more days 7 days
After the 45 days comes a grace period of 7 days. Letting it run out without two-factor authentication is what locks the account.
- Locked
No github.com until two-factor is on
A locked account cannot use github.com until two-factor authentication is set up, and every visit asks for it. Tokens it already has keep working, but it cannot authorise new apps or create new personal access tokens.
The timeline has 4 events, from The notice to Locked.
The enrolment step is where most of the protection is decided. GitHub’s guidance on the methods comes down to this:
| Method | Use it as | Why |
|---|---|---|
| A time-based one-time password (TOTP) app | The main method | GitHub recommends it as the primary method |
| A passkey or a security key | A backup | GitHub does not take them as the primary method yet |
| GitHub Mobile | A backup | A good backup when you have no passkey or security key |
| Text messages (SMS) | Only when nothing else works | Allowed in most countries, with security risks GitHub warns about; not offered where delivery is poor |
| 16 recovery codes | The way back in | Each works once; keep them in a password manager, away from your phone |
The recovery codes matter most on the worst day: when the phone with the app is lost, they and the backup methods are what still let you in, because the password alone is no longer enough.
Checkpoint
Check: Two-factor authentication and the noreply address
Git signs in over SSH with a key, or over HTTPS with a token
Git signs in whenever GitHub needs to know who you are: for every push, for fetches from private repositories, and for
everything over SSH, which always uses your key. The remote’s address decides the way: git@github.com:OWNER/REPO.git
goes over SSH, https://github.com/OWNER/REPO.git over HTTPS. Each proves who you are differently:
| Criterion | SSH key | HTTPS with a helper |
|---|---|---|
| Set up | A key pair; GitHub gets the public key | Git Credential Manager or GitHub CLI, signed in once in the browser |
| Sends | A signature made with your private key | A token the helper keeps for you |
| Secret in | ~/.ssh, behind a passphrase | Your system's credential store |
| Port | 22, or 443 at ssh.github.com | 443 |
| URL | git@github.com:OWNER/REPO.git | https://github.com/OWNER/REPO.git |
| When to choose | Daily work on your own computer | SSH is blocked, or you prefer signing in through the browser |
Both are fine, and many people use SSH on their own laptop and HTTPS with a helper elsewhere. A third way, pasting a
personal access token: A secret that GitHub issues to stand in for your password when Git, a script or a tool signs in over HTTPS. A fine-grained token is limited to chosen repositories and permissions, and should expire. when Git asks, also works, but then the token lives
wherever you pasted it; keep that for scripts that cannot use a helper. What you should not do is type your account
password for Git: GitHub removed password sign-in for Git, and when Git asks for a password over HTTPS, GitHub expects
a personal access token. A clone can change its way at any time with git remote set-url, which the next lesson
shows.
Checkpoint
Check: SSH or HTTPS
An SSH key’s private half never leaves your computer
ssh-keygen -t ed25519 makes a key pair with the Ed25519 algorithm, the type GitHub’s documentation asks for first;
its fallback for systems that lack Ed25519 is ssh-keygen -t rsa -b 4096. -C adds a label, usually your email,
and -f names the file. A new key is random, so the session below shows its random parts as descriptions:
from session import run
# A new key is random, so its fingerprint, its public key and its picture are new on every run: mask= shows them as
# descriptions instead.
NEW_KEY = (
(r"SHA256:[A-Za-z0-9+/]{43}", "SHA256:<43 characters, new for every key>"),
(r"(AAAAC3NzaC1lZDI1NTE5AAAAI)[A-Za-z0-9+/]{43}", r"\1<43 characters, new for every key>"),
(r"^The key's randomart image is:$", "The key's randomart image is: (a small picture of the fingerprint, left out here)"),
(r"^[+|]", None),
)
run("""
ssh -V
mkdir -m 700 ~/.ssh
# -N gives the passphrase here only because nobody can type it into this session. On your computer, leave -N out
# and type the passphrase when ssh-keygen asks, so that it never sits in your shell history.
ssh-keygen -t ed25519 -C "your_email@example.com" -f ~/.ssh/id_ed25519 -N "five random words for this demo"
ls ~/.ssh
cat ~/.ssh/id_ed25519.pub
ssh-keygen -lf ~/.ssh/id_ed25519.pub
""", mask=NEW_KEY) Output
$ ssh -V OpenSSH_10.3p1, LibreSSL 3.3.6 $ mkdir -m 700 ~/.ssh # -N gives the passphrase here only because nobody can type it into this session. On your computer, leave -N out # and type the passphrase when ssh-keygen asks, so that it never sits in your shell history. $ ssh-keygen -t ed25519 -C "your_email@example.com" -f ~/.ssh/id_ed25519 -N "five random words for this demo" Generating public/private ed25519 key pair. Your identification has been saved in ~/.ssh/id_ed25519 Your public key has been saved in ~/.ssh/id_ed25519.pub The key fingerprint is: SHA256:<43 characters, new for every key> your_email@example.com The key's randomart image is: (a small picture of the fingerprint, left out here) $ ls ~/.ssh id_ed25519 id_ed25519.pub $ cat ~/.ssh/id_ed25519.pub ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI<43 characters, new for every key> your_email@example.com $ ssh-keygen -lf ~/.ssh/id_ed25519.pub 256 SHA256:<43 characters, new for every key> your_email@example.com (ED25519)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ssh_key_session.py
ssh-keygen wrote two files. id_ed25519 is the private key: it never leaves your computer, it is never pasted
anywhere, and the passphrase encrypts it on disk, so a copied file cannot be used without the passphrase.
id_ed25519.pub is the public key, the one line you give GitHub. Every Ed25519 public key starts with
AAAAC3NzaC1lZDI1NTE5AAAAI, which is the key type and its length encoded in base64; only the 43 characters after it
are your key. The fingerprint, SHA256: and 43 more characters, is a short digest
of that key, and GitHub shows the same fingerprint next to the key once you have added it.
A passphrase you type every time gets tedious, so let the SSH agent: A program that holds your unlocked SSH private keys while you are signed in, so that ssh can sign with a key without asking for its passphrase every time. remember it while you
are signed in. On Linux and in Git Bash on Windows, eval "$(ssh-agent -s)" starts the agent and
ssh-add ~/.ssh/id_ed25519 loads the key. On macOS, ssh-add --apple-use-keychain ~/.ssh/id_ed25519 loads it and
keeps the passphrase in the keychain, and these lines in ~/.ssh/config make ssh do so on first use:
# Lines for ~/.ssh/config on macOS, as GitHub's documentation gives them: ssh loads the key into the agent on first
# use and keeps its passphrase in the macOS keychain. Leave out UseKeychain if the key has no passphrase.
Host github.com
AddKeysToAgent yes
UseKeychain yes
IdentityFile ~/.ssh/id_ed25519 Then give GitHub the public key: Settings → SSH and GPG keys → New SSH key, a title such as the computer’s name,
key type Authentication Key, and the contents of id_ed25519.pub pasted in. GitHub never needs the private key,
and nobody who helps you does either.
Checkpoint
Check: The SSH key pair and the agent
GitHub’s host key proves the server before your key proves you
Keys work both ways. Your key proves you to GitHub, and GitHub’s host key proves github.com to you, on every connection:
The two checks of every SSH connection to GitHub
Text description of the diagram
The diagram has two columns, one for each check. In each, a box for your computer and a box for github.com are joined by an arrow.
- Left column, GitHub proves itself first: github.com shows its host key, whose fingerprints GitHub publishes in its documentation, and ssh compares it with the key stored in ~/.ssh/known_hosts on your computer. On a first connection nothing is stored yet, so you compare the fingerprint with GitHub's list yourself before you accept it.
- Right column, then you prove yourself: ssh uses your private key, ~/.ssh/id_ed25519, which never leaves your computer, to sign, and GitHub checks the signature with the public key you added in Settings, SSH and GPG keys.
Only the first connection needs you, because ssh has no stored key for github.com yet. Step through it, from the key github.com offers to the greeting, and find the one step where ssh cannot decide for you:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- You connect. You run ssh -T git@github.com to test your key. ssh opens a connection to github.com on port 22.
- GitHub proves its key. github.com sends its Ed25519 host key, with a signature that only the holder of the matching private key can make. ssh checks the signature, so the server holds that key. Whether it is GitHub's key is the next question.
- Nothing stored yet. ssh looks for github.com in ~/.ssh/known_hosts and finds no entry, because this is the first connection. It shows the key's fingerprint and asks you whether to trust it.
- You compare. You compare all 43 characters after SHA256: with the Ed25519 line on GitHub's page of SSH key fingerprints. They match, so you answer yes. A key that is not on the list gets no.
- The key is stored. ssh adds GitHub's key to ~/.ssh/known_hosts and says so in one line. From now on it checks the key on every connection by itself.
- Your key signs. Now you prove who you are. ssh signs with your private key, ~/.ssh/id_ed25519, which never leaves your computer; the passphrase decrypts it, or the agent already holds it decrypted.
- GitHub checks. GitHub checks the signature with the public key you added under SSH and GPG keys, and greets you by your username. It offers no shell, so ssh ends with exit status 1, which is normal for this test.
- Every later connection. Next time ssh compares the key github.com offers with the stored one without asking. A different key stops it with REMOTE HOST IDENTIFICATION HAS CHANGED, and then you check GitHub's page before you change anything.
GitHub’s documentation shows the same exchange like this (not run here, because these sessions never use the network):
$ ssh -T git@github.com
The authenticity of host 'github.com (IP ADDRESS)' can't be established.
ED25519 key fingerprint is SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU.
Are you sure you want to continue connecting (yes/no)? yes
Hi USERNAME! You've successfully authenticated, but GitHub does not provide shell access. Before you type yes, compare the fingerprint with the list on GitHub’s page “GitHub’s SSH key fingerprints”. If they
do not match, type no: you may be talking to a machine that only claims to be github.com. You can make the same
check offline with ssh-keygen. github_known_hosts below holds GitHub’s published Ed25519 host key, and
lookalike_known_hosts the same line with its last character changed:
from session import files, run
# The two known_hosts files of this example's folder: GitHub's published key, and a key that differs in one character.
files("github_known_hosts", "lookalike_known_hosts")
run("""
ssh-keygen -lf github_known_hosts
ssh-keygen -lf lookalike_known_hosts
# Look a host up in a known_hosts file, with the fingerprint of the key stored for it:
ssh-keygen -l -F github.com -f github_known_hosts
""") Output
$ ssh-keygen -lf github_known_hosts 256 SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU github.com (ED25519) $ ssh-keygen -lf lookalike_known_hosts 256 SHA256:Xpui/jdQlpifVdYhm+ZUa03EcWPgy67Z4ikMikUUptc github.com (ED25519) # Look a host up in a known_hosts file, with the fingerprint of the key stored for it: $ ssh-keygen -l -F github.com -f github_known_hosts # Host github.com found: line 2 github.com ED25519 SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 host_key_session.py
The published key’s fingerprint, SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU, matches the Ed25519 entry on
GitHub’s page. One changed character gave SHA256:Xpui/jdQlpifVdYhm+ZUa03EcWPgy67Z4ikMikUUptc, with nothing in common
with it, which is why comparing a few characters at each end is not enough: compare them all.
ssh-keygen -l -F github.com finds the entry stored for a host in a known_hosts file and prints its fingerprint in
the same way.
Once a key is stored, ssh compares it on every connection, and stops with “REMOTE HOST IDENTIFICATION HAS CHANGED!”
when it differs. That has happened for real: GitHub replaced its RSA host key after the private key was briefly
exposed in a public repository, and people who connected with RSA saw the warning, while Ed25519 and ECDSA users saw
nothing. The right response is the same as on day one: check the new fingerprint against GitHub’s page, then remove
the old entry with ssh-keygen -R github.com. A changed key that GitHub has not announced is a reason to stop.
Key idea
Compare the whole fingerprint with GitHub’s published list once, before the first yes; from then on ssh checks the
stored key on every connection and stops when it changes.
Checkpoint
Check: Checking GitHub's host key
SSH still reaches GitHub on port 443 when port 22 is blocked
Some college, office and hotel networks block outgoing connections to port 22, and SSH to github.com then hangs
until it times out. GitHub also accepts SSH on port 443, the port HTTPS uses, at a different host name:
ssh.github.com, not github.com. Test it with ssh -T -p 443 git@ssh.github.com. To use it for every Git command,
add this to ~/.ssh/config:
# Lines for ~/.ssh/config when a network blocks port 22: SSH to GitHub then goes to ssh.github.com on port 443.
Host github.com
Hostname ssh.github.com
Port 443
User git ssh -G prints the settings ssh would use for a host and exits without connecting, so it shows whether the file
does what you meant:
from session import files, run
files("port443_config")
# ssh -G prints the settings ssh would use for a host and exits without connecting (-T: no terminal is needed).
run("""
ssh -T -G -F port443_config github.com | grep -E "^(hostname|port|user) "
ssh -T -G -F /dev/null github.com | grep -E "^(hostname|port|user) "
""", mask=((r"^user (?!git$).*", "user <your user name on this computer>"),)) Output
$ ssh -T -G -F port443_config github.com | grep -E "^(hostname|port|user) " user git hostname ssh.github.com port 443 $ ssh -T -G -F /dev/null github.com | grep -E "^(hostname|port|user) " user <your user name on this computer> hostname github.com port 22
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ssh_config_session.py
With the file, a connection to github.com goes to ssh.github.com on port 443 as user git; without it, to port
22 under your own user name. Your remote URLs keep saying github.com: the Host block changes where ssh connects,
not what Git asks for. The first connection through port 443 may say that the host key was not found or was found
under another name: it is the same key, so check the fingerprint against GitHub’s page as before.
Checkpoint
Check: SSH over port 443
A credential helper signs in once and keeps the token
A credential helper is a program Git asks for a user name and password when an HTTPS remote needs them. The good ones sign you in once, through your browser or a sign-in window and with your two-factor authentication, and keep the resulting token in your system’s credential store. Step through two pushes with one, and a third after the token has been revoked:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The push needs a sign-in. git push sends its first request to https://github.com/OWNER/REPO.git, and GitHub answers that it needs credentials. Git does not prompt you: it asks the credential helper for a user name and password for github.com.
- Nothing stored yet. The helper has nothing stored for github.com, so it opens a sign-in window or your browser. You sign in there once, with your password and your two-factor method, never in the terminal.
- A token for the helper. GitHub gives the helper a token, and the helper hands it to Git in place of a password. The token never appears on your screen.
- The push goes through. Git repeats the request with the token, and GitHub accepts the push. Git then tells the helper that the credentials worked, and the helper keeps the token in the system's credential store, such as the macOS keychain.
- The next push. The next time Git needs credentials for github.com, the helper answers from the store at once. No window opens and nothing is typed.
- A token that stops working. When GitHub refuses the token, because it was revoked or has expired, Git tells the helper to erase it. The next push signs in through the browser again and stores a new one.
Two helpers cover most setups:
- Git Credential Manager (GCM, MIT licence) comes with Git for Windows; on macOS it installs with
brew install --cask git-credential-manager, and on Linux as a .NET tool or a package, followed bygit-credential-manager configure. Git calls it on its own when it needs credentials for an HTTPS remote. - GitHub CLI:
gh auth loginsigns in through a web browser by default and stores a token in the system credential store, falling back to a plain-text file only when there is none.gh auth setup-gitthen makes Git use gh as its credential helper.
Either way, the token’s whole life happens between the helper and GitHub, as the figure showed: you never see it, and when you revoke it, the helper erases its copy at the next refused push and signs you in again.
Checkpoint
Check: Credential helpers
A fine-grained token opens only what one job needs
When you do need a personal access token, for a script or a tool that cannot use a helper, GitHub recommends fine-grained tokens over classic ones. Create one under Settings → Developer settings → Personal access tokens → Fine-grained tokens. For a script that writes to one repository of yours, fill the form in like this:
| Field | Choose | Why |
|---|---|---|
| Token name | What it is for, such as notes backup script |
You will know which one to revoke |
| Expiration | A date just after the job ends | GitHub highly recommends an expiry |
| Resource owner | Your account | The token reaches only what this owner owns |
| Repository access | Only select repositories, then the one repository | Every other repository stays closed |
| Permissions | Contents: read and write | Only what the script needs |
Every token can also read all public repositories, whatever you select. GitHub removes tokens that have not been used for a year, and a few things still need a classic token, such as writing to a public repository that neither you nor an organisation you belong to owns; for those, use a credential helper or SSH rather than a broad classic token.
Checkpoint
Check: Fine-grained tokens
A token in a URL leaks, and a leaked one is revoked first
It is tempting to put a token straight into the remote URL so that Git never asks. This session shows where it then ends up, using a token that is made up:
from session import run
# The token in this example is made up: it matches no real token format and opens nothing.
run("""
git init -q notes
cd notes
git remote add origin https://your-username:MADE-UP-TOKEN-FOR-THIS-DEMO@github.com/your-username/notes.git
git remote -v
git config --show-origin --get remote.origin.url
# The fix: remove the token from the URL (then revoke the token on GitHub):
git remote set-url origin https://github.com/your-username/notes.git
git config --show-origin --get remote.origin.url
""") Output
$ git init -q notes $ cd notes $ git remote add origin https://your-username:MADE-UP-TOKEN-FOR-THIS-DEMO@github.com/your-username/notes.git $ git remote -v origin https://your-username:MADE-UP-TOKEN-FOR-THIS-DEMO@github.com/your-username/notes.git (fetch) origin https://your-username:MADE-UP-TOKEN-FOR-THIS-DEMO@github.com/your-username/notes.git (push) $ git config --show-origin --get remote.origin.url file:.git/config https://your-username:MADE-UP-TOKEN-FOR-THIS-DEMO@github.com/your-username/notes.git # The fix: remove the token from the URL (then revoke the token on GitHub): $ git remote set-url origin https://github.com/your-username/notes.git $ git config --show-origin --get remote.origin.url file:.git/config https://github.com/your-username/notes.git
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 token_url_session.py
git remote add stored the whole URL, token included, in .git/config as plain text, and git remote -v prints it
to anyone looking at your screen, a screenshot, a recorded demo or a build log. The command also sits in your shell
history, and the token travels with every copy of the folder you zip and send to someone. git remote set-url
removes it from the configuration, but the token must be treated as known: revoke it on GitHub, then let a credential
helper sign you in instead.
The same holds for a token, a password or a private key pasted into a chat, committed or shown in a screenshot: treat it as compromised. The OWASP Secrets Management Cheat Sheet puts revocation first: exposed keys should be revoked immediately, a replacement issued, and only then the exposed value removed from where it leaked. Step through that order with GitHub’s own settings:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Exposed means known. A token pasted into a chat, committed, shown in a screenshot or written into a remote URL may already have been copied. From that moment treat it as known to someone else.
- Revoke first. Delete the token, or the SSH key, in your GitHub settings. Until then anyone who copied it can use it, and nothing you clean up later takes that copy back.
- Look for misuse. Open Settings, Archives, Security log. It lists the last 90 days of actions on your account, the token and SSH key events among them. Look for anything you do not recognise.
- A replacement. Create a new token with only the repositories and permissions the job needs, and an expiry. Better still, let a credential helper hold it, so nobody handles it by hand again.
- Clean up last. Only now remove the value from the chat, the file, the URL or the history. Deleting the message first changes nothing for whoever already copied the token.
Key idea
A token belongs in a credential helper’s store, never in a URL, a file or a chat, and one that has been seen is revoked before anything else is cleaned up.
Checkpoint
Check: Tokens in URLs and leaked secrets
Practise: check a host key by its fingerprint
The exercise turns the check you make by eye into code: compute a key’s fingerprint from a known_hosts line and
look it up in a published list. The sample tests use GitHub’s key and the lookalike of this lesson:
Exercise · Easy · Python
Compute an SSH key fingerprint and check it against a published list
Write two functions in fingerprint.py, so that a program can check a host key the way you check one by eye.
fingerprint(line) takes one line of a known_hosts file (github.com ssh-ed25519 AAAA…) or of a .pub file (ssh-ed25519 AAAA… comment) and returns the key's SHA256 fingerprint exactly as ssh-keygen -l prints it: SHA256: followed by the base64 of the SHA-256 digest of the decoded key, without the = padding. The key is the word after the key type, which starts with ssh-, ecdsa- or sk-. A line with no key on it (a comment, a blank line, a key type with nothing after it) raises ValueError.
is_published(line, published) returns True when the key on the line has one of the fingerprints in published, a list of strings such as the ones GitHub publishes.
- For GitHub's Ed25519 line,
fingerprintreturnsSHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU. - For the lookalike line of this lesson, it returns
SHA256:Xpui/jdQlpifVdYhm+ZUa03EcWPgy67Z4ikMikUUptc, andis_publishedreturnsFalsefor it.
The sample tests also try a .pub line, a line that names a host twice, and lines that hold no key.
Starter code · fingerprint.py
import base64
import hashlib
def fingerprint(line):
"""Return the SHA256 fingerprint of the key on a known_hosts or .pub line, as ssh-keygen -l prints it."""
# Replace this line with your code.
return "SHA256:"
def is_published(line, published):
"""Return True when the key on the line has one of the fingerprints in `published`."""
# Replace this line with your code.
return True The sample tests · test_fingerprint.py
from fingerprint import fingerprint, is_published
GITHUB = "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl"
LOOKALIKE = "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJm"
# The fingerprints GitHub publishes for its RSA, ECDSA and Ed25519 host keys.
PUBLISHED = [
"SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s",
"SHA256:p2QAMXNIC1TJYWeIOttrVc98/R1BUFWu3/LiyKgUfQM",
"SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU",
]
def test_github_key():
"""computes the fingerprint of GitHub's published Ed25519 key"""
assert fingerprint(GITHUB) == "SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU"
def test_one_character_changes_everything():
"""gives a completely different fingerprint for a key that differs in one character"""
assert fingerprint(LOOKALIKE) == "SHA256:Xpui/jdQlpifVdYhm+ZUa03EcWPgy67Z4ikMikUUptc"
def test_pub_line_and_two_host_names():
"""reads a .pub line (key type first) and a line that names the host twice"""
pub = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl laptop key"
two = "github.com,ssh.github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl"
assert fingerprint(pub) == fingerprint(GITHUB)
assert fingerprint(two) == fingerprint(GITHUB)
def test_published_or_not():
"""accepts only a key whose fingerprint is on the list"""
assert is_published(GITHUB, PUBLISHED) is True
assert is_published(LOOKALIKE, PUBLISHED) is False
assert is_published(GITHUB, []) is False
def test_lines_without_a_key():
"""raises ValueError for a comment, a blank line and a key type with no key after it"""
for line in ("# GitHub's keys", "", "github.com ssh-ed25519"):
try:
fingerprint(line)
except ValueError:
continue
assert False, f"no ValueError for {line!r}" A hint
Split the line into words and find the first word that starts with one of the key-type prefixes; the key is the word after it. base64.b64decode turns the key into bytes, hashlib.sha256(...).digest() gives the 32-byte digest, and base64.b64encode(...).decode() turns that back into text, from which .rstrip("=") removes the padding.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Each session above is a short Python script that types its commands through the helper below. Save the two files in
one folder and start one, say python3 ssh_key_session.py: the key it makes lands in a temporary home folder that is
deleted afterwards, so your own ~/.ssh is never read or changed.
"""Shows a terminal session with Git: each line of a script is typed at a prompt (after "$"), and what the command
printed appears under it.
Every session starts in a new, empty folder, shown as ~/demo, with a home folder of its own, so your own Git settings
take no part: its ~/.gitconfig holds only a name, an email address and init.defaultBranch = main. Standard output and
standard error appear together, as in a terminal, and a command that fails adds a line with its exit status, after
which the session goes on. "cd <folder>" changes the folder of the commands that follow, as in a shell; a line that
starts with "#" is printed as a comment. Nothing uses the network: the "remote" repositories are folders.
So that every run prints the same text:
* each command runs one minute after the one before (GIT_AUTHOR_DATE and GIT_COMMITTER_DATE), so the commit IDs
are the same on every run (yours will differ, because your commits carry your own times);
* the session's folder is shown as ~/demo and its home folder as ~, and spaces at the ends of lines are dropped;
* mask= rewrites text that is new on every run, such as a freshly made key, as a description of it.
On a Mac, the Command Line Tools' own git is used directly when it is there (the /usr/bin/git launcher in front of it
can print cache warnings when it runs without a writable home folder).
"""
import atexit
import os
import re
import shlex
import shutil
import subprocess
import tempfile
# The example's own folder (files() copies from it) and the session's folders, which are removed at the end.
HERE = os.getcwd()
_BASE = os.path.realpath(tempfile.mkdtemp(prefix="git-session-"))
atexit.register(shutil.rmtree, _BASE, ignore_errors=True)
ROOT = os.path.join(_BASE, "demo")
HOME = os.path.join(_BASE, "home")
for folder in (ROOT, HOME, os.path.join(_BASE, "tmp")):
os.makedirs(folder)
with open(os.path.join(HOME, ".gitconfig"), "w", encoding="utf-8") as f:
f.write("[user]\n\tname = Learner\n\temail = learner@example.com\n[init]\n\tdefaultBranch = main\n")
_MAC_GIT = "/Library/Developer/CommandLineTools/usr/bin"
_path = os.environ.get("PATH", "/usr/bin:/bin")
if os.path.exists(os.path.join(_MAC_GIT, "git")):
_path = _MAC_GIT + os.pathsep + _path
ENV = {
"PATH": _path,
"HOME": HOME,
"XDG_CONFIG_HOME": os.path.join(HOME, ".config"),
"TMPDIR": os.path.join(_BASE, "tmp"),
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_TERMINAL_PROMPT": "0",
"LANG": os.environ.get("LANG", "C.UTF-8"),
"LC_ALL": os.environ.get("LC_ALL", os.environ.get("LANG", "C.UTF-8")),
"TZ": "UTC",
}
# A fixed clock: the first command runs at this second (India Standard Time), each later one a minute after.
_state = {"clock": 1767240000, "cwd": ROOT}
def _tidy(line: str) -> str:
"""The line with the session's folders written the way the lesson shows them."""
for real, shown in ((ROOT, "~/demo"), (HOME, "~"), (_BASE, "…")):
line = line.replace(real, shown)
return line.rstrip()
def files(*names: str) -> None:
"""Copies files of the example's folder into ~/demo, the folder the session starts in."""
for name in names:
shutil.copy(os.path.join(HERE, name), os.path.join(ROOT, name))
def setup(script: str) -> None:
"""Runs the commands of `script` without showing them (the lesson shows them in the example's code); a command that
fails stops the example."""
run(script, show=False)
def run(script: str, mask: tuple = (), show: bool = True) -> None:
"""Types each line of `script` at the prompt and prints what it printed.
mask: (pattern, replacement) pairs applied to each line of output; a replacement of None leaves the line out.
"""
for command in script.strip("\n").splitlines():
command = command.strip()
if not command or command.startswith("#"):
if show:
print(command)
continue
if show:
print(f"$ {command}")
words = shlex.split(command)
if words[0] == "cd" and len(words) == 2:
where = HOME + words[1][1:] if words[1].startswith("~") else words[1]
target = os.path.normpath(os.path.join(_state["cwd"], where))
if os.path.isdir(target):
_state["cwd"] = target
elif show:
print(f"cd: no such file or directory: {words[1]}")
print("[exit status 1]")
else:
raise SystemExit(f"set-up command failed: {command}")
continue
_state["clock"] += 60
stamp = f"{_state['clock']} +0530"
env = {**ENV, "GIT_AUTHOR_DATE": stamp, "GIT_COMMITTER_DATE": stamp}
result = subprocess.run(command, shell=True, cwd=_state["cwd"], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30)
if not show:
if result.returncode != 0:
raise SystemExit(f"set-up command failed: {command}\n{_tidy(result.stdout)}")
continue
for line in result.stdout.splitlines():
line = _tidy(line)
for pattern, replacement in mask:
if replacement is not None:
line = re.sub(pattern, replacement, line)
elif re.search(pattern, line):
break
else:
print(line)
if result.returncode != 0:
print(f"[exit status {result.returncode}]") Then test yourself on the eight ideas of this lesson, from the account to a leaked token. Every answer is explained at once, and the questions keep coming back to the idea you have missed most:
Practice that adapts to you
Practise this lesson
19 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): Why should you check a host key fingerprint before answering “yes” to a first SSH connection to GitHub?
Because that “yes” is the only moment ssh cannot check the server for you. On a first connection there is no stored key for github.com, so ssh shows the fingerprint of whatever key the server offered and asks whether to trust it. If an attacker on the network were answering instead of GitHub, accepting their key would let them see and change your Git traffic, and ssh would accept that key quietly from then on, because it is now the stored one. Comparing the fingerprint with the list GitHub publishes on its documentation site, all 43 characters, turns the blind trust into a check. After that, ssh compares the stored key on every connection and warns loudly when it changes, as it did for RSA users when GitHub replaced that key.
Two harder questions start from this lesson: a token pasted into a busy team chat, and a network that blocks port 22.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A teammate pasted a fine-grained personal access token with write access to your team's main repository into a busy team chat ten minutes ago. Walk through what you do in the next fifteen minutes, in order, and what you change so that it cannot happen again.
-
Fresher to mid
On a college network, git push over SSH hangs for about a minute and then fails, while github.com opens normally in the browser. How do you confirm what is wrong, and how do you keep using your SSH key on that network?
Key takeaways
- An account that contributes code must turn on two-factor authentication within GitHub’s deadline: a TOTP app first, a passkey or a security key as a backup, and the 16 recovery codes kept in a password manager.
- Your noreply address,
ID+USERNAME@users.noreply.github.com, set asuser.email, keeps your real address out of your commits. ssh-keygen -t ed25519makes a key pair whose private half stays on your computer behind a passphrase that the agent remembers, and GitHub gets only the.pubfile.- Before the first
yes, the whole of GitHub’s host key fingerprint is compared with the list GitHub publishes, and when port 22 is blocked, SSH goes tossh.github.comon port 443. - Over HTTPS, Git Credential Manager or GitHub CLI signs you in and keeps the token, and a script that needs a token of its own gets a fine-grained one, limited to one repository, with an expiry.
- A token never goes into a remote URL, and a secret that has been exposed is revoked before anything else is cleaned up.
Terms in this lesson
- credential helper
- A program that Git asks for a user name and password when an HTTPS remote needs them. It keeps what it gets, such as a token, in a store, and erases it when the server refuses it. Git Credential Manager and GitHub CLI are two.
- host key
- The key pair that identifies an SSH server. On every connection the server proves it holds the private half, and ssh compares the public half with the copy stored in known_hosts, or asks you to check its fingerprint the first time.
- key fingerprint
- A short digest of a public key, such as SHA256: followed by 43 characters, used to compare keys by eye. Any change to the key gives a completely different fingerprint.
- personal access token
- A secret that GitHub issues to stand in for your password when Git, a script or a tool signs in over HTTPS. A fine-grained token is limited to chosen repositories and permissions, and should expire.
- SSH agent
- A program that holds your unlocked SSH private keys while you are signed in, so that ssh can sign with a key without asking for its passphrase every time.
- two-factor authentication (2FA)
- Signing in with two different things: something you know, your password, and something you have, such as an authenticator app, a passkey or a security key. A stolen password alone then opens nothing.
References
- About mandatory two-factor authentication (GitHub)
- Configuring two-factor authentication recovery methods (GitHub)
- Email addresses reference (noreply addresses) (GitHub)
- Setting your commit email address (GitHub)
- About authentication to GitHub (password sign-in for Git removed) (GitHub)
- Generating a new SSH key and adding it to the ssh-agent (GitHub)
- Working with SSH key passphrases (ssh-agent on Git for Windows) (GitHub)
- Testing your SSH connection (GitHub)
- GitHub's SSH key fingerprints (GitHub)
- Using SSH over the HTTPS port (GitHub)
- We updated our RSA SSH host key (GitHub)
- Managing your personal access tokens (GitHub)
- Your account's security log (GitHub)
- gitcredentials: providing usernames and passwords to Git (Git project)
- git-credential documentation (how Git asks helpers for credentials) (Git project)
- Git Credential Manager (README and installation) (Git Credential Manager project)
- Installing Git Credential Manager (Git Credential Manager project)
- gh auth login (GitHub)
- gh auth setup-git (GitHub)
- GitHub's plans (GitHub)
- Secrets Management Cheat Sheet (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress