An SSH key is a credential that does not expire, is stored as a file, and — unless you took a specific step at creation time — can be used by anything that can read that file.
That combination is why SSH keys are simultaneously the most convenient way to authenticate to Git and one of the most under-managed credentials in most organisations.
The short answer
Section titled “The short answer”{/* Generate a modern key with a passphrase, labelled so you know what it is for */}ssh-keygen -t ed25519 -C "you@example.com - laptop - 2026-09"Then:
- Set a passphrase. It is the only thing protecting the key if the file is copied.
- Load it into
ssh-agentso you type the passphrase once per session. - Upload the public half — the
.pubfile — to GitHub. Never the other one. - Register it separately as a signing key if you also want to sign commits with it.
- Verify the host fingerprint the first time you connect.
- Rotate it on a schedule, and immediately if the machine holding it is ever in doubt.
Ed25519 is the current recommendation. RSA at 4096 bits is the fallback for systems that cannot do Ed25519. DSA is unsupported and has been for years.
The architecture, briefly
Section titled “The architecture, briefly”An SSH key pair is two mathematically related files:
| File | Contains | Where it goes |
|---|---|---|
id_ed25519 | The private key | Stays on the machine, always |
id_ed25519.pub | The public key | Uploaded to GitHub, safe to share |
Authentication works by challenge and response. The server sends a challenge; your client signs it with the private key; the server verifies with the public key it holds. The private key never crosses the network, which is the entire point and the reason SSH is a better shape than a password.
The corollary is what makes key hygiene matter: possession of the private key file is the identity. There is no second factor, no session, and nothing to expire.
Key types GitHub supports
Section titled “Key types GitHub supports”| Type | Command | Notes |
|---|---|---|
| Ed25519 | ssh-keygen -t ed25519 | Current recommendation. Short keys, fast, well-supported |
| RSA 4096 | ssh-keygen -t rsa -b 4096 | Fallback where Ed25519 is unavailable |
| Ed25519-SK | ssh-keygen -t ed25519-sk | Backed by a hardware security key |
| ECDSA-SK | ssh-keygen -t ecdsa-sk | Hardware-backed fallback where Ed25519-SK is unsupported |
| DSA | — | No longer supported; new DSA keys cannot be added |
Two details worth knowing. RSA keys generated after 2 November 2021 must use a SHA-2 signature
algorithm — older clients that only offer ssh-rsa with SHA-1 will fail, and the fix is updating the
client, not downgrading the key. And Ed25519 keys have no size parameter: -b is meaningless for
them, because the size is fixed by the algorithm.
Generating a key properly
Section titled “Generating a key properly”ssh-keygen -t ed25519 -C "you@example.com - laptop - 2026-09"What it doesCreates an Ed25519 key pair, prompting for a file location and a passphrase.
Why we run itEd25519 is GitHub's current recommendation, and the comment is what lets you identify the key later on the account page.
Expected resultTwo files created, plus a fingerprint and randomart image printed.
The comment matters more than it looks. GitHub shows it on the SSH keys page, and an account with
five keys all commented you@example.com is an account where nobody can safely delete any of them.
Include the machine and the date.
The passphrase is not optional
Section titled “The passphrase is not optional”When prompted, set one. The reasoning is short:
Without a passphrase, the private key file is the credential in plaintext. Anything that can read
~/.ssh/ — a backup process, a misconfigured sync client, a malicious dependency in a project you
ran, another user on a shared machine — has your Git access.
With a passphrase, the file is encrypted at rest. Copying it is not sufficient; the attacker also needs the passphrase.
The usual objection is typing it constantly. That is what ssh-agent is for, and it means once per
session rather than once per push.
eval "$(ssh-agent -s)"ssh-add ~/.ssh/id_ed25519What it doesStarts the agent in the current shell and loads the key into it.
Why we run itThe agent holds the decrypted key in memory so the passphrase is entered once, not per operation.
Expected resultIdentity added: followed by the key path and comment.
On macOS, ssh-add --apple-use-keychain stores the passphrase in the system keychain so it survives
reboots. On Linux, most desktop environments run an agent automatically. On Windows, the OpenSSH
Authentication Agent service does the same job.
A useful habit for keys with real access: give the agent a lifetime, so a forgotten unlocked laptop does not stay unlocked indefinitely.
{/* Forget the key after one hour */}ssh-add -t 3600 ~/.ssh/id_ed25519Registering the key on GitHub
Section titled “Registering the key on GitHub”Upload the public key — the .pub file. Print it and copy it:
cat ~/.ssh/id_ed25519.pubThe output is a single line beginning ssh-ed25519. If what you are looking at begins
-----BEGIN OPENSSH PRIVATE KEY-----, stop: that is the private key, and it must not leave the
machine.
GitHub keys have a type:
- Authentication key — lets the key clone, fetch and push
- Signing key — lets signatures made with the key verify
They are separate entries and the same key can be registered as both. The most common confusion in this area is a correctly signed commit showing as Unverified because the key was only ever added for authentication. See Signed Git Commits.
ssh -T git@github.comWhat it doesOpens an SSH connection to GitHub, which reports the authenticated account and closes.
Why we run itIt confirms the key is registered and working, without needing a repository.
Expected resultHi USERNAME! You've successfully authenticated, but GitHub does not provide shell access.
Host verification
Section titled “Host verification”Everything above authenticates you to GitHub. Host verification is the other direction: confirming that the server you reached is GitHub and not something between you and it.
The first connection to a new host prints a fingerprint and asks you to accept it. That prompt is the
only point at which the check happens, and answering yes without reading it is the most commonly
skipped security step in all of SSH.
GitHub’s current host key fingerprints, read from github.com on 1 September 2026:
SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s (RSA)SHA256:p2QAMXNIC1TJYWeIOttrVc98/R1BUFWu3/LiyKgUfQM (ECDSA)SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU (ED25519)Compare rather than trust — GitHub publishes these, and they have been rotated before.
ssh-keyscan -t rsa,ecdsa,ed25519 github.com 2>/dev/null | ssh-keygen -lf -What it doesFetches the host keys github.com currently presents and prints their fingerprints.
Why we run itIt lets you compare against GitHub's published list before accepting a key, rather than after.
Expected resultOne line per key type, each with a SHA256 fingerprint.
Accepted keys are recorded in ~/.ssh/known_hosts. If a host key ever changes unexpectedly, SSH
refuses to connect with a loud warning — and the correct response is to find out why, not to delete
the entry. Removing the line to make the error go away is exactly what an interception attack needs
you to do.
Multiple accounts on one machine
Section titled “Multiple accounts on one machine”A common and quietly risky situation: a work account and a personal account, both on github.com,
where the hostname alone cannot disambiguate.
The clean answer is host aliases in ~/.ssh/config:
Host github-work HostName github.com User git IdentityFile ~/.ssh/id_ed25519_work IdentitiesOnly yes
Host github-personal HostName github.com User git IdentityFile ~/.ssh/id_ed25519_personal IdentitiesOnly yesThen clone with the alias in place of the hostname:
git clone git@github-work:YOUR_ORG/YOUR_REPO.gitIdentitiesOnly yes is the load-bearing line. Without it, SSH offers every key the agent holds until
one is accepted — which means your personal key may authenticate a work repository, quietly, and the
commits will be attributed accordingly. It also means every key you hold is offered to every host you
connect to, which is more disclosure than necessary.
Deploy keys
Section titled “Deploy keys”A deploy key is an SSH key registered to a single repository rather than to an account.
That scoping is genuinely useful: a server that needs to clone one repository gets access to exactly that repository, and a leak does not reach anything else.
Two things to get right:
Read-only by default. The write checkbox is per key. Most deployment use cases only need read.
They belong to nobody. A deploy key is not covered by any person’s offboarding, appears in no team, and expires never. Deploy keys added for one-off migrations and left in place are a recurring finding in access reviews.
For anything ongoing, a GitHub App installation is the better instrument: scoped permissions across chosen repositories, short-lived tokens issued per use, and a clear owner. See GitHub Apps and Least-Privilege GitHub Access.
SSH keys and CI
Section titled “SSH keys and CI”The short version: do not put an SSH private key in CI if you can avoid it.
A private key stored as a repository secret is a long-lived credential sitting in a system that runs code from pull requests. It does not expire, and if it leaks there is nothing that will tell you it is being used.
The alternatives, in descending order of preference:
- Nothing at all.
GITHUB_TOKENalready authenticates the workflow to its own repository. Most jobs that people add a deploy key for do not need one. - A GitHub App token, minted per run, scoped to the repositories the job needs, expiring in an hour.
- A read-only deploy key, if the job genuinely needs cross-repository read and an App is not available.
- A read-write deploy key — only when something must push, and with the understanding that this is a standing credential.
If you do use a key in CI, keep it out of the filesystem and hand it to the agent from the environment, so it is not left behind on a runner:
- name: Load the deploy key into the agent run: | eval "$(ssh-agent -s)" ssh-add - <<< "${DEPLOY_KEY}" echo "SSH_AUTH_SOCK=${SSH_AUTH_SOCK}" >> "$GITHUB_ENV" env: DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}On a self-hosted runner, be aware that anything written to disk may outlive the job. See Secure self-hosted runners.
Knowing what keys exist
Section titled “Knowing what keys exist”You cannot rotate what you have not enumerated, and SSH keys are unusually easy to lose track of because nothing ever reminds you they are there.
On your machines:
{/* Every key file in the standard location, with its type, size and comment */}for f in ~/.ssh/*.pub; do ssh-keygen -lf "$f"; doneThat output is the inventory of what this machine can authenticate as. On a laptop that has been in service for a few years it is routinely longer than the owner expects, and the comments — if there are any — are the only way to tell what each one is for.
Whether a key is passphrase-protected:
{/* Succeeds silently for an unencrypted key; fails for one with a passphrase */}ssh-keygen -y -P "" -f ~/.ssh/id_ed25519 >/dev/null 2>&1 && echo "NO PASSPHRASE" || echo "passphrase set"Any key reported as NO PASSPHRASE that can push to a repository is a file whose theft is a full
compromise of that access.
On the GitHub side: the account settings page lists authentication keys and signing keys, and each repository lists its own deploy keys. GitHub records when an authentication key was last used, which is the closest thing to a usage signal available — a key with no recorded use in a year is a key to remove.
The organisational version of the question is harder, because deploy keys are per repository and personal keys belong to accounts rather than to the organisation. The API is the practical route:
{/* Deploy keys on one repository, with their permissions and creation dates */}gh api /repos/OWNER/REPO/keys --jq '.[] | {id, title, read_only, created_at}'Run that across your repositories and the result is usually the most surprising output in an access review.
The threat model
Section titled “The threat model”Threat. An SSH private key is obtained by someone other than its owner and used to clone, push or sign.
Attack surface. The key file on disk, and every process that can read it. Backups and sync
clients. A running ssh-agent, and any host you forwarded it to. Copies made “temporarily” onto
other machines. Repository secrets holding deploy keys. Anything that has ever had the file, because
nothing about a key changes when it is copied.
Impact. For an account key, everything that account can reach — which for a maintainer is typically write access across an organisation, and therefore code execution in CI. For a deploy key, one repository, which is the entire argument for using them where they fit.
Control. A passphrase, so the file alone is insufficient. A hardware-backed key, so the secret is
not a file at all. Per-machine keys, so a compromise is bounded. IdentitiesOnly, so keys are not
offered indiscriminately. Short agent lifetimes.
Verification. Confirm each key you hold is passphrase-protected using the command above. Confirm you can identify what every key on your GitHub account is for. Confirm the deploy key list on your most sensitive repository contains only entries you can justify.
The step people skip is the second one. A key you cannot identify is a key you cannot safely delete, which is why the comment at generation time is a security control rather than a nicety.
Rotation and revocation
Section titled “Rotation and revocation”Keys do not expire. Nothing prompts you. The only mechanism is deliberate rotation.
A rotation that does not break anything:
-
Generate the new key with a fresh comment recording the machine and date.
-
Add it to GitHub alongside the existing one. Both now work.
-
Update
~/.ssh/configor your agent to use the new key, and confirm withssh -T git@github.com. -
Update anything else that used the old key — other hosts, other services, deploy targets. This is where rotations stall, and it is the argument for one key per purpose.
-
Remove the old key from GitHub. Not before this point.
-
Delete the old private key file, or archive it deliberately if something still needs it — in which case you have not finished rotating.
If a key is exposed
Section titled “If a key is exposed”Speed matters more than tidiness, and the order is:
Remove the public key from GitHub first. That revokes access immediately, and it is one click. Everything else can follow.
Then: generate a replacement, work out what the key could reach, and check whether it was used. For an account key that is every repository the account can see. For a deploy key it is one repository — which is the argument for deploy keys in the first place.
Note the asymmetry with tokens: GitHub can tell you when a token was last used, but an SSH key’s usage is not surfaced the same way. Assume the worst and check what changed.
Common mistakes
Section titled “Common mistakes”No passphrase. The file becomes the credential in plaintext, readable by anything running as you.
Copying a private key between machines. One key per machine per purpose. Copying means a compromise anywhere is a compromise everywhere, and rotation becomes a hunt.
Accepting an unknown host fingerprint. The prompt is the check. Answering yes reflexively skips
it, and deleting a known_hosts line to silence a warning skips it a second time.
Omitting IdentitiesOnly yes with multiple accounts. SSH offers every key it has until one
works, so the wrong identity authenticates and nothing looks wrong.
Leaving deploy keys in place. They belong to no person and survive every offboarding process.
Long-lived keys in CI. A standing credential in a system that runs untrusted code, with no expiry and no usage signal.
Uploading the wrong file. If it starts with -----BEGIN, it is the private key. Rotate
immediately if it left the machine.
Mental model
Section titled “Mental model”An SSH key pair splits a credential into a part you keep and a part you publish. Security comes entirely from the first part staying on one machine, protected by something the file alone does not contain.
What you learned
Section titled “What you learned”- Ed25519 is GitHub’s current recommendation; RSA 4096 is the fallback and DSA is unsupported
- The private key never leaves the machine, and possession of it is the identity
- A passphrase is what makes a copied key file useless;
ssh-agentmakes that practical - An unlocked agent is a live credential, and agent forwarding extends it to the remote host
- Authentication keys and signing keys are separate registrations on GitHub
- Host verification is a real check that happens once, at the fingerprint prompt
IdentitiesOnly yesprevents the wrong key silently authenticating- Deploy keys are repository-scoped and belong to nobody, which is both their value and their risk
- Prefer no credential, then a GitHub App token, over a long-lived key in CI
- Rotation is add-new, switch, verify, remove-old — in that order
Exercise
Section titled “Exercise”Do this on your own machine, with a disposable repository.
-
Generate a key with a passphrase and a descriptive comment. Predict: how many files appear?
-
Run
caton both and identify which is safe to share. -
Add the public key to GitHub as an authentication key. Run
ssh -T git@github.com. -
Run the
ssh-keyscancommand and compare the fingerprints against GitHub’s published list. -
Add a second key and configure two host aliases, one with
IdentitiesOnly yesand one without. Predict: with the wrong key first in the agent, which alias authenticates as which identity? Verify withssh -T. -
Run
ssh -vT git@github.comand read which keys were offered before one was accepted. -
Add a read-only deploy key to a test repository. Predict: can you push with it? Try.
-
Rotate: add a third key, switch to it, verify, then remove the first. Confirm the removed key now fails.
Related lessons
Section titled “Related lessons”Get the repository security checklists — setup, rulesets, tokens — from the Professional Toolkit.