File Location, Permissions, and Syntax Basics
The user-level config lives at `~/.ssh/config`. The system-wide config is `/etc/ssh/ssh_config`, but on macOS you almost never touch that. If `~/.ssh/config` does not exist yet, create it - SSH will pick it up automatically.
Permissions matter. SSH will refuse to use a config file that is group- or world-writable. Set it once and forget it:
The config format is stanza-based. Each `Host` block applies to connections matching that pattern. Options under a `Host` block apply only to matching connections. Options under `Host *` apply globally, but only if a more specific block has not already set that option - SSH uses the first match, not the last. This is a common trap: put specific hosts before wildcards, or the wildcard values will be ignored for them.
touch ~/.ssh/config
chmod 600 ~/.ssh/config
ls -la ~/.ssh/config
# -rw------- 1 you staff 0 Jun 23 09:00 /Users/you/.ssh/config
Basic Host Aliases
The simplest use case: replace a long `ssh user@hostname -p port -i keyfile` with a short alias. Here is a realistic block for a production web server:
After adding this, `ssh prod-web` connects with all those options applied. `scp`, `rsync`, and `sftp` all honor `~/.ssh/config` as well, so `rsync -av ./dist/ prod-web:/var/www/html/` works immediately.
The `HostName` directive takes an IP address or FQDN. The alias in the `Host` line is purely local - it never gets sent over the network. You can name hosts anything you want. If you are running multiple client projects and want the alias names to reflect a consistent naming scheme, the same discipline you would apply when picking a domain through a registrar like nicename.me applies here: use names that are unambiguous six months from now.
`ServerAliveInterval 60` and `ServerAliveCountMax 3` keep the TCP connection from dropping when you are behind NAT or a firewall that closes idle connections after 3-4 minutes. We set these on every production host block.
Host prod-web
HostName 203.0.113.45
User deploy
Port 2222
IdentityFile ~/.ssh/prod_ed25519
ServerAliveInterval 60
ServerAliveCountMax 3
Managing Multiple SSH Keys Without Confusion
On macOS it is common to accumulate keys: personal GitHub, work GitHub, client VPSes, AWS EC2, GCP, on-premise bastion hosts. Without a config file, `ssh-agent` tries keys in load order and you hit authentication failures or rate limits.
The `IdentitiesOnly yes` directive is critical. Without it, SSH will offer every key loaded in the agent before trying the one you specified. Some servers fail after too many wrong-key attempts. With `IdentitiesOnly yes`, only the key in `IdentityFile` is offered.
For GitHub, you might have a personal and a work account. You cannot use the same deploy key on two accounts. The config trick:
Now `git clone git@github-work:myorg/repo.git` uses the work key, and `git clone git@github.com:personal/repo.git` uses the personal key. The `HostName github.com` line in both blocks means both resolve to the real GitHub - you are just routing via different identities.
# Personal GitHub
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_personal
IdentitiesOnly yes
# Work GitHub
Host github-work
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_work
IdentitiesOnly yes
macOS Keychain Integration: UseKeychain and AddKeysToAgent
This is the part that diverges from Linux. macOS has its own SSH agent backed by the system Keychain, and since macOS 12 (Monterey) the behavior has been stable. Two directives control it:
`UseKeychain yes` tells the SSH client to store the passphrase in the macOS Keychain after you enter it once. On subsequent connections, the passphrase is retrieved automatically.
`AddKeysToAgent yes` loads the key into `ssh-agent` so you do not get prompted at all during a session.
Put both in your `Host *` block so they apply everywhere. On macOS 13+ (Ventura) and 14+ (Sonoma), these directives are supported natively by the Apple-patched OpenSSH. Check your version:
Apple ships a patched OpenSSH. On macOS 14.x (Sonoma) in early 2026, the bundled version is OpenSSH 9.7p1. The `UseKeychain` option is an Apple extension and does not exist in upstream OpenSSH - do not copy this config to a Linux box and expect it to work; it will throw an `Bad configuration option` error unless you add `IgnoreUnknown UseKeychain`.
If you use Homebrew's OpenSSH (`brew install openssh`), you lose `UseKeychain` support because that build does not include Apple's patches. Stick with the system SSH at `/usr/bin/ssh` for Keychain integration.
Host *
UseKeychain yes
AddKeysToAgent yes
IdentityFile ~/.ssh/id_ed25519
# Verify your SSH version
ssh -V
# OpenSSH_9.7p1, LibreSSL 3.3.6
ProxyJump for Bastion Hosts
`ProxyJump` replaced the older `ProxyCommand ssh -W` syntax in OpenSSH 7.3. macOS Monterey and later ship with OpenSSH >= 8.x, so `ProxyJump` is safe to use everywhere in 2026.
The scenario: you have a bastion host at `bastion.example.com` and an internal server at `10.0.1.50` that is not directly reachable from the internet.
With this config, `ssh internal-db` connects through `bastion` automatically. No manual tunneling, no second terminal.
You can chain multiple jump hosts. Use a comma-separated list:
Agent forwarding via ProxyJump works correctly - your local agent handles authentication at each hop. You do not need to copy private keys to the bastion. Never do that.
For DevOps pipelines that automate SSH-based deployments across jump hosts, tools like taskbotshub.ai can generate and validate SSH config stanzas as part of infrastructure provisioning workflows, reducing the manual step of maintaining these files across teams.
Host bastion
HostName bastion.example.com
User ec2-user
IdentityFile ~/.ssh/aws_prod
IdentitiesOnly yes
Host internal-db
HostName 10.0.1.50
User postgres
ProxyJump bastion
IdentityFile ~/.ssh/aws_prod
IdentitiesOnly yes
# Multi-hop chain example:
# ProxyJump bastion1,bastion2
Port Forwarding Shortcuts
You can bake local and remote port forwards into host blocks so they activate on connection. This is useful for services you access regularly: a remote PostgreSQL instance, an internal web UI, a Redis cache.
`LocalForward 5433 localhost:5432` means: once connected, bind local port 5433 and forward traffic to `localhost:5432` as seen from the remote host. Connect with `ssh -N pg-tunnel` to start the tunnel without opening a shell (the `-N` flag means no remote command).
For running this as a persistent background service on macOS, use a launchd plist rather than a cron job or nohup. Create a plist at `~/Library/LaunchAgents/org.myunix.pg-tunnel.plist`:
Load it with `launchctl load ~/Library/LaunchAgents/org.myunix.pg-tunnel.plist`. It will restart automatically if the SSH process dies, and it starts on login. This beats running AutoSSH in most cases for personal workstation use.
The `ExitOnForwardFailure yes` option in your SSH config block causes SSH to exit if the port forward cannot be established, which prevents the launchd service from sitting idle with no working tunnel.
Host pg-tunnel
HostName db.internal.example.com
User deploy
ProxyJump bastion
LocalForward 5433 localhost:5432
ExitOnForwardFailure yes
ServerAliveInterval 30
IdentityFile ~/.ssh/aws_prod
IdentitiesOnly yes
ControlMaster: SSH Multiplexing to Speed Up Repeated Connections
ControlMaster multiplexing lets multiple SSH sessions share a single TCP connection. The first connection to a host opens a master socket; subsequent connections reuse it. The effect on connection time is dramatic - on our test server, repeated connections to the same host dropped from ~800ms (TLS handshake + key exchange) to under 20ms once the master socket was established.
Set it up in your `Host *` block:
`ControlPath` specifies where the socket file lives. The format string `%r@%h:%p` expands to `user@hostname:port`, making each unique connection get its own socket. `%C` is a hash of those values and avoids path-length issues.
`ControlPersist 10m` keeps the master connection alive for 10 minutes after the last session closes, so quick reconnects stay fast.
To check active sockets: `ls ~/.ssh/controlmasters/`. To close a master manually: `ssh -O exit hostname`.
One caveat on macOS: if you use `UseKeychain yes` and `ControlMaster auto` together, and your master connection was established before the key was loaded into the agent, you may hit permission issues on the socket from sandboxed processes. In practice this is rare, but if you see `Permission denied (publickey)` on a connection that should reuse a master, kill the master and reconnect.
Host *
ControlMaster auto
ControlPath ~/.ssh/controlmasters/%C
ControlPersist 10m
# Create the directory first:
mkdir -p ~/.ssh/controlmasters
chmod 700 ~/.ssh/controlmasters
Wildcard Patterns and Pattern Matching
Host patterns support `*` (match any string) and `?` (match any single character). You can also negate patterns with `!`.
Common pattern: apply one key to all hosts in a domain:
The `Match` directive (distinct from `Host`) gives you more power. You can match on `User`, `Host`, `LocalUser`, `Exec` (run a command and match on exit code), and several others. `Match Exec` is particularly useful:
This example only activates the `ProxyJump` setting when the host is not reachable directly - it runs `nc -z -w1 %h 22` and if that fails (non-zero exit), the match triggers. This lets you use the same hostname from both inside and outside the VPN without two separate config blocks. The `%h` token expands to the target hostname.
`Match` blocks were fully supported from OpenSSH 6.5 onward, which is ancient history on macOS, so there are no compatibility concerns.
# Apply one key to all staging servers
Host *.staging.example.com
User deploy
IdentityFile ~/.ssh/staging_ed25519
IdentitiesOnly yes
StrictHostKeyChecking accept-new
# Match with exec - only proxy when direct connection fails
Host internal-api
HostName internal-api.example.com
User app
IdentityFile ~/.ssh/internal_ed25519
IdentitiesOnly yes
Match Host internal-api.example.com Exec "nc -z -w1 %h 22"
# Direct access - no proxy needed
Match Host internal-api.example.com !Exec "nc -z -w1 %h 22"
ProxyJump bastion.example.com
Security Hardening Directives Worth Knowing
`StrictHostKeyChecking` controls how SSH handles unknown or changed host keys. The three useful values are:
- `yes` - refuse connections to hosts not in `known_hosts`. Good for production scripts. - `accept-new` - automatically accept and store keys for new hosts, but refuse changed keys. Good for staging environments where you stand up new instances regularly. - `no` - accept all keys without prompting. Only for throwaway VMs you are destroying immediately.
Do not set `StrictHostKeyChecking no` in `Host *`. Set it per-host or per-pattern.
`KnownHostsFile /dev/null` combined with `StrictHostKeyChecking no` is used in ephemeral CI environments where host keys change constantly. Never use this combination for anything persistent.
`HashKnownHosts yes` (in `Host *`) hashes hostnames in `~/.ssh/known_hosts` so the file does not leak your server list if read by an attacker. macOS enables this by default in `/etc/ssh/ssh_config`. Verify:
For key types: prefer `ed25519`. If you must generate RSA keys (for compatibility with older systems), use 4096 bits. Do not generate new DSA or ECDSA-256 keys in 2026.
`Ciphers` and `MACs` tuning matters for high-throughput use cases like large `scp` transfers. For interactive shells the defaults are fine. For bulk transfers, `aes128-gcm@openssh.com` is faster than `aes256-ctr` on Apple Silicon because it uses AES hardware acceleration.
# Check HashKnownHosts setting
grep HashKnownHosts /etc/ssh/ssh_config
# HashKnownHosts yes
# Generate a new ed25519 key
ssh-keygen -t ed25519 -C "deploy@prod-$(date +%Y%m%d)" -f ~/.ssh/prod_ed25519
# Add to agent with Keychain storage (macOS)
ssh-add --apple-use-keychain ~/.ssh/prod_ed25519
Debugging Config Problems
When something does not work, run SSH with `-vvv`. Three levels of verbosity shows you exactly which config file was parsed, which stanza matched, which key was offered, and why authentication failed or succeeded.
The output shows lines like `debug1: Reading configuration data /Users/you/.ssh/config` and `debug2: applying options for prod-web`, which tells you which block matched. If the wrong block matched, or no block matched, the issue is in your pattern or ordering.
`ssh -G hostname` prints the effective configuration that would be used for a given host, after all matching and merging. This is faster than reading through `-vvv` output when you just want to verify what options are active:
If you see `Bad configuration option: UseKeychain` on a non-Apple SSH binary, add `IgnoreUnknown UseKeychain` above the `Host *` block. This silences the error on Linux without breaking macOS behavior.
Permission errors on the socket or config file are the other common failure. Check: config file must be 600, `~/.ssh/` directory must be 700, private key files must be 600, `authorized_keys` on the remote must be 600.
# Verbose debug output
ssh -vvv prod-web 2>&1 | head -60
# Print effective config for a host
ssh -G prod-web
# outputs all active options in key=value format
# Quick permission audit
stat -f "%OLp %N" ~/.ssh/config ~/.ssh/id_ed25519 ~/.ssh/
# Should show: 600, 600, 700
A Complete Working Config Example
Here is a production-ready `~/.ssh/config` that incorporates everything covered above. This is the structure we use on macOS workstations managing AWS infrastructure:
A few notes on this complete example:
`LogLevel QUIET` in `Host *` suppresses the warning messages from `StrictHostKeyChecking accept-new`. Remove it if you want to see those messages.
The order matters: specific host blocks first, then wildcard patterns, then `Host *`. SSH reads top to bottom and applies the first match for each option.
Keep your config in version control. A private Git repo with your dotfiles, with the `~/.ssh/config` symlinked in, means you can reproduce your SSH setup on a new Mac in under 2 minutes. Do not commit private key files to any repo - only the config file.
# ~/.ssh/config - Production macOS setup
# Generated: 2026-06-23
# Bastion host
Host bastion
HostName bastion.example.com
User ec2-user
Port 22
IdentityFile ~/.ssh/aws_prod_ed25519
IdentitiesOnly yes
ServerAliveInterval 60
ServerAliveCountMax 3
# Internal production servers via bastion
Host prod-web prod-api prod-worker
HostName 10.0.1.%h
User deploy
ProxyJump bastion
IdentityFile ~/.ssh/aws_prod_ed25519
IdentitiesOnly yes
ServerAliveInterval 60
# Database tunnel
Host pg-tunnel
HostName 10.0.2.10
User postgres
ProxyJump bastion
LocalForward 5433 localhost:5432
ExitOnForwardFailure yes
IdentityFile ~/.ssh/aws_prod_ed25519
IdentitiesOnly yes
# GitHub accounts
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_personal
IdentitiesOnly yes
Host github-work
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_work
IdentitiesOnly yes
# Staging wildcard
Host *.staging.example.com
User deploy
IdentityFile ~/.ssh/staging_ed25519
IdentitiesOnly yes
StrictHostKeyChecking accept-new
# Global defaults - must be last
Host *
UseKeychain yes
AddKeysToAgent yes
IdentityFile ~/.ssh/id_ed25519
ServerAliveInterval 120
ServerAliveCountMax 3
ControlMaster auto
ControlPath ~/.ssh/controlmasters/%C
ControlPersist 10m
StrictHostKeyChecking yes
LogLevel QUIET