Skip to content

SSH Tunnels and Jump Hosts: Getting Traffic Through a Middle Machine ​

A jump host exists because of a routing fact: your laptop has no path to the internal network, but a machine you can reach has a second leg into it. Everything else people expect from a bastion — one login, a shared key, a single obvious target — is a management decision layered on top of that path, and each layer can fail independently.

The same SSH connection can also carry arbitrary TCP streams, which is what -L, -R, and -D do. These are usually taught as separate topics, but in real work they interlock: the jump host is what lets a forward reach a machine you cannot otherwise see, and a forward is frequently the only way to prove the jump chain works at all. Treating "I can SSH through the bastion" and "traffic gets to the database" as the same success is the root of most confusing tickets.

Where an SSH tunnel actually sends your traffic ​

Before arguing about flags, separate two questions that people constantly merge:

  • Which side listens — your machine, or the SSH server's machine?
  • Which side connects out — and therefore whose routing table and DNS view decides whether the target address resolves?

-L listens locally and connects out from the server. -R listens on the server and connects out from your machine. -D listens locally and lets the application pick a target per connection. Those two columns explain most "the tunnel is up but nothing answers" reports before you touch a firewall.

The client version decides which options exist and which defaults apply, so start by knowing what you are running:

bash
$ ssh -V
OpenSSH_9.2p1 Debian-2+deb12u5, OpenSSL 3.0.15 3 Sep 2024

ssh -G resolves the configuration for a host without connecting. It is the most useful single command for arguing with defaults, because it prints what the client decided, not what your config file appears to say:

bash
$ ssh -G -L 8080:localhost:80 example.com | head -20
host example.com
user root
hostname example.com
port 22
addressfamily any
batchmode no
canonicalizefallbacklocal yes
canonicalizehostname false
checkhostip no
compression no
controlmaster false
enablesshkeysign no
clearallforwardings no
exitonforwardfailure no
fingerprinthash SHA256
forwardx11 no
forwardx11trusted yes
gatewayports no
gssapiauthentication yes

Three of those lines decide how a tunnel behaves during an incident. clearallforwardings no means your command-line forwards survive configuration merges. exitonforwardfailure no means the session stays up even when the forward does not — a failed tunnel still gives you a working shell, which is exactly why people believe the tunnel is fine. And gatewayports no is the default that makes remote forwards loopback-only.

ProxyJump, multi-hop chains, and what -W does differently ​

ProxyJump does not magically route packets. The client opens an SSH connection to the bastion, then carries the second SSH connection to the final target inside that first one. Every hop authenticates on its own terms, with its own user, key, and host fingerprint. On the wire this is exactly the old ProxyCommand trick; ProxyJump is shorthand for it.

The cleanest way to see what the client builds is to let it expand the option:

bash
$ ssh -G -J bastion.example.com internal-app | grep -Ei '^(proxyjump|hostname|user|port)'
user root
hostname internal-app
port 22
userknownhostsfile /root/.ssh/known_hosts /root/.ssh/known_hosts2
proxyjump bastion.example.com

The extra userknownhostsfile line appears because it also begins with user — a reminder that ad-hoc grepping of generated config can surprise you as much as reading config files.

-W is the piece people confuse with ProxyJump. -W host:port puts the SSH client into a deliberate netcat mode: it pipes standard input and output straight to that TCP port on the far side. ProxyJump is built on this idea, and you can write it out by hand when you need behaviour the shorthand does not cover:

bash
$ ssh -G -o ProxyCommand='ssh -W %h:%p bastion.example.com' internal-app | grep -Ei '^(proxycommand|proxyjump|hostname)'
hostname internal-app
proxycommand ssh -W %h:%p bastion.example.com

So the distinction is about role, not capability: -W is a transport primitive useful inside ProxyCommand and in scripts, while ProxyJump is the host-list feature that also drives scp and sftp. Multi-hop is written as a comma chain — -J hop1,hop2 or ProxyJump hop1,hop2 — and each added hop adds one more authentication, one more place to hold credentials, and one more hop that can time out. Prefer the shortest chain that actually reaches the target, and give each hop its own key rather than reusing one privileged key everywhere.

Local forwarding with -L ​

Local forwarding is the workhorse. You listen on your own machine and ask the SSH server to make the outbound connection:

bash
ssh -N -L 127.0.0.1:8080:localhost:80 deploy@bastion.example.com

-N means "no remote command" — hold the connection open and do nothing else. The middle address, localhost:80, is interpreted from the SSH server's point of view. That single sentence resolves a large share of port-forwarding confusion: if the database is reachable from the application server but not from the bastion, then -L pointed at the bastion will not find it, no matter what your laptop's routing table says. Your local routing table is irrelevant to what the far end can reach:

bash
$ ip route
default via 192.168.31.1 dev ens18 onlink 
172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1 
172.18.0.0/16 dev br-dddc48ca25c5 proto kernel scope link src 172.18.0.1 linkdown 
192.168.31.0/24 dev ens18 proto kernel scope link src 192.168.31.101

Bind the local side to 127.0.0.1 unless you have a specific reason not to. Binding to 0.0.0.0 invites every device that can reach your laptop — including anything on a shared Wi-Fi network — to use your authenticated tunnel as a free path into the internal network. The convenience of reaching the tunnel from a phone or a VM is real, but it should be a deliberate decision backed by a firewall rule, not a side effect of a habit.

Remote forwarding with -R and the loopback trap ​

Remote forwarding inverts the direction: the SSH server listens, and the connection is carried back to your machine.

bash
ssh -N -R 127.0.0.1:9090:localhost:8080 deploy@bastion.example.com

The immediate question is whether anything except the server itself can reach port 9090. By default, no. OpenSSH's server-side default is GatewayPorts no, which means a remote forward binds the loopback interface only. This is a default worth verifying rather than assuming, and the server configuration shows its intent clearly:

bash
$ grep -nEi 'gatewayports|allowtcpforwarding|permitopen' /etc/ssh/sshd_config
87:#AllowAgentForwarding yes
88:#AllowTcpForwarding yes
89:#GatewayPorts no

Lines beginning with # are comments — the values shown are the compiled-in defaults, and GatewayPorts no is therefore still in force. To let other machines use the remote port you need GatewayPorts clientspecified (or yes), a listen address that is not loopback, and a firewall rule that matches. Note that a graphical client form showing 0.0.0.0 proves nothing about the server: the server may force loopback, or refuse remote forwarding entirely through AllowTcpForwarding and PermitOpen. A -R that connects but stays unreachable from elsewhere is usually policy, not a bug.

Remote forwarding also fails loudly in a way that confuses scripts. ExitOnForwardFailure defaults to no, so a session can succeed while the tunnel does not:

bash
$ timeout 8 ssh -o BatchMode=yes -o ConnectTimeout=4 -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -N -L 18080:127.0.0.1:80 example.com
$ echo "exit=$?"
exit=124

The captured output is the timeout exit status on its own: the command produced no diagnostic line and simply never connected, which is what exit=124 records. Set ExitOnForwardFailure yes for anything automated so a failed forward fails the command instead of leaving a half-working session behind.

Dynamic forwarding with -D: a SOCKS proxy over SSH ​

Dynamic forwarding does not map one port to one service. It exposes a SOCKS proxy on your machine, and each client request names its own target:

bash
ssh -N -D 127.0.0.1:1080 deploy@bastion.example.com

This is the option to reach when you need many destinations through one authenticated connection — a browser, a database tool, or a CLI that understands SOCKS5. Test it with an explicit proxy rather than trusting the listener alone:

bash
curl --socks5-hostname 127.0.0.1:1080 https://example.com/ -I

The -hostname variant matters. --socks5-hostname sends the hostname to the proxy so DNS happens on the far side, while plain --socks5 resolves locally and then forwards the address. The second form can leak internal names to your local resolver and can also fail when split-horizon DNS returns a public address that means something different inside the network. Whichever you choose, be explicit about where the name is resolved, because that is the difference between a working tunnel and a confusing one.

Agent forwarding is a trust decision, not a convenience toggle ​

ssh -A forwards your ssh-agent socket to the remote host rather than copying a private key. That sounds safer, and often it is — nothing permanent lands on the server. The catch is what the forwarded socket can do while the connection is open: a process on the far side can ask your agent to sign authentication requests, and the agent will comply. Anyone who controls that host during the window can use your credentials to reach whatever else accepts them, without ever holding your key file.

So the decision is about trust in the middle machine, not about convenience. If the bastion is the least-trusted box in the path, do not put your agent socket on it. ProxyJump is usually the better answer: the final host authenticates directly, and you avoid parking a signing capability on an intermediate machine you do not fully control. Where a bastion genuinely must contribute authentication — typically a keyboard-interactive one-time-code prompt on top of key auth — keep the credentials scoped to that hop and keep every hop's identity distinct; the automatic OTP interactive authentication documentation covers matching those prompts reliably instead of typing codes by hand.

Verifying a tunnel and reading the failures ​

Start from what is actually listening, not from what you believe you passed on the command line:

bash
$ ss -tunlp | head -8
Netid State  Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess                                   
udp   UNCONN 0      0            0.0.0.0:39883      0.0.0.0:*    users:(("avahi-daemon",pid=398632,fd=14))
udp   UNCONN 0      0            0.0.0.0:5353       0.0.0.0:*    users:(("avahi-daemon",pid=398632,fd=12))
udp   UNCONN 0      0               [::]:52089         [::]:*    users:(("avahi-daemon",pid=398632,fd=15))
udp   UNCONN 0      0               [::]:5353          [::]:*    users:(("avahi-daemon",pid=398632,fd=13))
tcp   LISTEN 0      2048         0.0.0.0:9119       0.0.0.0:*    users:(("hermes",pid=1352629,fd=11))     
tcp   LISTEN 0      128          0.0.0.0:22         0.0.0.0:*    users:(("sshd",pid=644,fd=3))            
tcp   LISTEN 0      10         127.0.0.1:9222       0.0.0.0:*    users:(("chromium",pid=6408,fd=55))

Read the Local Address:Port column against the state. 0.0.0.0:22 and 0.0.0.0:9119 accept connections from any interface; 127.0.0.1:9222 is loopback-only. A forward you expected to see as loopback but that appears as 0.0.0.0 is a security event, not a cosmetic detail, and this column is where GatewayPorts and bind-address mistakes show up.

When nothing appears at all, raise the log level before guessing. The first lines of a verbose attempt tell you whether the failure is before or after the encrypted channel exists:

bash
$ timeout 8 ssh -v -o BatchMode=yes -o ConnectTimeout=4 -o StrictHostKeyChecking=no -N -L 18082:127.0.0.1:22 127.0.0.1 2>&1 | head -8
OpenSSH_9.2p1 Debian-2+deb12u5, OpenSSL 3.0.15 3 Sep 2024
debug1: Reading configuration data /root/.ssh/config
debug1: Reading configuration data /etc/ssh/ssh_config
debug1: /etc/ssh/ssh_config line 19: include /etc/ssh/ssh_config.d/*.conf matched no files
debug1: /etc/ssh/ssh_config line 21: Applying options for *
debug1: Connecting to 127.0.0.1 [127.0.0.1] port 22.
debug1: fd 3 clearing O_NONBLOCK
debug1: Connection established.

The channel reached Connection established, so anything after that point is authentication or forwarding policy, not connectivity. Pushing the same attempt to completion shows the failure that follows:

bash
$ timeout 8 ssh -o BatchMode=yes -o ConnectTimeout=4 -o StrictHostKeyChecking=no -N -L 18081:127.0.0.1:22 127.0.0.1
Warning: Permanently added '127.0.0.1' (ED25519) to the list of known hosts.
root@127.0.0.1: Permission denied (publickey).

This is the honest shape of tunnel debugging: the network leg succeeded and the identity leg did not, so no amount of firewall inspection will help. The messages worth recognizing are Permission denied (publickey) (credentials, before any forward exists), bind: Address already in use (local port taken — find the owner rather than changing ports blindly), administratively prohibited (server policy such as AllowTcpForwarding/PermitOpen, which should be resolved with the server owner, not bypassed), and connect failed: Connection refused inside a working session — that one means the forward is fine and the far-side target is not.

Connection reuse, and why a graphical client still needs the same mental model ​

Every additional forward normally means another authentication round trip, another host-key prompt, and another password or touch on a hardware key. Connection multiplexing removes that cost by reusing one transport. The resolved values make the trap visible — ControlMaster is only useful when a ControlPath exists:

bash
$ ssh -G -o ControlMaster=auto -o ControlPersist=10m -L 8080:localhost:80 example.com | grep -Ei '^(controlmaster|controlpersist|controlpath)'
controlmaster auto
controlpersist 600

ControlPath is empty here, so multiplexing is effectively off until you set a socket path. Once you do, you can inspect and tear down the shared connection explicitly instead of killing terminals:

bash
$ timeout 5 ssh -o BatchMode=yes -o ControlPath=/tmp/tm-demo-%r@%h:%p -O check example.com
Control socket connect(/tmp/tm-demo-root@example.com:22): No such file or directory

That real message just means no master is running for this socket yet — -O check reports state, it does not create one. Reuse and forwarding interact in a way that bites teams: a forward started on a shared master outlives the terminal you started it in, so verifying ownership and shutting down the master is part of cleaning up.

A graphical SSH client does not change any of this; it changes where the state lives. Termark's port-forwarding rules map onto the same -L, -R, and -D model and reuse an existing host entry, so the local-listen-address question, the server-side GatewayPorts question, and the "is anything actually listening" check all apply unchanged — the port forwarding documentation describes which fields correspond to which flag. Keeping host definitions consistent across machines is the other half of staying oriented during an incident, and that is what cloud sync is for. The tool is convenient; the model is still yours to hold. If you would rather manage those hosts and forwards from a graphical workspace, Termark is available here.

Termark · SSH client and terminal workspace