SSH Deep Dive Part 3: Tunnels and Jump Hosts, -L, -R, -D, and -J

Parts 1 and 2 covered connecting and not repeating yourself. This post covers using SSH to reach things that aren’t directly reachable at all, port forwards, a SOCKS proxy, and jump hosts. These same four flags show up in Pivoting and Tunneling Part 1 on this site, framed for getting an attacker’s traffic through a segmented network. The mechanics are identical here; the difference is entirely who’s using them and why. A sysadmin reaching a database console through a bastion and a pentester reaching a payroll server through a compromised file share are running the exact same command.

-L: local forwarding, reaching one thing through a host you can already reach

-L binds a port on your own machine and forwards anything that connects to it, through the SSH connection, to a destination the remote end can reach. The shape is: something local, that isn’t directly reachable, made reachable through a host that already has a route to it.

A common case: a database or admin UI that’s firewalled to only accept connections from inside the network, but you have SSH access to a bastion or jump box that sits inside it.

ssh -L 5432:db.internal:5432 michealg@bastion.example.com

Connections to 127.0.0.1:5432 on your own machine now get forwarded through bastion to db.internal:5432, a host bastion can reach but you can’t directly. Point psql -h 127.0.0.1 at it and it behaves exactly as if you were on the internal network yourself. Add -N to skip opening an interactive shell, since you just want the forward, and -f to background the connection:

ssh -N -f -L 5432:db.internal:5432 michealg@bastion.example.com

This is also how you reach a web admin UI, a Grafana dashboard, or a Proxmox web console that only listens on an internal address: forward the relevant port, then browse to 127.0.0.1:<port> as if it were local.

-R: remote forwarding, the far end calls back to you

-R runs the same idea backwards. Instead of you reaching in, a port on the remote host gets forwarded back to something on your side:

ssh -N -R 8080:127.0.0.1:3000 michealg@public-host.example.com

Anything that connects to public-host.example.com:8080 now reaches port 3000 on your own machine. The legitimate everyday use case: sharing something running on your laptop, a local dev server, a webhook receiver during testing, with a host that has a public address and yours doesn’t. It’s the same primitive Part 1 of the Pivoting and Tunneling series uses to get a foothold host to phone home through an outbound-only connection, just running in the other direction from a trust perspective: there, the concern is a compromised host reaching out; here, it’s your own laptop borrowing a public host’s reachability on purpose.

-D: dynamic forwarding, a SOCKS proxy for anything

-L forwards to one fixed destination. -D turns the SSH client itself into a SOCKS5 proxy, so any SOCKS-aware application can route arbitrary destinations through the remote end, not just one:

ssh -N -f -D 1080 michealg@bastion.example.com

127.0.0.1:1080 is now a SOCKS5 proxy that reaches wherever bastion can reach. Point a browser’s proxy settings, or curl --socks5 127.0.0.1:1080, at it, and traffic routes through bastion without a separate -L for every single destination. This is the practical choice when there are several internal services to reach in one session rather than just one, browsing an entire internal network’s web consoles through a bastion, for example, instead of forwarding a dozen individual ports.

The limitation is the same one covered in the Pivoting and Tunneling post: only applications that know how to speak SOCKS use -D directly. Everything else needs a SOCKS-aware wrapper, which for legitimate admin tooling is rarely a problem since most modern browsers, curl, and psql-adjacent clients all support a --proxy or equivalent flag natively.

-J: jump hosts, chaining through a host that isn’t the destination

-J (ProxyJump, same thing, flag or config directive) is different in kind from the three forwards above: it’s not about exposing a port, it’s about routing the SSH connection itself through one or more intermediate hosts to reach a destination that isn’t directly reachable at all.

ssh -J michealg@bastion.example.com michealg@10.10.10.63

Reading it: connect to bastion.example.com first, then from there, open a second SSH connection to 10.10.10.63, an isolated lab host in this example, and hand you a shell on the final destination. The two hops are transparent, from the terminal’s perspective it looks like one connection, but two separate SSH sessions are actually involved, one to the jump host, one from the jump host onward.

This is exactly the pattern from Part 2’s config example:

Host bastion
    HostName 203.0.113.10
    User michealg
    IdentityFile ~/.ssh/id_ed25519

Host myhost
    HostName 192.168.1.63
    User michealg
    IdentityFile ~/.ssh/id_ed25519
    ProxyJump bastion

With that saved, ssh myhost transparently routes through bastion first, no -J needed on the command line at all, the config file remembers it. Multiple hops chain by separating hosts with commas:

ssh -J michealg@bastion1.example.com,michealg@bastion2.example.com michealg@10.10.10.63

Each hop in that chain needs its own key or agent-forwarded access; -J doesn’t grant access, it just routes the connection through hosts you can already reach individually. -J replaced an older, clunkier pattern of nesting ProxyCommand with nc or ssh -W, if you ever see that in an old config file, it’s doing the same job -J/ProxyJump now does in one flag.

Picking the right one

  • Need to reach one specific internal service, a database, an admin UI, from your own machine: -L.
  • Need a host you’re already on to expose something back to you, sharing a local dev server outward: -R.
  • Need to reach several internal destinations through one connection, not just one: -D plus a SOCKS-aware client.
  • Need a shell, or any connection at all, on a host that’s only reachable through another host: -J / ProxyJump.

They combine, too. A ProxyJump’d connection can carry a -L forward in the same command, reaching a database through a jump host you’re also getting a shell on, in one line:

ssh -J michealg@bastion.example.com -L 5432:db.internal:5432 michealg@10.10.10.63

Where this fits

That’s the full arc: keys and getting connected in Part 1, the config file and not repeating yourself in Part 2, and reaching things you can’t connect to directly here. For the same four flags used adversarially, working around a compromised host’s outbound-only egress and stacking them into full pivot chains, Pivoting and Tunneling picks up where this post leaves off. For hardening the server side of all of this, certificate authorities instead of static keys, bastion patterns, and session auditing, see SSH Hardening Beyond the Basics.