RE:NODE

SSH config file and ProxyJump: hosts, keys and jump hosts

By RE:NODE Team
16 min read


The client-side SSH config file is ~/.ssh/config (on Windows, C:\Users\<you>\.ssh\config). Each Host block gives a short name to a connection and stores everything you would otherwise type: HostName for the real address, User, Port, IdentityFile for the key, ProxyJump for the machine in between. After that, ssh web replaces ssh -i ~/.ssh/id_work -p 2222 deploy@203.0.113.10, and so do scp, sftp, rsync, Git and every editor that speaks SSH, because they all read the same file.

The one rule that explains nearly every surprise: for each setting, the first value ssh finds wins. Specific blocks go at the top, the catch-all Host * goes at the bottom, and a setting given on the command line beats anything in the file. Keep that in mind and the rest of this guide is a reference you can copy from.

This post is about the client side. Generating keys, the agent and hardening sshd_config on the server are covered in SSH keys and sshd_config hardening; read that first if you do not yet have a key.

Where the config file lives#

There are two files, read in order: your own, then the system-wide one.

SystemUser fileSystem file
Linux, BSD~/.ssh/config/etc/ssh/ssh_config
macOS~/.ssh/config/etc/ssh/ssh_config
Windows OpenSSH%USERPROFILE%\.ssh\config%ProgramData%\ssh\ssh_config

The file does not exist until you create it. On Linux and macOS it must not be writable by anybody but you, or ssh refuses to use it with Bad owner or permissions on /home/you/.ssh/config:

bash
$ mkdir -p ~/.ssh && chmod 700 ~/.ssh$ touch ~/.ssh/config && chmod 600 ~/.ssh/config

Note the name: ssh_config is the client, sshd_config (with the d, for daemon) is the server. Editing the wrong one is a surprisingly common way to lose an afternoon, and the server file lives in the same /etc/ssh directory.

Two flags make the file transparent. ssh -G name prints the fully resolved configuration for a name without connecting - every option, after every block and default has been applied. ssh -F /dev/null name connects while ignoring your config, which tells you in one step whether the file is the problem. -G arrived in OpenSSH 6.8, so it is available on anything you are likely to meet.

bash
$ ssh -G web | grep -E '^(hostname|user|port|identityfile|proxyjump) 'hostname 203.0.113.10user deployport 2222identityfile ~/.ssh/id_workproxyjump none

Host blocks and how ssh picks a value#

A Host line starts a block that runs until the next Host or Match line. Indentation is for you, not for ssh. Keywords are case-insensitive, values mostly are not, and you may write Keyword value or Keyword=value.

~/.ssh/config
Host web    HostName 203.0.113.10    User deploy    Port 2222    IdentityFile ~/.ssh/id_work    IdentitiesOnly yesHost *.lab    User admin    StrictHostKeyChecking accept-newHost *    ServerAliveInterval 30    AddKeysToAgent yes

When you type ssh web, ssh walks the file from top to bottom. Every block whose pattern matches contributes the settings it has not already seen. So web gets its port from its own block and ServerAliveInterval from Host *. If Host * were at the top with User root in it, every later User line would be ignored, which is the classic version of "my config is being ignored".

The exception is the small set of options that accumulate instead of taking the first value: IdentityFile, CertificateFile, LocalForward, RemoteForward, DynamicForward and SendEnv. Several IdentityFile lines from several matching blocks produce a list, tried in order.

Patterns are matched against the name you typed, not against HostName. They support * and ?, several patterns on one line, and negation with !:

~/.ssh/config
Host web db cache    User deployHost 10.0.0.* !10.0.0.1    ProxyJump bastionHost * !github.com    ForwardAgent no

A negated pattern on its own never matches anything; it only excludes from the other patterns on the same line. Host !github.com alone is a block that applies to nothing.

Match, for conditions Host cannot express

Match blocks test conditions rather than names. The useful ones are host (the name after HostName has been applied), originalhost (what you typed), user, localuser, exec and all.

~/.ssh/config
# Use the office jump host only when not on the office networkMatch host 10.20.* !exec "ip route | grep -q 10.20.0.0/16"    ProxyJump gw.example.com

exec runs a shell command and matches if it exits with status zero. It runs on every connection, so keep it fast, and remember it is a shell command run on your machine - never paste one you do not understand.

The settings worth knowing#

These are the keywords that earn a place in a real config. Defaults are the OpenSSH defaults.

KeywordDefaultWhat it does
HostNamethe name typedReal address or DNS name
Useryour local usernameRemote account
Port22Remote SSH port
IdentityFilethe standard id_* filesPrivate key to offer
IdentitiesOnlynoOffer only the named keys
ProxyJumpnoneHop through these hosts first
ServerAliveInterval0 (off)Seconds between keepalives
ServerAliveCountMax3Missed keepalives before giving up
ConnectTimeoutsystem TCP timeoutSeconds to wait for a connect
StrictHostKeyCheckingaskHow to treat unknown host keys

HostName takes tokens, so one block can serve a whole family of machines. %h is the name you typed:

~/.ssh/config
Host *.prod    HostName %h.example.com

That does not quite do what people expect: ssh api.prod connects to api.prod.example.com. If your real names do not contain .prod, write one block per host instead. Clever patterns save lines and cost clarity, and the config is a file you will read at two in the morning.

User is worth setting on every block, because the default is your local login name. On a Windows laptop that is often a capitalised name, or an email-shaped one, that does not exist on any Linux server, and the error you get is a plain Permission denied with nothing pointing at the username.

Port matters mostly for machines that moved SSH away from 22 to quiet the logs, and for anything that runs its own SSH-speaking service on another port. ssh -p 2222 on the command line wins over the file, as every command-line option does.

IdentityFile and IdentitiesOnly

IdentityFile names a private key, and the path may start with ~. Without one, ssh tries the standard names in ~/.ssh - id_rsa, id_ecdsa, id_ed25519 and their security-key variants - plus anything loaded in the agent.

That default is fine with one key. With several it causes the failure everyone with a work and a personal key eventually meets: ssh offers every key in the agent, one after another, each offer counts as an authentication attempt, and a server with MaxAuthTries 3 disconnects you with Too many authentication failures before it reaches the right one. IdentitiesOnly yes tells ssh to offer only the keys named for that host, even when the agent holds more. Put it in Host * near the bottom and name a key in every block that needs one.

IdentityFile also accepts the .pub file when the private half lives somewhere else, such as a password manager acting as the agent. ssh uses the public key to pick which agent key to offer, which is the cleanest way to combine IdentitiesOnly with an agent that holds a dozen keys.

Host key checking without the yes-yes-yes habit

StrictHostKeyChecking accept-new (OpenSSH 7.6 and later) adds keys for hosts you have never seen without asking, and still refuses loudly when a known host's key changes. That is a sensible setting for a lab of throwaway machines and a poor one for a production server you are reaching for the first time, where the prompt is the only moment you can compare the fingerprint. Scope it with a pattern rather than putting it in Host *. UserKnownHostsFile can point a group of hosts at a separate file, useful for a test network that is rebuilt every week:

~/.ssh/config
Host *.lab    StrictHostKeyChecking accept-new    UserKnownHostsFile ~/.ssh/known_hosts_lab

ProxyJump and jump hosts#

A jump host (also called a bastion) is one machine you can reach from the internet, standing in front of others that you cannot: a database on a private network, a game server behind a firewall that only allows SSH from the bastion, or every internal machine of a small company. ProxyJump makes the hop invisible.

~/.ssh/config
Host bastion    HostName 203.0.113.10    User jump    IdentityFile ~/.ssh/id_ed25519Host db    HostName 10.0.0.20    User deploy    IdentityFile ~/.ssh/id_ed25519    ProxyJump bastion

ssh db now connects to bastion, asks it to open a TCP connection to 10.0.0.20:22, and runs a second, complete SSH session through that tunnel. The important property is where authentication happens: on your laptop, end to end, for both hops. The bastion forwards encrypted bytes and never sees your private key, your agent or your session. That is why ProxyJump replaced agent forwarding as the right way to reach a second machine.

SSH session 1TCP forward onlySSH session 2, inside 1Your machineholds the keysbastionpublic, port 22db10.0.0.20, private
ProxyJump keeps both logins on your machine

Things worth knowing about it:

  • It arrived in OpenSSH 7.3. On anything older, the equivalent is ProxyCommand ssh -W %h:%p bastion, which does the same job and is what most pre-2017 tutorials show. Do not set both on one host; whichever ssh reads first wins.
  • Every hop is looked up in the config. ProxyJump bastion uses the bastion block's address, user, port and key. You can also write a hop inline as user@host:port.
  • Chains are comma-separated. ProxyJump bastion,inner goes through both, in order, each hop again resolved from the config.
  • On the command line it is `-J`. ssh -J jump@203.0.113.10 deploy@10.0.0.20 is the one-off form, and scp and sftp accept -J too.
  • `ProxyJump none` in a more specific block switches off a jump inherited from a broad pattern further down.

The target sees the connection arrive from the bastion's private address, so firewall rules on the inner machine should allow SSH from that address and nothing else. On the bastion itself, sshd must permit TCP forwarding: AllowTcpForwarding no or DisableForwarding yes in its sshd_config breaks every jump with a channel 0: open failed: administratively prohibited message.

A bastion account that can only jump

A bastion is more useful when the account on it can do nothing except forward. In the jump user's authorized_keys on the bastion, the key options from the hardening guide do exactly that:

/home/jump/.ssh/authorized_keys (on the bastion)
restrict,port-forwarding,permitopen="10.0.0.20:22",permitopen="10.0.0.21:22" ssh-ed25519 AAAAC3Nza... laptop

restrict turns off everything optional, port-forwarding turns forwarding back on, and permitopen limits it to the listed destinations. With that key, ssh bastion alone gets no shell, while ssh db works. A stolen key opens two SSH ports on two private machines, which still need their own keys.

Keepalives and connections that freeze#

A session that hangs rather than closing, after a few minutes of inactivity, is almost always a NAT table entry in a home router, a mobile network or a corporate firewall that was quietly dropped. The fix is on the client:

~/.ssh/config
Host *    ServerAliveInterval 30    ServerAliveCountMax 3

Every 30 seconds of silence, ssh sends a request inside the encrypted channel and expects an answer. The traffic keeps middleboxes from forgetting the connection, and if three answers in a row go missing, ssh closes the session after about 90 seconds instead of leaving a frozen terminal. Do not confuse it with TCPKeepAlive, which is on by default and works at the TCP level; it is easier to spoof and its timing is the operating system's, often two hours, which is no help at all against a router that forgets after five minutes.

ConnectTimeout 10 is the companion setting for scripts. Without it, a connection to a machine that is down waits for the operating system's TCP timeout, which can be well over a minute, and a backup script that loops over hosts stalls on the first dead one.

Connection reuse with ControlMaster#

Multiplexing lets later sessions to the same host ride on the first one's TCP connection. The second ssh web, every scp, and each step of an Ansible run or a git pull start instantly and do not authenticate again. Over a slow link or through a jump host, the difference is seconds per command.

~/.ssh/config
Host *    ControlMaster auto    ControlPath ~/.ssh/cm-%C    ControlPersist 10m

ControlMaster auto makes the first connection the master and lets later ones use it. ControlPath is the local socket file; %C is a hash of the connection details (local host, remote host, port, user), so it is unique per destination and always short. That matters: Unix sockets have a path limit of roughly a hundred characters, and the older %r@%h:%p style fails with long hostnames. ControlPersist 10m keeps the master open for ten minutes after the last session closes, in the background.

Manage the master with -O:

bash
$ ssh -O check web     # is a master running for this host?Master running (pid=48213)$ ssh -O exit web      # close it and every session on it

The caveats are real, and they are why some people leave this off:

  • Everything shares one connection. If the network drops, every session on it dies at once.
  • Settings are fixed by the master. A new session with different options, such as a port forward, joins the existing connection, and options that act at connection level, the user and the jump path among them, are the master's. When a config change seems to have no effect, run ssh -O exit first.
  • The socket is a credential. Anyone who can open the file can use your authenticated connection, which is why it belongs in a directory only you can read.
  • Windows OpenSSH does not support it. The built-in client has no Unix-socket multiplexing, and a ControlMaster line produces errors rather than speed. Use a Match exec guard, keep it in a separate file for your Linux and macOS machines, or use WSL.

Windows OpenSSH: paths and traps#

Windows 10 (1809 and later) and Windows 11 ship the OpenSSH client as C:\Windows\System32\OpenSSH\ssh.exe, and it reads the same config format. The user file is C:\Users\<you>\.ssh\config, and ~ in IdentityFile works. Paths with spaces need double quotes; forward slashes avoid escaping questions entirely:

C:\Users\you\.ssh\config
Host web    HostName 203.0.113.10    User deploy    IdentityFile ~/.ssh/id_ed25519    IdentitiesOnly yesHost db    HostName 10.0.0.20    User deploy    IdentityFile "C:/Users/you/Key Store/id_db"    ProxyJump web

The traps are all outside the syntax.

Encoding. In Windows PowerShell 5.1, echo "Host web" > config writes UTF-16, and ssh rejects the file with a garbled Bad configuration option on line 1. Create the file with New-Item -ItemType File $HOME\.ssh\config and edit it in an editor that saves plain UTF-8. PowerShell 7 writes UTF-8 by default and does not have the problem.

Key permissions. Windows ssh checks the ACL of private keys and refuses one that other accounts can read, with UNPROTECTED PRIVATE KEY FILE. That usually happens to a key copied in from elsewhere. Remove inherited permissions and grant only yourself:

code
icacls "$HOME\.ssh\id_ed25519" /inheritance:ricacls "$HOME\.ssh\id_ed25519" /grant:r "$($env:USERNAME):(R)"

The agent is a service. AddKeysToAgent yes does nothing until the OpenSSH Authentication Agent runs. From an elevated PowerShell:

code
Get-Service ssh-agent | Set-Service -StartupType AutomaticStart-Service ssh-agent

Two ssh programs. Git for Windows ships its own ssh in C:\Program Files\Git\usr\bin. It reads the same ~/.ssh/config but uses its own agent, so keys added with the Windows ssh-add are invisible to Git. Pointing Git at the system client fixes that: git config --global core.sshCommand "C:/Windows/System32/OpenSSH/ssh.exe". VS Code's Remote-SSH extension reads the same config file, so every Host name you define appears in its list.

Recipes that come up again and again#

Two GitHub accounts on one machine

GitHub identifies you by key, and every account connects as git@github.com, so a second account needs a second key and an alias:

~/.ssh/config
Host github-work    HostName github.com    User git    IdentityFile ~/.ssh/id_work    IdentitiesOnly yes

Clone with the alias in place of the hostname - git clone git@github-work:company/app.git - and test with ssh -T github-work. Without IdentitiesOnly, the agent may offer the personal key first, GitHub accepts it, and you push as the wrong person.

Reaching a database through SSH

A database that listens only on 127.0.0.1 on a server can be reached from your laptop with a forward, kept in the config:

~/.ssh/config
Host web-pg    HostName 203.0.113.10    User deploy    LocalForward 15432 127.0.0.1:5432    SessionType none

ssh web-pg then holds a tunnel open with no shell (SessionType none needs OpenSSH 8.7; on older clients use ssh -N web-pg), and a client on your machine connects to localhost:15432. It is often a better answer than opening the database port to the internet, which PostgreSQL remote connections discusses properly.

Splitting the file with Include

Include (OpenSSH 7.3 and later) reads other files, with wildcards, and relative paths are taken from ~/.ssh. Put it at the very top, outside any block, or it applies only to the block it sits in:

~/.ssh/config
Include config.d/*Host *    IdentitiesOnly yes    ServerAliveInterval 30

One file per client or project in ~/.ssh/config.d/ keeps a long config readable and lets you delete a whole project's hosts in one step.

Managed servers over SFTP

A game, app or web server on a hosting panel usually has no shell, but its SFTP access still benefits from a block. On RE:NODE, SFTP is available on every server with credentials issued per server rather than per account, so a block per server with the host, port and username shown in the panel lets sftp mc-world replace a long string, and editors that read the config list it by name. The password is still asked for each time; do not try to force a key with IdentitiesOnly on such a block. SFTP and the file manager covers clients and the panel side.

A complete config to start from#

Put together, a config for one VDS, a private machine behind it and GitHub looks like this:

~/.ssh/config
Include config.d/*Host vds    HostName 203.0.113.10    User deploy    IdentityFile ~/.ssh/id_ed25519Host db    HostName 10.0.0.20    User deploy    IdentityFile ~/.ssh/id_ed25519    ProxyJump vdsHost github.com    User git    IdentityFile ~/.ssh/id_ed25519Host *    IdentitiesOnly yes    AddKeysToAgent yes    ServerAliveInterval 30    ServerAliveCountMax 3    ConnectTimeout 10    ControlMaster auto    ControlPath ~/.ssh/cm-%C    ControlPersist 10m

Remove the three Control lines on Windows. If the server is fresh, the first hour on a new VDS covers the account and firewall on the other end, and the ufw guide shows how to allow SSH only from the bastion once a jump host is in place.

Troubleshooting the config#

SymptomUsual cause
Settings seem ignoredAn earlier block set them first
Too many authentication failuresAgent offering every key; add IdentitiesOnly yes
Bad owner or permissionsConfig or key writable by others
Bad configuration option on line 1UTF-16 file from PowerShell 5.1
administratively prohibited on a jumpBastion has forwarding disabled
Config change has no effectA ControlMaster connection is still open
Could not resolve hostnameThe name has no Host block, or a typo in it

The method for anything else is the same three steps. ssh -G name shows what ssh thinks the settings are. ssh -v name shows which config lines it applied, which keys it offered and where the connection stopped; with ProxyJump, the output covers both hops, so read which one failed. ssh -F /dev/null user@address rules the file in or out entirely.

FAQ#

Does scp, rsync and Git use my SSH config?

Yes. They all start the system ssh client, which reads ~/.ssh/config, so a Host alias works anywhere you would write a hostname: scp file web:/tmp/, rsync -a dir/ web:/srv/, git@github-work:org/repo.git. Tools with their own SSH implementation, such as some GUI SFTP clients, may ignore the file or read only part of it.

What is the difference between ProxyJump and agent forwarding?

With ProxyJump, both logins happen on your machine and the middle host only passes bytes. With agent forwarding (ssh -A), you log into the middle host and then use your agent from there, so anyone with root on that machine can borrow your keys while you are connected. Use ProxyJump whenever the goal is to reach another machine.

Can I use a different port and a jump host at the same time?

Yes. Port in a block applies to that block's host, and each hop is resolved separately, so a bastion on port 2222 and a target on 22 need only their own Port lines. Inline, ProxyJump jump@203.0.113.10:2222 does the same.

Is it safe to put ControlMaster in Host star?

On Linux and macOS it is safe for interactive use, as long as ControlPath points inside your own ~/.ssh directory. Some people exclude hosts used for long file transfers, since one stalled master stalls every session on it. On Windows, leave it out entirely.

Why does ssh still ask which key, or use the wrong one?

Usually the agent holds several keys and IdentitiesOnly is not set, or the IdentityFile path has a typo and ssh quietly falls back to the defaults. ssh -v lists every key it offered, in order, and ssh -G shows which identityfile lines it actually resolved.