Skip to content

Git SSH Keys: Generation, Protection and Rotation

Lesson 5 of 9Intermediate13 min readGit Security & DevSecOps · Repository SecurityVerified: OpenSSH 9.6p1 and git 2.43.0 on Ubuntu 24.04; GitHub SSH key documentation, September 2026

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.

Terminal window
{/* 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-agent so you type the passphrase once per session.
  • Upload the public half — the .pub file — 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.

An SSH key pair is two mathematically related files:

FileContainsWhere it goes
id_ed25519The private keyStays on the machine, always
id_ed25519.pubThe public keyUploaded 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.

TypeCommandNotes
Ed25519ssh-keygen -t ed25519Current recommendation. Short keys, fast, well-supported
RSA 4096ssh-keygen -t rsa -b 4096Fallback where Ed25519 is unavailable
Ed25519-SKssh-keygen -t ed25519-skBacked by a hardware security key
ECDSA-SKssh-keygen -t ecdsa-skHardware-backed fallback where Ed25519-SK is unsupported
DSANo 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.

Terminal window
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.

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.

Terminal window
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519

What 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.

Terminal window
{/* Forget the key after one hour */}
ssh-add -t 3600 ~/.ssh/id_ed25519

Upload the public key — the .pub file. Print it and copy it:

Terminal window
cat ~/.ssh/id_ed25519.pub

The 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.

Terminal window
ssh -T git@github.com

What 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.

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.

Terminal window
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.

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 yes

Then clone with the alias in place of the hostname:

Terminal window
git clone git@github-work:YOUR_ORG/YOUR_REPO.git

IdentitiesOnly 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.

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.

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:

  1. Nothing at all. GITHUB_TOKEN already authenticates the workflow to its own repository. Most jobs that people add a deploy key for do not need one.
  2. A GitHub App token, minted per run, scoped to the repositories the job needs, expiring in an hour.
  3. A read-only deploy key, if the job genuinely needs cross-repository read and an App is not available.
  4. 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.

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:

Terminal window
{/* Every key file in the standard location, with its type, size and comment */}
for f in ~/.ssh/*.pub; do ssh-keygen -lf "$f"; done

That 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:

Terminal window
{/* 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:

Terminal window
{/* 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.

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.

Keys do not expire. Nothing prompts you. The only mechanism is deliberate rotation.

A rotation that does not break anything:

  1. Generate the new key with a fresh comment recording the machine and date.

  2. Add it to GitHub alongside the existing one. Both now work.

  3. Update ~/.ssh/config or your agent to use the new key, and confirm with ssh -T git@github.com.

  4. 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.

  5. Remove the old key from GitHub. Not before this point.

  6. Delete the old private key file, or archive it deliberately if something still needs it — in which case you have not finished rotating.

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.

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.

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.

  • 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-agent makes 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 yes prevents 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

Do this on your own machine, with a disposable repository.

  1. Generate a key with a passphrase and a descriptive comment. Predict: how many files appear?

  2. Run cat on both and identify which is safe to share.

  3. Add the public key to GitHub as an authentication key. Run ssh -T git@github.com.

  4. Run the ssh-keyscan command and compare the fingerprints against GitHub’s published list.

  5. Add a second key and configure two host aliases, one with IdentitiesOnly yes and one without. Predict: with the wrong key first in the agent, which alias authenticates as which identity? Verify with ssh -T.

  6. Run ssh -vT git@github.com and read which keys were offered before one was accepted.

  7. Add a read-only deploy key to a test repository. Predict: can you push with it? Try.

  8. Rotate: add a third key, switch to it, verify, then remove the first. Confirm the removed key now fails.

GitHub Actions Security ChecklistToken permissions, fork pull requests, script injection and supply chain — with the attack each item prevents.

Get the repository security checklists — setup, rulesets, tokens — from the Professional Toolkit.