●moray: ssh, sftp & terminal

Jump Hosts and ProxyJump: Reaching Servers Behind a Bastion

Why servers sit behind a bastion, how to configure ProxyJump for multi-hop SSH, and why it replaced the agent forwarding that lends out your identity.

Tunnels · Bartłomiej Żabicki · updated 2026-07-26 · 8 min read

Sooner or later you hit a server that refuses to talk to you. Not a wrong password — the connection simply times out, from your laptop, from your phone, from anywhere except one particular machine. That machine is a bastion, and reaching past it is what jump hosts are for.

Why bastions exist

A bastion (or jump host) is the one server allowed to accept SSH from the outside world. Everything else in the network accepts SSH only from the bastion's address.

The reasoning is simple arithmetic. Thirty servers with port 22 open to the internet is thirty machines to patch, thirty sshd configs to keep correct, and thirty sets of auth logs nobody reads. One bastion is one door: one thing to harden, one place where every session is logged, one address to revoke when a laptop is lost.

you ──► bastion.example.com ──┬──► web1.internal
        (public :22)          ├──► web2.internal
                              └──► db.internal

The servers behind it usually have no public address at all, which is the real security benefit — there is nothing to scan.

ProxyJump: the modern answer

Since OpenSSH 7.3 the whole thing is one option.

ssh -J deploy@bastion.example.com deploy@db.internal

Or, permanently, in ~/.ssh/config:

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

Host db
    HostName db.internal
    User deploy
    ProxyJump bastion
    IdentityFile ~/.ssh/id_ed25519

Now ssh db works from anywhere. What happens under the hood matters:

  1. Your client opens a normal SSH connection to the bastion and verifies the bastion's host key.
  2. It asks the bastion to open a plain TCP channel to db.internal:22 — nothing more.
  3. Your client runs a second, complete SSH handshake with db through that channel, and verifies db's own host key.
  4. Authentication to db happens between your device and db. The bastion carries encrypted bytes it cannot read.

That last point is the whole game: the bastion is a pipe, not a participant. It never sees your key material and never sees your session.

Why not agent forwarding

The older pattern was to SSH into the bastion with -A, then SSH onward from there, using your forwarded agent to authenticate.

It works, and it is a genuine security problem.

Agent forwarding places a socket on the bastion that anyone able to read it can use to ask your agent to sign challenges — as you, to any server that trusts your key. Root on the bastion, a compromised process running as your user, or a hostile administrator can therefore authenticate to every machine your key opens, for as long as your session is live. You will not see it happen; signing requests look identical to your own.

ProxyJump removes the reason to forward anything: your key never leaves your device, and the bastion is not asked to help with authentication at all.

If you are stuck with an environment that genuinely requires agent forwarding, at minimum constrain it — and read ssh-agent explained for what you are actually exposing:

Host bastion
    ForwardAgent yes
    AddKeysToAgent confirm     # prompt for every signature

confirm turns silent abuse into a visible prompt. It is a mitigation, not a fix. Prefer ProxyJump.

Chains, and what each hop verifies

ProxyJump takes a comma-separated list, traversed left to right:

ssh -J deploy@edge.example.com,deploy@bastion.internal deploy@db.internal

Each hop performs its own host-key verification. So a changed key anywhere in the chain still produces a warning attributable to a specific machine, which is exactly the property you want from a multi-hop path.

Practically, two hops is common (an edge relay plus a per-environment bastion) and three is a sign that someone should draw the network diagram again.

ProxyCommand: the old way, and when you still need it

Before ProxyJump, the same effect required spawning netcat on the bastion:

Host db-legacy
    HostName db.internal
    ProxyCommand ssh -W %h:%p bastion

-W is the built-in replacement for nc, so this form needs nothing installed on the bastion. It is equivalent to ProxyJump for the simple case; ProxyJump is just the readable spelling.

ProxyCommand earns its keep when the transport is not SSH — for example reaching an instance through a cloud provider's session manager, or through a TLS-wrapping helper:

Host ec2-*
    ProxyCommand aws ssm start-session --target %h \
      --document-name AWS-StartSSHSession --parameters portNumber=%p

If your only need is "hop through a machine that speaks SSH", use ProxyJump.

Making jump hosts work from a phone

Two details matter more on iOS than on a laptop.

The jump host's key must be available too. A jump connection authenticates twice — once to the bastion, once to the target. If your bastion accepts a different key from your app servers, the client needs both, and both need to be on the device.

Latency compounds. Each hop adds a round trip to connection setup, and a phone on cellular already has 40–80 ms to spare. A two-hop connect that feels instant on a laptop takes a noticeable beat on a phone. Persistent connections help:

Host *
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m

That is a laptop-side optimisation — connection multiplexing reuses one authenticated connection for subsequent sessions. On iOS, the equivalent is simply that the app keeps the bastion transport alive for as long as the session it carries.

Common failures, in the order they happen

SymptomCause
Times out with no promptYou are not going through the bastion at all — check ProxyJump actually applies to this host pattern
Permission denied at the bastionWrong user or key for the bastion, not the target
Permission denied at the targetBastion hop succeeded; the target does not trust your key
channel 0: open failed: administratively prohibitedThe bastion's sshd forbids opening channels — AllowTcpForwarding no
Host key warning naming an internal hostThe target was rebuilt, or something is impersonating it. Verify before accepting
Works from laptop, not from phoneThe device is missing one of the two keys involved

ssh -v is worth its weight here: it prints each hop as it is established, so you can see exactly which handshake failed rather than guessing.

The short version

  • A bastion exists so there is one door to secure and audit.
  • Use ProxyJump (-J, or ProxyJump in config). It verifies every hop's host key and keeps your key on your device.
  • Do not use agent forwarding to hop. It lends your identity to whoever controls the bastion.
  • Chains are supported and each hop is verified independently.
  • Keep ProxyCommand for non-SSH transports like cloud session managers.

Related: SSH port forwarding explained for reaching a service rather than a shell, and SSH keys explained for managing the two keys a jump connection needs.

Written by Bartłomiej Żabicki

iOS developer writing Swift since 2015, with a homelab he administers from an iPhone more often than he would like to admit. Moray is the SSH client he wanted to exist.

Frequently asked

What is a bastion host?
A single hardened server that is allowed to accept SSH from the outside world. Everything else in the network only accepts SSH from the bastion, so there is exactly one door to audit, patch and monitor instead of dozens.
Is ProxyJump better than agent forwarding?
Yes, and it is the reason ProxyJump exists. Agent forwarding lets anyone with root on the bastion use your loaded keys to authenticate elsewhere as you. ProxyJump instead tunnels an end-to-end connection through the bastion, so your key is only ever used against the machine you meant to reach.
Can I chain more than one jump host?
Yes — ProxyJump takes a comma-separated list and each hop is traversed in order. Each hop still verifies its own host key, so you get a warning if any machine in the chain changes identity.

try it yourself

Moray does all of this natively

A real terminal, dual-pane SFTP, tunnels and jump hosts on iPhone, iPad and Mac. Keys stay in the Keychain, hosts sync over your own iCloud, and there is no account to make.