How macOS ssh-agent Differs from Linux

On Linux, you typically start ssh-agent manually or via a shell profile snippet that checks for a running agent socket. On macOS, ssh-agent is launched by launchd and is always running. The socket path is injected into your environment automatically when you log in graphically. You can verify this:

``` echo $SSH_AUTH_SOCK ```

On a stock macOS 14 or 15 system this returns something like `/private/tmp/com.apple.launchd.XXXXXX/Listeners`. That socket is owned by launchd and persists as long as your login session exists.

The critical difference from Linux: Apple patches OpenSSH to support the `--apple-use-keychain` flag and the `UseKeychain` directive in ssh_config. These allow passphrases to be stored in and retrieved from the macOS Keychain, not just held in agent memory. When you reboot, the agent socket returns but your keys are not loaded - unless you configure AddKeysToAgent and UseKeychain correctly. That gap is what breaks most sysadmin setups.

ssh-add --apple-use-keychain ~/.ssh/id_ed25519

Generate Keys the Right Way in 2026

If you are still generating RSA 2048-bit keys, stop. OpenSSH on macOS 14+ supports Ed25519 by default and it is faster, smaller, and has no known practical attacks. For anything that needs RSA compatibility (some legacy HSMs or ancient SSH servers), use RSA 4096.

Generate a key with a clear, descriptive filename. Vague names like `id_rsa` cause confusion the moment you have three keys. Descriptive filenames like `work-github-ed25519` or `prod-aws-bastion-ed25519` save debugging time six months later. The same logic applies when naming any technical asset - if you find yourself registering a domain for a side project or internal tool, services like nicename.me help find short, memorable names that won't conflict with existing namespaces.

Set a strong passphrase. The Keychain integration means you will only type it once per machine lifetime.

ssh-keygen -t ed25519 -C "you@work 2026" -f ~/.ssh/work-github-ed25519
ssh-keygen -t ed25519 -C "you@personal 2026" -f ~/.ssh/personal-vps-ed25519
ssh-keygen -t rsa -b 4096 -C "you@legacy-client 2026" -f ~/.ssh/legacy-client-rsa

Load Keys into Keychain Persistently

The `ssh-add --apple-use-keychain` flag stores the passphrase in the macOS Keychain. On next login, ssh-agent can retrieve the passphrase automatically without prompting you. This is Apple-specific behavior not present in upstream OpenSSH.

Load each key once:

``` ssh-add --apple-use-keychain ~/.ssh/work-github-ed25519 ssh-add --apple-use-keychain ~/.ssh/personal-vps-ed25519 ssh-add --apple-use-keychain ~/.ssh/legacy-client-rsa ```

Verify everything is loaded:

``` ssh-add -l ```

After a reboot, those keys are gone from agent memory even though the passphrases are in Keychain. To auto-reload on login, configure `~/.ssh/config` with `UseKeychain yes` and `AddKeysToAgent yes`. When ssh first needs a key matching a given config block, it loads it from disk using the stored Keychain passphrase.

If you want all keys reloaded immediately at login without waiting for the first connection, use a launchd agent. The plist below runs `ssh-add --apple-load-keychain` at session start, which reloads everything previously saved to Keychain.

cat ~/.ssh/config
# Top of file, global defaults:
Host *
  UseKeychain yes
  AddKeysToAgent yes
  ServerAliveInterval 60
  ServerAliveCountMax 3
// advertisement

launchd Agent for Persistent Key Loading

macOS launchd replaces cron, init scripts, and rc.local. For SSH key loading, you want a LaunchAgent (per-user, runs after graphical login) not a LaunchDaemon (system-wide, no GUI session, no Keychain access).

Create the plist at `~/Library/LaunchAgents/com.myunix.ssh-add.plist`. The `--apple-load-keychain` flag was added in macOS 12 Monterey. If you are on an older system, use `--apple-use-keychain` with explicit key paths instead.

cat > ~/Library/LaunchAgents/com.myunix.ssh-add.plist << 'EOF'




  Label
  com.myunix.ssh-add
  ProgramArguments
  
    /usr/bin/ssh-add
    --apple-load-keychain
  
  RunAtLoad
  


EOF

launchctl load ~/Library/LaunchAgents/com.myunix.ssh-add.plist

Per-Host Key Routing in ~/.ssh/config

Letting ssh-agent try every loaded key against every host is slow and will get you rate-limited or temporarily banned on hardened servers that enforce MaxAuthTries. Explicit routing with `IdentityFile` and `IdentitiesOnly yes` fixes this.

`IdentitiesOnly yes` is the critical directive. Without it, ssh offers every key in the agent before trying the configured one. With three or four keys loaded, that means three or four failed attempts before the right key is tried.

Order matters in ssh_config: the first matching `Host` block wins for each directive. Put specific host patterns before wildcards. If you use `Match` blocks (available since OpenSSH 6.5), you can route on username, local port, or OS environment variables for more complex setups.

# GitHub - work account
Host github.com
  User git
  IdentityFile ~/.ssh/work-github-ed25519
  IdentitiesOnly yes

# GitHub - personal account via alias
Host github-personal
  HostName github.com
  User git
  IdentityFile ~/.ssh/personal-vps-ed25519
  IdentitiesOnly yes

# Production bastion
Host bastion.prod.example.com
  User ec2-user
  IdentityFile ~/.ssh/prod-aws-bastion-ed25519
  IdentitiesOnly yes
  ForwardAgent yes

# All staging hosts via bastion
Host *.staging.example.com
  User deploy
  ProxyJump bastion.prod.example.com
  IdentityFile ~/.ssh/work-github-ed25519
  IdentitiesOnly yes

# Legacy client with old OpenSSH
Host legacy.client.example.com
  HostKeyAlgorithms ssh-rsa
  PubkeyAcceptedAlgorithms +ssh-rsa
  IdentityFile ~/.ssh/legacy-client-rsa
  IdentitiesOnly yes

Agent Forwarding: When to Use It and When Not To

Agent forwarding (`ForwardAgent yes`) lets a remote server use your local ssh-agent to authenticate to a third host, without copying private keys onto the intermediary. The classic use case: you SSH into a bastion, then SSH from the bastion into an internal host, and the internal host authenticates using your local key.

The security caveat is real. Any root user on the intermediary can hijack your agent socket for the duration of your session. `$SSH_AUTH_SOCK` on the remote host points to a forwarded socket, and anyone with read access to it can use your keys. This is why `ForwardAgent yes` should never be in your global `Host *` block. Restrict it to bastion hosts you control.

A safer alternative for simple jump scenarios is `ProxyJump` (the `-J` flag, or `ProxyJump` directive). ProxyJump does not forward the agent - it creates an SSH connection through the intermediary using your local credentials for both hops. No socket exposure on the bastion.

We tested both approaches on a three-hop AWS topology: local -> bastion -> app server -> database host. ProxyJump chaining handles two hops cleanly. Beyond two hops, or when the intermediate host needs to run its own SSH commands (like a CI runner pulling from a private Git repo), agent forwarding is the right tool.

# ProxyJump - no agent exposure, works for 2 hops
ssh -J bastion.prod.example.com internal-host.prod.example.com

# Agent forwarding - only when the remote needs to authenticate onward
ssh -A bastion.prod.example.com

# Check if agent is forwarded on remote
echo $SSH_AUTH_SOCK
ssh-add -l
// advertisement

Debugging ssh-agent Problems on macOS

The most common failure mode on macOS is a stale or missing `SSH_AUTH_SOCK`. This happens after using sudo, switching to another user with `su`, or running commands via a tool that does not inherit your login environment.

If `ssh-add -l` returns `Could not open a connection to your authentication agent`, the socket variable is wrong. Find the correct socket:

``` launchctl getenv SSH_AUTH_SOCK ```

If that returns nothing, launchd has not set it for your session. Log out and back in, or restart the launchd agent.

A second common issue: keys loaded but connections still fail. Run ssh in verbose mode:

``` ssh -vvv user@host 2>&1 | grep -E '(Offering|Trying|denied|accepted|identity)' ```

Look for lines like `Offering public key` and whether it shows `Server accepts key` or moves on to the next identity. If you see multiple `Offering public key` lines and none are accepted, the public key is not in `authorized_keys` on the remote host, or the key type is rejected by the server's config.

For Keychain-specific issues, open Keychain Access.app, search for `SSH`, and verify your passphrases are stored. The item name should match your key filename. If entries are missing, re-run `ssh-add --apple-use-keychain` for each key.

On macOS 13 and later, some users report that `--apple-load-keychain` does not reload all keys after a reboot unless the keys were saved with the current macOS version's `ssh-add`. If you upgraded from Monterey to Ventura or Sonoma, delete the old Keychain entries and re-add them.

# List what the agent currently holds
ssh-add -l

# Remove all keys from agent (does not delete from Keychain)
ssh-add -D

# Re-load everything from Keychain
ssh-add --apple-load-keychain

# Verbose connection debug, filtered
ssh -vvv git@github.com 2>&1 | grep -E '(Offering|accepted|denied|identity)'

Multi-Account Git Workflows with SSH Config Aliases

Developers running multiple GitHub or GitLab accounts on one machine hit a specific problem: both accounts use `github.com` as the hostname, but they need different keys. The `Host` alias pattern in ssh_config solves this.

Define a second host entry with a different alias but the same `HostName github.com`. Then use the alias in your Git remote URL:

``` git remote set-url origin git@github-personal:username/repo.git ```

This is clean and does not require any wrapper scripts or environment variable switching. The downside is that cloning requires you to manually rewrite the URL. If your team uses automation pipelines or if you are managing this across multiple developer machines at scale, tools that handle environment-aware identity switching - such as the AI-driven DevOps automation available at taskbotshub.ai - can apply the correct remote URL format as part of a provisioning workflow.

For local repository configuration, `.git/config` allows per-repo SSH command overrides via `core.sshCommand`:

``` git config core.sshCommand 'ssh -i ~/.ssh/personal-vps-ed25519 -F /dev/null' ```

The `-F /dev/null` skips your global ssh_config entirely, which is useful when you need to bypass IdentitiesOnly or other global settings for a specific repository.

# In ~/.ssh/config:
Host github-personal
  HostName github.com
  User git
  IdentityFile ~/.ssh/personal-vps-ed25519
  IdentitiesOnly yes

# Clone using the alias:
git clone git@github-personal:yourusername/your-repo.git

# Or update an existing remote:
git remote set-url origin git@github-personal:yourusername/your-repo.git

Hardware Keys and the macOS Agent

If your security policy requires hardware-backed SSH keys - FIDO2 tokens like YubiKey 5 series, or the newer resident key format - macOS handles these natively since OpenSSH 8.2.

Generate a hardware-resident key (stored on the YubiKey itself, not on disk):

``` ssh-keygen -t ed25519-sk -O resident -O application=ssh:work-yubikey -f ~/.ssh/work-yubikey-ed25519 ```

The `-sk` suffix means security key. The key handle lives on the YubiKey. The file in `~/.ssh/` is just a reference handle. Without the physical token, the key is useless.

To load hardware keys into the agent:

``` ssh-add -K ```

On macOS, `-K` loads keys from a connected security key. You will be prompted to touch the YubiKey. Once loaded, connections use the hardware-backed key without repeated touch prompts unless you used `-O verify-required` during generation.

Hardware keys do not store passphrases in the macOS Keychain. The Keychain integration applies only to software keys. Plan your launchd setup accordingly - software keys load automatically, hardware keys require the token to be present and a manual `ssh-add -K` or a script that detects YubiKey insertion via a launchd `IOKit` trigger.

# Generate FIDO2 resident key
ssh-keygen -t ed25519-sk -O resident -O application=ssh:work -f ~/.ssh/work-sk-ed25519

# Load from connected security key
ssh-add -K

# Verify it is in the agent
ssh-add -l
# Output includes: (none) or the sk key fingerprint with [SK] marker
// advertisement

SSH Agent in CI and Automation Contexts on macOS

macOS is increasingly used as a CI runner platform, particularly for iOS/macOS builds on Apple Silicon hardware. In these contexts, the graphical login session does not exist, and launchd does not inject `SSH_AUTH_SOCK` the same way. GitHub Actions self-hosted runners and Buildkite agents running as LaunchDaemons hit this issue.

The fix is to run the CI agent as a LaunchAgent (user session) not a LaunchDaemon, or to explicitly start and configure ssh-agent in the job environment:

``` eval "$(ssh-agent -s)" ssh-add --apple-use-keychain ~/.ssh/deploy-key-ed25519 ```

For non-interactive agents where Keychain access is not available, store keys in encrypted files and use `SSH_ASKPASS` or a secrets manager. HashiCorp Vault's SSH secrets engine, AWS Secrets Manager, or 1Password CLI (`op ssh-agent`) all work on macOS and export keys to the agent without touching the Keychain.

1Password CLI 2.x includes a built-in SSH agent that replaces the system agent entirely:

``` export SSH_AUTH_SOCK=~/.1password/agent.sock ```

Add this to `~/.zshrc` or `~/.bashrc`. The 1Password agent reads keys from your vault and handles passphrase prompts via the 1Password GUI. We tested this on macOS 15 Sequoia with the 1Password 8.10.x CLI - it works for interactive sessions but requires the 1Password app to be unlocked, which is a problem for unattended runners.

# Manual agent start for CI environments without GUI session
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/deploy-key-ed25519

# Confirm socket is set
echo $SSH_AUTH_SOCK
ssh-add -l

# Kill agent at end of job
ssh-agent -k

Locking Keys and Session Security

When you lock your workstation on Linux, screen lockers do not touch the ssh-agent. On macOS, the screensaver and lock screen also leave the agent running and keys loaded. Anyone with physical access to an unlocked terminal session - or remote access via a backgrounded tmux session - can use your loaded keys.

Lock the agent manually when stepping away:

``` ssh-add -x ```

This password-locks the agent. Subsequent use requires `ssh-add -X` with the lock password. It is not the key passphrase - it is a separate lock password you set at the time of locking. If you forget to set one, you will be prompted.

For automation, you can wire `ssh-add -x` to a Hammerspoon caffeinate watcher or a shell alias that locks before activating the screensaver. A simpler approach: set a short `ssh_config` timeout on the agent side via `SSH_AGENT_TIMEOUT` in your launchd plist, though Apple's ssh-agent does not respect that environment variable the same way standalone agents do.

The practical recommendation on our test machines: use `AddKeysToAgent confirm` in your ssh_config instead of `AddKeysToAgent yes`. This triggers a GUI confirmation dialog every time an identity is used from the agent, which gives you visibility into unexpected key usage without requiring you to type a passphrase.

# Lock the agent
ssh-add -x

# Unlock the agent
ssh-add -X

# Use confirmation mode instead of silent key use
# In ~/.ssh/config:
Host *
  AddKeysToAgent confirm
  UseKeychain yes