Skip to content

SSH Agent and Key Management

SSH is the authentication substrate for git, remote shells, rsync, scp, sftp, tunneling, and most deployment pipelines. Its configuration surface is small but deeply consequential: a misconfigured ~/.ssh/config can leak keys between identities, lock you out of hosts that enforce key-count limits, or silently downgrade a secure connection. This page establishes a configuration that fails loudly rather than silently, minimizes passphrase prompts without weakening security, and cleanly supports multiple identities via host aliases.

Key management philosophy

The framework commits to the following defaults:

  • Ed25519 keys only — smaller, faster, and cryptographically stronger than RSA at any practical key size. Generate RSA only when a legacy server refuses Ed25519 (rare in 2026).
  • One key per identity, not per host — a single work key authenticates you to every work system; a single personal key authenticates you everywhere else.
  • Passphrase on every private key — no exceptions. The passphrase is what makes a leaked ~/.ssh useless to an attacker.
  • Agent over persisted keys — the private key material stays encrypted at rest; the agent holds decrypted material in memory only.
  • IdentitiesOnly yes — without this, SSH offers every key in your agent to every host, which breaks authentication on servers that reject after N failed attempts and leaks key fingerprints to servers that don't need them.
  • No agent forwarding to untrusted hosts — a compromised host with your forwarded agent can sign authentications for any key you have loaded. Use ProxyJump for jump-host scenarios instead.

Generating keys

Generate a separate key for each identity. Use descriptive filenames and meaningful comments — the comment is written into both the public and private key and is the only way to distinguish keys years later.

# Work key
ssh-keygen -t ed25519 \
  -C "dev@springbig.com (work, MBP 16\" M4, 2026-04)" \
  -f ~/.ssh/id_ed25519_work

# Personal key
ssh-keygen -t ed25519 \
  -C "you@billwoika.com (personal, MBP 16\" M4, 2026-04)" \
  -f ~/.ssh/id_ed25519_personal

# Permissions must be strict; SSH refuses to use keys with loose perms
chmod 600 ~/.ssh/id_ed25519_*
chmod 644 ~/.ssh/id_ed25519_*.pub
chmod 700 ~/.ssh

Why the comment format matters

The comment travels with the key. When you look at authorized_keys on a server two years from now trying to decide which lines are stale, the you@billwoika.com (personal, MBP 16" M4, 2026-04) format tells you: which identity, which machine, when the key was generated. Never use the default comment (user@hostname at generation time).

Registering public keys

The .pub file contents are what you paste into GitHub (Settings > SSH and GPG keys > New SSH key), GitLab, or a server's ~/.ssh/authorized_keys. For GitHub, add the key twice if you use it for both authentication and signing — once with the "Authentication Key" type, once with "Signing Key."

# Copy public key to clipboard (macOS)
pbcopy < ~/.ssh/id_ed25519_work.pub

# Copy to a remote server in one step
ssh-copy-id -i ~/.ssh/id_ed25519_work.pub user@host

The ~/.ssh/config file

The SSH config is where host aliases, identity scoping, and connection options live. The framework's reference config follows a first-match-wins pattern: specific host blocks come before the general Host * block.

# ── GitHub multi-identity host aliases ────────────────────────
Host github.com-work
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_work
    IdentitiesOnly yes
    AddKeysToAgent yes
    UseKeychain yes           # macOS: store passphrase in Keychain

Host github.com-personal
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_personal
    IdentitiesOnly yes
    AddKeysToAgent yes
    UseKeychain yes

# ── Work infrastructure ──────────────────────────────────────
Host bastion
    HostName bastion.springbig.internal
    User your-username
    IdentityFile ~/.ssh/id_ed25519_work
    IdentitiesOnly yes

# Internal hosts via bastion — no agent forwarding
Host *.springbig.internal
    User your-username
    IdentityFile ~/.ssh/id_ed25519_work
    IdentitiesOnly yes
    ProxyJump bastion         # Jump through bastion; key stays local

# ── Global defaults (must come LAST) ─────────────────────────
Host *
    IdentitiesOnly yes
    ServerAliveInterval 60
    ServerAliveCountMax 3
    TCPKeepAlive yes
    ControlMaster auto
    ControlPath ~/.ssh/control/%C
    ControlPersist 10m
    HashKnownHosts yes
    StrictHostKeyChecking accept-new
    UpdateHostKeys yes
    UseKeychain yes
    AddKeysToAgent yes
    IdentityFile ~/.ssh/id_ed25519_personal

ControlPath needs the directory to exist

The ~/.ssh/control/ directory is not created automatically — SSH will silently skip multiplexing. The framework's bootstrap.sh creates it, and conf.d/70-tools.zsh checks for it on every shell start. The %C substitution hashes user, host, port, and address into a short filename — safer than the older %h-%p-%r form because it avoids the Unix socket path-length limit.

ssh-agent — native, keychain-backed (macOS)

macOS ships a launchd-managed ssh-agent that runs per-user and is always available at $SSH_AUTH_SOCK without any shell activation. Combined with UseKeychain yes, passphrases are stored in the Keychain, and you never type a passphrase after the initial key load.

# Load a key and save its passphrase to the Keychain (once per key)
ssh-add --apple-use-keychain ~/.ssh/id_ed25519_work
ssh-add --apple-use-keychain ~/.ssh/id_ed25519_personal

# Verify
ssh-add -l

With AddKeysToAgent yes in the SSH config, the first use of a key also triggers an agent load and a Keychain save — you can skip the ssh-add step and just git push, answer the passphrase prompt once, and be done.

If you already use 1Password, the 1Password SSH agent is a substantial upgrade. It stores private keys inside the vault (end-to-end encrypted, synced across devices), requires Touch ID for every use, integrates with git commit signing, and eliminates the passphrase workflow. On a machine using the 1Password agent, your ~/.ssh directory contains no private key material at all.

Enabling the agent

In the 1Password desktop app: Settings > Developer > Use the SSH agent. Point SSH at it:

# In ~/.ssh/config, under Host *:
Host *
    IdentityAgent "~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock"
    # Linux: IdentityAgent ~/.1password/agent.sock

The IdentityFile directives still matter — they tell SSH which public key to present, and the agent looks up the matching private key in the vault.

Commit signing via 1Password

# In ~/.config/git/work.config (and personal.config)
[gpg "ssh"]
    allowedSignersFile = ~/.config/git/allowed_signers
    # macOS
    program = "/Applications/1Password.app/Contents/MacOS/op-ssh-sign"
    # Linux: program = "/opt/1Password/op-ssh-sign"

Touch ID on every operation

This is both the selling point and the main tradeoff. Every git push, every ssh, every git commit (if signing is on) triggers a Touch ID prompt. For daily workflows this is the right security posture. For high-volume scripts or CI, use short-lived personal access tokens or agent-less CI runners.

Connection multiplexing

ControlMaster auto with ControlPersist 10m reuses a single TCP connection for up to 10 minutes of subsequent ssh/scp/sftp invocations to the same host, dropping second-and-later connection latency to effectively zero. This is the single biggest perceived-latency improvement for anyone who runs SSH in a loop.

To close a multiplexed session explicitly: ssh -O exit <host>. To see active control sockets: ls ~/.ssh/control/.

Host aliases for multi-identity use

The github.com-work and github.com-personal host entries are the key mechanism. SSH treats them as distinct hosts with distinct identity files; the git URL rewrites from the git configuration then route clones through the correct alias based on repository path.

The end-to-end flow:

  1. Clone ~/work/springbig/api — git sees includeIf "gitdir:~/work/", loads work.config
  2. work.config rewrites github.com:springbig/ to github.com-work:springbig/
  3. SSH resolves github.com-work to github.com + work identity file
  4. The correct key is presented; the commit is signed with the work key

ProxyJump vs. agent forwarding

For reaching internal hosts through a bastion, always use ProxyJump rather than agent forwarding (-A or ForwardAgent yes):

Host *.springbig.internal
    ProxyJump bastion

Agent forwarding creates a socket on the bastion that anyone with root access can use to authenticate as you to any host your agent has keys for. ProxyJump tunnels the TCP connection through the bastion without exposing the agent — your keys never leave your machine.

Security checklist

  • Ed25519 keys with passphrases
  • IdentitiesOnly yes in Host *
  • No agent forwarding; ProxyJump for bastions
  • StrictHostKeyChecking accept-new (prompt once, then strict)
  • HashKnownHosts yes (obscures hostnames in known_hosts)
  • Private key permissions 600, .ssh directory 700
  • Keys registered with GitHub as both Authentication and Signing