SSH & SSH Config
Client-side SSH for daily SRE work: ~/.ssh/config, keys and agents, ProxyJump and bastions, local/remote/dynamic port forwarding, agent-forwarding risks, and SSH certificates.
SSH & SSH Config
Client-side SSH for daily SRE work: keys, agents, ~/.ssh/config, jump hosts, port forwarding, and certificates.
Overview
OpenSSH is the workhorse you reach for dozens of times a day — to land on a box behind a bastion, to tunnel a database port to your laptop, to drive rsync and git. The payoff from investing in a good ~/.ssh/config is enormous: short host aliases, the right key and user picked automatically, jumps through bastions made transparent, and connection multiplexing so the second and third session to a host open instantly.
This sheet is the client side — your config, your keys, your forwards. Server hardening (sshd_config baselines, CIS, server-side MFA, CA-issued host certificates at scale) lives in the separate SSH Hardening and Bastion Hosts topic. Where a server directive explains why your client fails (e.g. AllowTcpForwarding no killing a forward), it is shown here as context only.
graph LR
C["Client<br/>~/.ssh/config"] -->|"ProxyJump"| B["Bastion<br/>(jump host)"]
B -->|"hop, no shell"| T["Target<br/>10.0.x.x (private)"]
C -. "-L 5432:db:5432<br/>(tunnel rides the same hop)" .-> DB[("Internal DB<br/>db.internal:5432")]
T --- DB
The arrows show the two things people conflate: a jump (ProxyJump) gets your SSH session to an otherwise-unreachable target, while a tunnel (-L/-R/-D) carries arbitrary TCP through the encrypted connection. You frequently want both at once — jump through the bastion, and forward the database port along the way.
Keys
Key Concepts
- A key pair is a private key (kept secret, ideally passphrase-protected) and a public key (copied to servers'
authorized_keys). - ed25519 is the default modern choice: small, fast, and not dependent on a good RNG for each signature. Use it unless something old forces otherwise.
- Hardware-backed keys (
-sk, "security key") keep the private key inside a FIDO2 authenticator (YubiKey, Secure Enclave via a resident credential, etc.) — the host only ever sees a key handle, so the secret cannot be exfiltrated. - RSA is legacy-compat only. If you must, use 4096-bit RSA and ensure the server negotiates
rsa-sha2-512/rsa-sha2-256(the oldssh-rsa/SHA-1 signature is disabled by default in current OpenSSH).
Generating Keys
# The modern default — ed25519, with a comment to identify it later
ssh-keygen -t ed25519 -C "mike@laptop-2026" -f ~/.ssh/id_ed25519
# Hardware-backed FIDO2 key (touch-to-authenticate). Two flavours:
ssh-keygen -t ed25519-sk -C "mike@yubikey" # preferred where supported
ssh-keygen -t ecdsa-sk -C "mike@yubikey" # for authenticators lacking ed25519
# add -O resident to store a discoverable credential ON the key (portable)
# add -O verify-required to force a PIN as well as touch
# Legacy-compat only: RSA 4096 (use ed25519 unless a peer can't do it)
ssh-keygen -t rsa -b 4096 -C "mike@legacy" -f ~/.ssh/id_rsa
# Re-encrypt an existing private key with a (new) passphrase
ssh-keygen -p -f ~/.ssh/id_ed25519
-a 100 (KDF rounds) is sometimes suggested to slow passphrase brute-forcing; ed25519 already uses the bcrypt KDF and the default is fine for most. Always set a passphrase on keys that live on disk — the agent (below) means you only type it once per session.
Distributing and Inspecting
# Copy a public key into a server's authorized_keys (idempotent, dedupes)
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@host
ssh-copy-id -i ~/.ssh/id_ed25519.pub -p 2222 user@host # non-standard port
# No ssh-copy-id? Append manually, preserving perms
cat ~/.ssh/id_ed25519.pub | ssh user@host \
'umask 077; mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys'
# Fingerprint of a key (SHA256 by default; -E md5 for the old colon form)
ssh-keygen -lf ~/.ssh/id_ed25519.pub
# Fingerprint every key a remote server presents (compare on first connect)
ssh-keyscan host | ssh-keygen -lf -
# Derive the public key from a private key (e.g. lost the .pub)
ssh-keygen -y -f ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.pub
File Permissions
OpenSSH refuses to use a private key (or sometimes the whole config) if the permissions are too open. This is the single most common "publickey" failure on a freshly-copied dotfiles repo.
chmod 700 ~/.ssh # directory: owner-only
chmod 600 ~/.ssh/id_ed25519 # private keys: owner read/write
chmod 644 ~/.ssh/id_ed25519.pub # public keys: world-readable is fine
chmod 600 ~/.ssh/config # config: owner-only (avoids warnings)
chmod 644 ~/.ssh/known_hosts # known_hosts: not secret
| Path | Mode | Why |
|---|---|---|
~/.ssh/ |
700 |
Others must not list or traverse |
| Private key | 600 |
A group/other-readable key is rejected |
~/.ssh/config |
600 |
Group/world-writable config is rejected |
~/.ssh/authorized_keys (on server) |
600 |
sshd ignores it if too open |
The ssh-agent
Key Concepts
The agent holds your decrypted private keys in memory so you type the passphrase once, then every ssh/git/scp invocation borrows the agent to sign challenges. It is also what gets forwarded (see the risks section) — understanding the agent is the prerequisite for understanding forwarding.
# Start an agent (most desktops already run one; this is for headless/manual use)
eval "$(ssh-agent -s)"
# Add a key (prompts for the passphrase once)
ssh-add ~/.ssh/id_ed25519
# Add ALL default keys
ssh-add
# List loaded keys (fingerprints) / list public keys
ssh-add -l
ssh-add -L
# Remove one key / flush everything
ssh-add -d ~/.ssh/id_ed25519
ssh-add -D
# Time-limit a key — auto-removed after 1 hour (great for shared/loaner machines)
ssh-add -t 3600 ~/.ssh/id_ed25519
# Require confirmation on EACH use of this key (a GUI/askpass prompt per signature)
ssh-add -c ~/.ssh/id_ed25519
-t (timed) and -c (confirm-each-use) are your two cheap defences when forwarding an agent — a stolen handle expires, or pops a prompt you didn't initiate.
macOS Keychain Integration
macOS can store key passphrases in the login Keychain and load them into the agent automatically. Put this in ~/.ssh/config:
Host *
UseKeychain yes # read/store the passphrase in the macOS Keychain
AddKeysToAgent yes # add a key to the running agent on first use
IdentityFile ~/.ssh/id_ed25519
Then ssh-add --apple-use-keychain ~/.ssh/id_ed25519 once stores the passphrase; subsequent logins are silent. AddKeysToAgent yes is not macOS-specific and is worth setting everywhere — it means "load this key into the agent the first time I use it" without an explicit ssh-add.
IdentitiesOnly — Stop Offering Every Key
Without IdentitiesOnly yes, the client offers the agent's keys (and default identity files) in turn until one works. With many keys loaded this can trip a server's MaxAuthTries and get you disconnected with Too many authentication failures before the right key is reached. Pin each host to its key:
Host github.com
IdentityFile ~/.ssh/id_ed25519_github
IdentitiesOnly yes # use ONLY the IdentityFile above, ignore the agent's others
~/.ssh/config
This is the centrepiece. A good config turns ssh -i ~/.ssh/prod_key -p 2222 -J bastion.example.com deploy@10.0.4.17 into ssh app1.
Matching Semantics — Read This First
- The file is a list of
Host(andMatch) blocks, each followed by indentedkeyword valuedirectives. - First-match-wins per directive. OpenSSH walks the file top to bottom; for each keyword, the first value it encounters is the one that sticks. Later blocks can only fill in keywords not yet set.
- The practical consequence: specific hosts go at the top, broad wildcards at the bottom. A
Host *block at the end supplies defaults for anything not already specified. Hostmatches against the alias you typed on the command line (and patterns), not the resolved hostname.Matchis more powerful — it can test the resolved host, the local user, exec a command, etc.- Patterns:
*(any run of chars),?(one char),!(negation). Multiple patterns are space-separated.
Host web-* # matches web-01, web-02, ...
Host *.internal # matches anything.internal
Host prod !prod-db # prod, but explicitly not prod-db
Annotated Example Config
# ── Global defaults (kept LAST in spirit, but Host * first-match still applies
# only to keywords not set earlier, so it is safe at the top for these) ──
Host *
AddKeysToAgent yes
ServerAliveInterval 60 # ping every 60s so NAT/firewalls don't drop idle sessions
ServerAliveCountMax 3 # give up after 3 missed (≈3 min) — then reconnect
HashKnownHosts yes # store hostnames hashed in known_hosts
LogLevel INFO # bump to DEBUG3 (== -vvv) when troubleshooting one host
# ── Connection multiplexing: huge daily-driver win (see its own section) ──
Host *
ControlMaster auto
ControlPath ~/.ssh/cm/%C # %C = hash of host+port+user; one socket per identity
ControlPersist 10m # keep the master open 10 min after the last session
# ── The bastion / jump host ──
Host bastion
HostName bastion.example.com
User jump
Port 22
IdentityFile ~/.ssh/id_ed25519_bastion
IdentitiesOnly yes
ForwardAgent no # NEVER forward the agent to a shared jump box (see risks)
# ── Internal hosts reached THROUGH the bastion ──
Host app1
HostName 10.0.4.17
User deploy
IdentityFile ~/.ssh/id_ed25519_prod
IdentitiesOnly yes
ProxyJump bastion # hop via the 'bastion' block above
Host app2
HostName 10.0.4.18
User deploy
IdentityFile ~/.ssh/id_ed25519_prod
IdentitiesOnly yes
ProxyJump bastion
# ── Wildcard for a whole internal range, one rule ──
Host 10.0.4.*
User deploy
ProxyJump bastion
IdentityFile ~/.ssh/id_ed25519_prod
IdentitiesOnly yes
StrictHostKeyChecking accept-new # auto-trust first-seen keys in this trusted range
# ── A git forge with its own dedicated key ──
Host github.com
User git
IdentityFile ~/.ssh/id_ed25519_github
IdentitiesOnly yes
Order note: because
Host *sets keywords likeServerAliveIntervalthat no later block overrides, keeping it at the top is fine. But for any keyword you also set per-host (e.g.User,IdentityFile), the per-host block must come before anyHost *that sets the same keyword — otherwise the wildcard wins. When in doubt, specific blocks first,Host *last.
Key Directives
| Directive | Purpose |
|---|---|
HostName |
The real address to connect to (lets the Host alias differ from the FQDN/IP) |
User |
Remote username |
Port |
Remote port (default 22) |
IdentityFile |
Private key to offer (repeatable) |
IdentitiesOnly yes |
Offer only the listed keys, not the agent's whole set |
ProxyJump |
Hop via one or more jump hosts (modern; equals -J) |
ProxyCommand |
Run a command to obtain the connection (legacy/advanced) |
ForwardAgent |
Expose your agent on the remote (default no; keep it that way for shared hosts) |
AddKeysToAgent yes |
Auto-load a key into the agent on first use |
ServerAliveInterval / ServerAliveCountMax |
Keepalives so idle sessions survive NAT timeouts |
ControlMaster / ControlPath / ControlPersist |
Connection multiplexing (below) |
StrictHostKeyChecking |
yes / accept-new / no — host-key trust policy |
UserKnownHostsFile |
Where known host keys are stored (per-env files are handy) |
LogLevel |
Verbosity; DEBUG3 ≈ ssh -vvv |
Connection Multiplexing — The Big Win
Multiplexing reuses one authenticated TCP connection for many SSH sessions. The first ssh opens a "master" and a control socket; subsequent sessions to the same host ride that socket, skipping the TCP handshake, key exchange, and re-authentication. For tools that open many short connections (Ansible, git over SSH, repeated rsync) the speed-up is dramatic.
Host *
ControlMaster auto
ControlPath ~/.ssh/cm/%C # %C hashes host+port+user — short, collision-free
ControlPersist 10m # master lingers 10 min after last client exits
mkdir -p ~/.ssh/cm # create the socket directory once
# Open a master in the background, no shell, just hold the connection
ssh -fN -M app1
# Check / stop / inspect the master via the control socket
ssh -O check app1
ssh -O exit app1
ssh -O stop app1 # stop accepting new multiplexed sessions, finish existing
Gotcha: a hung master wedges every session to that host. ssh -O exit <host> (or deleting the stale socket under ~/.ssh/cm/) clears it. Also, server-side changes to forwarding only take effect on a fresh master — kill the master after editing forwards.
Bastions and Jump Hosts
A bastion (jump host) is the single internet-facing door into a private network. You SSH through it to reach hosts that have no public address. The modern mechanism is ProxyJump; the older ProxyCommand … -W form is still seen and occasionally needed.
ProxyJump (-J) — Use This
# Single hop: connect to the target via the bastion
ssh -J bastion.example.com deploy@10.0.4.17
# Chained hops (bastion -> mid -> target), left to right
ssh -J jump1.example.com,jump2.example.com deploy@10.0.9.5
# Per-hop user/port in the jump spec
ssh -J jumpuser@bastion.example.com:2222 deploy@10.0.4.17
In config it is just ProxyJump bastion (referencing another Host block, so the bastion's own key/user/port are reused). ProxyJump establishes the connection to the final target end-to-end encrypted — the bastion only forwards bytes and never sees your session plaintext or your agent. That is why it is the safe default.
ProxyCommand … -W (Legacy / Advanced)
Host app1-legacy
HostName 10.0.4.17
User deploy
# -W %h:%p tells the bastion's ssh to forward stdio to host:port
ProxyCommand ssh -W %h:%p bastion.example.com
When you still need ProxyCommand:
- Non-ssh transports — e.g.
ProxyCommand cloudflared access ssh --hostname %h, AWS SSM (aws ssm start-session …), orsocat/corkscrewthrough an HTTP proxy. - Old clients —
ProxyJumparrived in OpenSSH 7.3 (2016); anything older needs the-Wform.
For plain "hop through a bastion", prefer ProxyJump — it is clearer, supports chaining natively, and doesn't spawn a shell on the jump host.
Port Forwarding
Forwarding carries arbitrary TCP through the SSH connection. Three directions, easily muddled:
graph TB
subgraph local["Local machine (laptop)"]
LA["-L 5432:db:5432<br/>listens locally,<br/>forwards OUT"]
LD["-D 1080<br/>SOCKS proxy<br/>(dynamic)"]
end
subgraph remote["Remote / via SSH server"]
RA["-R 8080:localhost:3000<br/>listens on REMOTE,<br/>forwards back IN"]
end
LA -->|"reach a private service"| DB[("db:5432")]
LD -->|"any destination the<br/>server can reach"| NET(("Internet / VPC"))
APP(("remote users")) -->|"hit your local app"| RA
| Flag | Name | Listens on | Sends to | Use for |
|---|---|---|---|---|
-L [bind:]lport:dhost:dport |
Local | your machine | a host reachable from the server | Reach an internal DB/admin UI locally |
-R [bind:]rport:dhost:dport |
Remote | the SSH server | a host reachable from you | Expose your local service to the remote side |
-D [bind:]port |
Dynamic | your machine | anywhere (SOCKS) | Proxy a browser through the server's network |
Common companions: -N (do not run a remote command — pure tunnel), -f (background after auth), -g (let other hosts use your local-forward listener, i.e. bind to 0.0.0.0 not just loopback).
Local Forward (-L) — Reach a DB Through a Bastion
# Forward laptop:5432 -> (via bastion) -> db.internal:5432
# Now `psql -h 127.0.0.1 -p 5432` on the laptop hits the private DB.
ssh -N -L 5432:db.internal:5432 -J bastion.example.com deploy@app1
# Bind only to loopback (default & safest). Expose to your LAN with an explicit bind:
ssh -N -L 0.0.0.0:5432:db.internal:5432 -J bastion app1 # also needs -g semantics; be careful
The forward's dhost:dport is resolved from the SSH server's vantage point — db.internal must be what the server (here app1) can reach, not your laptop.
Remote Forward (-R) — Expose a Local Service
# A teammate on the server can hit your laptop's :3000 via the server's :8080
ssh -N -R 8080:localhost:3000 user@server
# Demo a local webhook receiver to a remote service running on the server
ssh -N -R 9000:127.0.0.1:9000 user@server
By default a remote forward binds to the server's loopback only, so only processes on the server can use it. To let other machines reach it, the server needs GatewayPorts yes (or clientspecified) in sshd_config, and you bind explicitly:
ssh -N -R 0.0.0.0:8080:localhost:3000 user@server # requires GatewayPorts on the server
Dynamic Forward (-D) — SOCKS Proxy a Browser
# Open a SOCKS5 proxy on localhost:1080; all traffic egresses from the server's network
ssh -N -D 1080 -J bastion.example.com deploy@app1
Point a browser (or curl --socks5-hostname localhost:1080) at localhost:1080. Use --socks5-hostname / "remote DNS" so name resolution also happens at the far end — otherwise you leak DNS locally and can't resolve internal-only names. This is the quickest way to reach a whole internal web estate without a per-service -L.
Forwarding vs Jumping
A jump (-J/ProxyJump) moves your SSH session to an unreachable host. A forward (-L/-R/-D) tunnels other TCP. They compose: ssh -J bastion -L 5432:db:5432 app1 jumps to app1 and forwards the DB port through the same encrypted path.
Agent Forwarding and Its Risks
-A / ForwardAgent yes exposes your local agent's signing capability on the remote host, so you can git pull or ssh onward from there using keys that never left your laptop. Convenient — and a real footgun.
The Socket-Hijack Risk
Agent forwarding plants a Unix socket on the remote host ($SSH_AUTH_SOCK). Anyone who can read that socket can ask your agent to sign anything — they can't extract the key, but they can authenticate as you to any host that trusts it, for as long as your session is connected. On a shared bastion, root (or a compromised root) can hijack the socket and impersonate you across the entire estate. This is exactly the scenario that took down several high-profile environments.
sequenceDiagram
participant You as Your agent (laptop)
participant Jump as Bastion (shared, root-controlled)
participant Target as Internal host
You->>Jump: ssh -A (forwards agent socket)
Note over Jump: $SSH_AUTH_SOCK now lives here
Jump->>You: root borrows the socket
Jump->>Target: "sign this challenge as you"
You-->>Target: ✓ authenticated AS YOU
Note over Target: attacker is now you, anywhere
Mitigations (in order of preference)
- Prefer ProxyJump. It does not expose the agent on intermediate hosts — the connection to the target is end-to-end, and signing happens locally. For "I need to reach host B via bastion A",
-Jreplaces-Aand removes the risk entirely. - Don't forward to untrusted/shared hosts. Set
ForwardAgent noglobally (Host *) and enable it only per-host, only for hosts you fully control. ssh-add -c— confirm each signature with a prompt, so a hijack attempt pops a dialog you didn't trigger.ssh-add -t— time-bound keys so a leaked socket has a short window.- Scope
ForwardAgentper-host in config, never as a blanket default:
Host *
ForwardAgent no # safe default
Host my-trusted-dev-box
ForwardAgent yes # opt in, only where you trust root
For "I want to use my keys from the remote without exposing the agent", agent forwarding's safer cousins are ProxyJump (for onward SSH) and, for git, just cloning over a -L forward or using a deploy key on the box.
SSH Certificates
authorized_keys sprawl — copying public keys to every host, then chasing them down when someone leaves — does not scale. SSH certificates replace it with a CA model: a trusted CA key signs short-lived certificates that bind a public key to a set of principals (usernames/roles) and a validity window. Servers trust the CA, not individual keys, so onboarding/offboarding is "issue a cert" / "let it expire".
graph LR
CA[("User CA<br/>(private)")] -->|"signs"| UC["User cert<br/>principal: deploy<br/>valid: 52w"]
UC -->|"presented at login"| S["Server<br/>TrustedUserCAKeys = CA.pub"]
HCA[("Host CA")] -->|"signs"| HC["Host cert"]
HC -->|"presented to client"| KH["Client known_hosts<br/>@cert-authority"]
Issuing a User Certificate
# Sign a user's public key. -I = key id (for logs/audit), -n = principals (comma list),
# -V = validity window (+52w from now; or e.g. -1h:+8h for a windowed cert).
ssh-keygen -s ca_user_key \
-I "mike@2026-06" \
-n deploy,admin \
-V +52w \
~/.ssh/id_ed25519.pub
# -> writes ~/.ssh/id_ed25519-cert.pub alongside the key
# The client offers the cert automatically if it sits next to the key, or pin it:
# IdentityFile ~/.ssh/id_ed25519
# CertificateFile ~/.ssh/id_ed25519-cert.pub
The server side (configured in the hardening sheet) trusts the CA with TrustedUserCAKeys /etc/ssh/ca_user_key.pub and maps principals to allowed logins. Short -V windows are the whole point — a leaked key is useless once the cert expires, with no per-host revocation chase.
Host Certificates and @cert-authority
Host certs solve the other trust problem — verifying you reached the right server without TOFU-prompting on every new host. Sign each host's key with a host CA, then tell clients to trust that CA for a domain in known_hosts:
# ~/.ssh/known_hosts (or /etc/ssh/ssh_known_hosts)
@cert-authority *.example.com ssh-ed25519 AAAAC3Nz...hostCApub
Now any host under *.example.com presenting a valid host cert from that CA is trusted with no first-connect prompt — new hosts "just work" the moment they're issued a cert.
Inspecting Certificates
# Decode a cert: principals, validity, key id, critical options, extensions
ssh-keygen -L -f ~/.ssh/id_ed25519-cert.pub
Look at Valid: (start/end), Principals:, and Extensions: (e.g. permit-pty, permit-port-forwarding). In practice you rarely run ssh-keygen -s by hand at scale — HashiCorp Vault's SSH secrets engine and Teleport issue these dynamically with proper audit and revocation. See the Related Topics. Vault/Teleport, mentioned here as cross-refs, are where the CA private key and issuance policy actually live.
Host Key Verification
On first connect, the client records the server's host key in known_hosts (TOFU — trust on first use). On every later connect it checks the key matches. A mismatch is either a legitimate rebuild/IP-reuse or a man-in-the-middle — never blindly accept it.
# StrictHostKeyChecking policy (CLI or config):
# yes -> refuse unknown/changed keys (strictest)
# accept-new -> auto-add NEW hosts, still refuse CHANGED ones (sane default)
# no / off -> add new, warn-but-connect on changed (insecure; avoid)
ssh -o StrictHostKeyChecking=accept-new user@host
# Per-environment known_hosts (keep ephemeral CI/lab hosts out of your real file)
ssh -o UserKnownHostsFile=~/.ssh/known_hosts_lab user@host
Fixing "REMOTE HOST IDENTIFICATION HAS CHANGED" Safely
# Remove the stale entry for one host (handles hashed entries too), then reconnect
ssh-keygen -R host.example.com
ssh-keygen -R "[host.example.com]:2222" # non-standard port form
# VERIFY the new key out-of-band before trusting it (ask whoever rebuilt the box,
# or compare the fingerprint shown on the server's console):
ssh-keygen -lf <(ssh-keyscan host.example.com 2>/dev/null)
Only after confirming the new fingerprint is legitimate should you reconnect and accept it. If you can't verify, stop — that warning is doing its job.
Hashed known_hosts
HashKnownHosts yes stores hostnames as salted hashes, so a stolen known_hosts doesn't hand an attacker your inventory of servers. You can still target a specific entry by name for removal (ssh-keygen -R and -F both understand hashes):
ssh-keygen -F host.example.com # find an entry (works on hashed files)
Useful One-Liners
# Run a single remote command (no interactive shell)
ssh user@host 'uptime && df -h /'
# Copy files (scp). -r recursive; honours ~/.ssh/config Host aliases
scp file.tar.gz app1:/tmp/ # to remote
scp app1:/var/log/app.log ./ # from remote
scp -3 app1:/data/x app2:/data/x # remote-to-remote via your machine
# rsync over ssh (the workhorse — resumable, delta-transfer)
rsync -avz -e ssh ./dist/ app1:/srv/app/ # -e ssh is default but explicit here
rsync -avz -e 'ssh -J bastion' ./dist/ 10.0.4.17:/srv/app/ # through a bastion
# Interactive file transfer
sftp app1
# Debugging: escalate verbosity. -v / -vv / -vvv (auth, kex, channel detail)
ssh -vvv user@host
# What algorithms does THIS client support? (sanity-check a negotiation failure)
ssh -Q key # public-key / cert types
ssh -Q kex # key-exchange methods
ssh -Q cipher # symmetric ciphers
ssh -V # OpenSSH + crypto library version
# Test config resolution WITHOUT connecting — see the effective settings for a host
ssh -G app1 # prints the fully-resolved config (User, IdentityFile, ProxyJump, …)
# Quick "is the port open and SSH answering?" without logging in
ssh -o BatchMode=yes -o ConnectTimeout=5 user@host exit; echo $?
ssh -G <host> is the fastest way to debug a config that "isn't picking up my settings" — it shows exactly which User, IdentityFile, ProxyJump, etc. the matching rules produced.
Quick Reference
Commands
| Task | Command |
|---|---|
| Generate an ed25519 key | ssh-keygen -t ed25519 -C "comment" |
| Generate a FIDO2 hardware key | ssh-keygen -t ed25519-sk |
| Install a key on a server | ssh-copy-id -i key.pub user@host |
| Key fingerprint | ssh-keygen -lf key.pub |
| Start agent / add key | eval "$(ssh-agent -s)" ; ssh-add key |
| List agent keys | ssh-add -l |
| Add key for 1 hour | ssh-add -t 3600 key |
| Jump via a bastion | ssh -J bastion user@target |
| Chained jumps | ssh -J h1,h2 user@target |
| Local forward (reach a DB) | ssh -N -L 5432:db:5432 -J bastion app1 |
| Remote forward (expose local) | ssh -N -R 8080:localhost:3000 user@server |
| Dynamic SOCKS proxy | ssh -N -D 1080 -J bastion app1 |
| Resolve effective config | ssh -G host |
| Verbose debug | ssh -vvv user@host |
| List supported key types | ssh -Q key |
| Forget a changed host key | ssh-keygen -R host |
| Sign a user cert | ssh-keygen -s ca -I id -n principals -V +52w key.pub |
| Inspect a cert | ssh-keygen -L -f cert.pub |
| Close a multiplex master | ssh -O exit host |
Config Directives
| Directive | What it does |
|---|---|
HostName |
Real address behind the alias |
User / Port |
Remote user / port |
IdentityFile |
Key to offer |
IdentitiesOnly yes |
Offer only listed keys (avoids MaxAuthTries) |
ProxyJump |
Hop via jump host(s) — modern, safe default |
ProxyCommand |
Custom connection command (legacy/non-ssh transports) |
ForwardAgent |
Expose the agent remotely — default no, opt-in only |
AddKeysToAgent yes |
Load keys into the agent on first use |
ControlMaster auto + ControlPath + ControlPersist |
Connection multiplexing |
ServerAliveInterval |
Keepalive to survive NAT idle timeouts |
StrictHostKeyChecking accept-new |
Auto-trust new hosts, refuse changed keys |
UserKnownHostsFile |
Per-environment host-key store |
UseKeychain yes |
macOS: store passphrase in the Keychain |
LogLevel DEBUG3 |
Per-host verbose logging |
Common Issues and Solutions
| Issue | Cause | Solution |
|---|---|---|
Permission denied (publickey) |
Wrong/absent key offered, or server has no matching authorized_keys |
ssh -vvv to see which keys are offered; confirm IdentityFile; check the pubkey is in the server's authorized_keys |
Permissions 0644 for 'id_ed25519' are too open |
Private key is group/world-readable | chmod 600 ~/.ssh/id_ed25519 (and chmod 700 ~/.ssh) |
Too many authentication failures |
Agent offered many keys, hit server MaxAuthTries |
Set IdentitiesOnly yes and a specific IdentityFile per host |
REMOTE HOST IDENTIFICATION HAS CHANGED! |
Host key differs from known_hosts (rebuild, IP reuse, or MITM) |
Verify the new key out-of-band, then ssh-keygen -R host and reconnect |
channel N: open failed: administratively prohibited: open failed |
Server has AllowTcpForwarding no (or per-direction off) |
Forwarding is disabled server-side — needs a server config change (see SSH Hardening) |
Agent not available on the remote (Could not open a connection to your agent) |
ForwardAgent off, or no local agent / key loaded |
Confirm ssh-add -l locally; prefer ProxyJump over forwarding; only enable ForwardAgent for trusted hosts |
| Remote forward bound but unreachable from other hosts | -R binds server loopback by default |
Need GatewayPorts yes on the server and an explicit 0.0.0.0: bind |
| Connection drops when idle | Firewall/NAT reaps idle flows | ServerAliveInterval 60 (and ServerAliveCountMax 3) in config |
| Second session to a host hangs | Stale/wedged multiplex master | ssh -O exit host, or remove the socket under ~/.ssh/cm/ |
Bad configuration option / config ignored |
Typo, or ~/.ssh/config too permissive |
Run ssh -G host to see what resolved; chmod 600 ~/.ssh/config |
| Key works locally, fails through a forward to a private name | -L's dhost resolves from the server, not your laptop |
Use a name/IP the SSH server can reach for dhost:dport |
Related Topics
The following topics complement this client-side SSH cheatsheet:
- SSH Hardening and Bastion Hosts — the server side:
sshd_configbaselines, CIS, server-side MFA, host-CA rollout, and locking down the jump box - Teleport — identity-aware access proxy that issues short-lived SSH certs with audit, recording, and RBAC
- Vault — HashiCorp Vault's SSH secrets engine as the CA for signed, short-lived SSH certificates
- Python - paramiko — programmatic SSH/SFTP from Python (with
ProxyJump/jump-host support) for automation - Linux network tools —
ss,ip, and friends for diagnosing what a forward or bastion can actually reach - tmux — keep long-running remote sessions alive across drops and reconnects on the far end of an SSH hop