SSH ProxyJump: a practical guide to jump hosts

Reach servers behind a bastion with ssh -J and ProxyJump: config examples, multi-hop chains, scp and rsync through a jump host, and bastion hardening.

On this pageThe one-line versionPut it in ~/.ssh/configMore than one hopHow it works, and why it is safeProxyJump vs ProxyCommandCopy files through a jump hostTunnels through the chainMake repeated connections fastLock down the bastionWhen it failsJump chains in Gatesys SSHFAQ

A jump host, or bastion, is the one machine in a network that accepts SSH from outside. Everything else is reached through it. OpenSSH has had a clean way to do this since version 7.3, released in 2016: ProxyJump. This guide covers the command, the config file, chains of several hops, file copies through a bastion, and the settings that keep the bastion itself safe.

The one-line version

ssh -J alice@bastion.example.com deploy@10.0.3.4

-J names the jump host. Your client connects to bastion.example.com, asks it to open a TCP connection to 10.0.3.4 port 22, and then runs a second, complete SSH session to the target through that connection.

A port or a user for the bastion goes in the same argument:

ssh -J alice@bastion.example.com:2222 deploy@10.0.3.4

Put it in ~/.ssh/config

Typing -J every time gets old. In ~/.ssh/config, the same thing is one keyword:

Host bastion
    HostName bastion.example.com
    User alice
    IdentityFile ~/.ssh/id_ed25519

Host db-primary
    HostName 10.0.3.4
    User deploy
    ProxyJump bastion

Now ssh db-primary does the right thing, and so does everything built on ssh: scp, sftp, rsync, git and Ansible all read the same file.

A pattern covers a whole network at once:

Host 10.0.3.* *.internal.example.com
    ProxyJump bastion

More than one hop

List the hops in order, separated by commas:

ssh -J bastion,inner-bastion deploy@db-primary

In the config file there are two ways to write a chain, and the second is easier to maintain:

# Every hop on the target
Host db-primary
    ProxyJump bastion,inner-bastion

# Or: each host knows only the hop in front of it
Host inner-bastion
    ProxyJump bastion

Host db-primary
    ProxyJump inner-bastion

With the second form, a change to how inner-bastion is reached is made in one place, and every host behind it follows.

How it works, and why it is safe

ProxyJump uses SSH’s own TCP forwarding. The client asks the bastion for a direct-tcpip channel to the target, then speaks SSH to the target inside that channel. Three things follow:

  • The bastion cannot read your session. The connection to the target is encrypted end to end between your computer and the target. The bastion forwards bytes it cannot decrypt.
  • Your private key never leaves your computer. Each hop is authenticated from your machine. Nothing is copied to the bastion and no agent is forwarded.
  • The bastion needs nothing extra. No nc, no shell access even, only an SSH server that allows forwarding.

That second point is the reason to prefer ProxyJump to the old habit of ssh -A to a bastion and then ssh again from there. Agent forwarding lets anyone with root on the bastion use your agent for as long as you are connected. ProxyJump gives them nothing to use.

ProxyJump vs ProxyCommand

Before 7.3, the idiom was:

Host db-primary
    ProxyCommand ssh -W %h:%p bastion

ProxyJump bastion is shorthand for exactly this. ProxyCommand is still useful when the proxy is not SSH at all: a cloud provider’s session tool, an HTTP proxy with nc -X connect, or cloudflared. If the thing in the middle is an SSH server, use ProxyJump.

Copy files through a jump host

With ProxyJump in the config file, nothing changes:

scp backup.sql db-primary:/tmp/
rsync -av ./site/ web-1:/srv/site/

Without a config entry, pass the option:

scp -o ProxyJump=bastion backup.sql deploy@10.0.3.4:/tmp/
rsync -av -e "ssh -J bastion" ./site/ deploy@10.0.3.4:/srv/site/

For Ansible, set it on the group:

ansible_ssh_common_args: "-o ProxyJump=alice@bastion.example.com"

Tunnels through the chain

Port forwarding rides the same chain. To reach PostgreSQL on a database that only the bastion can see:

ssh -J bastion -L 15432:localhost:5432 deploy@db-primary

Then point your client at localhost:15432. The forward ends on db-primary, not on the bastion. More in SSH port forwarding explained.

Make repeated connections fast

Every new ssh through a chain repeats every handshake. Connection sharing reuses the first:

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

The second ssh, scp or git push to the same host then opens in a fraction of a second.

Lock down the bastion

A bastion’s job is to forward, so let its users do nothing else. In the bastion’s /etc/ssh/sshd_config:

Match Group jumpers
    AllowTcpForwarding yes
    PermitOpen 10.0.3.4:22 10.0.3.5:22
    AllowAgentForwarding no
    X11Forwarding no
    PermitTTY no
    ForceCommand /usr/sbin/nologin
  • PermitOpen limits where a forwarded connection may go. It takes host:port pairs, and * stands for any host or any port.
  • PermitTTY no and ForceCommand deny a shell. ProxyJump still works, because it never asks for one.
  • Keys only: PasswordAuthentication no.

Before you save a change like this on a machine you reach over SSH, read how to edit sshd_config without locking yourself out.

When it fails

A chain can break at any hop, and OpenSSH reports it in one line. The usual suspects:

MessageMeaning
channel 0: open failed: administratively prohibitedThe bastion does not allow forwarding, or PermitOpen excludes the target
channel 0: open failed: connect failed: Connection refusedThe bastion reached the target, and nothing listens on that port
channel 0: open failed: connect failed: Name or service not knownThe bastion cannot resolve the target’s name
Permission denied (publickey)A login failed. Read the user@host in front of it to see which hop

There is a full walk-through in Debugging a failed ProxyJump chain, hop by hop.

Jump chains in Gatesys SSH

Gatesys SSH builds chains the same way OpenSSH does, with direct-tcpip channels, so a bastion that works with ssh -J works with it. What it adds:

  • Import. File › Import from ~/.ssh/config… turns ProxyJump lines, comma-separated chains included, into saved jump chains.
  • One connection per host. The terminal, the SFTP browser, tunnels and metrics share one session, so the chain is negotiated once.
  • A diagnosis when it breaks. Hop Doctor names the hop and the step, and never logs in a second time to find out.

All of it is in the free plan, with no account.

FAQ

What is the difference between ProxyJump and ProxyCommand?

ProxyJump host is shorthand for ProxyCommand ssh -W %h:%p host. Use ProxyJump when the thing in the middle is an SSH server, and ProxyCommand when it is some other kind of proxy.

Can the jump host read my session?

No. Your client runs a separate, end-to-end encrypted SSH session to the target through the jump host. The jump host sees the destination address and the volume of traffic, not its content.

Do I need my private key on the jump host?

No, and you should not put it there. Every hop is authenticated from your own computer, using your local key or agent.

Does ProxyJump need netcat on the bastion?

No. It uses the SSH server’s built-in forwarding. Only very old setups with ProxyCommand ssh bastion nc %h %p needed nc.

Which OpenSSH version do I need?

ProxyJump and -J arrived in OpenSSH 7.3. Only the client needs it. The servers just need to allow TCP forwarding.

Keep reading

All posts
Guides

Debugging a failed ProxyJump chain, hop by hop

An SSH connection through a bastion can fail at five steps on every hop. How to read the error, find the hop that broke, and test from the hop in front of it.

6 min read

Guides

SSH port forwarding explained: -L, -R and -D

Local, remote and dynamic SSH tunnels with examples you can paste: reach a database behind a bastion, expose a local port, or run a SOCKS proxy.

5 min read

Your servers, understood.

Download Gatesys SSH free. Start Pro when you want Farabi — the first 3 months are on us.