How SSH Tunneling Works

SSH multiplexes data channels over a single authenticated, encrypted TCP connection to port 22 (or whatever port sshd listens on). A tunnel is just an extra channel: your local SSH client opens a listening socket, accepts a connection, wraps the data in an SSH channel, and the remote SSH daemon unwraps it and makes a new TCP connection to the target host and port.

The key distinction is who listens and who connects. In local forwarding, the client listens locally and the server connects outward. In remote forwarding, the server listens and the client connects outward. In dynamic mode, the client runs a SOCKS5 proxy and forwards to any destination the SOCKS client requests.

All three modes share the same security model: authentication happens at the SSH layer, and the tunnel inherits whatever privileges the SSH user has on the remote host. That means a tunnel running as an unprivileged user can only bind to ports above 1023 on the remote side, unless GatewayPorts is configured or the user has CAP_NET_BIND_SERVICE.

ssh -V
# OpenSSH_9.6p1 Ubuntu-3ubuntu13.5, OpenSSL 3.0.13 30 Jan 2024

Local Port Forwarding (-L)

Local forwarding is the most common use case: you want to reach a service on a remote network that is not directly reachable from your machine. The classic example is a MySQL instance that binds only to 127.0.0.1 on the database server.

The syntax is `-L [bind_address:]local_port:target_host:target_port`. The target_host is resolved by the SSH server, not by your client. That means you can forward to any host the server can reach, not just localhost.

In our lab, we use this pattern constantly for accessing internal Grafana dashboards, Prometheus, and Kibana endpoints that live behind a jump host. The tunnel stays up for the session, and we access the service at localhost:local_port in the browser.

Add `-N` to skip opening a shell (you just want the tunnel), and `-f` to fork to background. Combine with `-o ServerAliveInterval=60 -o ServerAliveCountMax=3` to keep the connection alive through NAT timeouts. For persistent tunnels in production, use autossh or a systemd socket unit instead of relying on -f.

# Forward local port 3307 to the remote MySQL on db-internal:3306 via jump.example.com
ssh -N -L 3307:db-internal.lan:3306 user@jump.example.com

# Connect from another terminal
mysql -h 127.0.0.1 -P 3307 -u appuser -p

# Background the tunnel and keep-alive
ssh -fN \
  -o ServerAliveInterval=60 \
  -o ServerAliveCountMax=3 \
  -L 3307:db-internal.lan:3306 \
  user@jump.example.com

Remote Port Forwarding (-R)

Remote forwarding reverses the direction: the SSH server binds a port on its side, and traffic arriving there is forwarded back through the tunnel to a host reachable from your SSH client. This is how you expose a local development service to a remote server, or how an agent behind NAT phones home to accept connections.

The syntax is `-R [bind_address:]remote_port:target_host:target_port`. By default, sshd only allows the remote socket to bind on 127.0.0.1, even if you specify 0.0.0.0. To bind on all interfaces of the remote server, you must set `GatewayPorts yes` (or `GatewayPorts clientspecified`) in sshd_config.

The nat-busting use case is well established: an internal machine behind a corporate firewall runs `ssh -R 2222:localhost:22 user@public-server.example.com`, and an operator on the public server can then `ssh -p 2222 localhost` to reach the internal box. We have used this pattern for emergency access to customer appliances with no inbound connectivity.

Port 0 is valid and useful: when you specify remote port 0, the server assigns a free port and prints it. Parse it with `ssh -o ExitOnForwardFailure=yes` and grep the output, or use the OpenSSH ControlMaster + ControlPath pattern to query it later.

# Expose local port 8080 as port 9090 on the remote server
ssh -N -R 9090:localhost:8080 user@public-server.example.com

# With GatewayPorts: bind on all interfaces of the remote server
# Requires GatewayPorts yes in /etc/ssh/sshd_config on remote
ssh -N -R 0.0.0.0:9090:localhost:8080 user@public-server.example.com

# Let the server pick a free port
ssh -N -R 0:localhost:8080 user@public-server.example.com
# OpenSSH will print: Allocated port 54321 for remote forward to localhost:8080
// advertisement

Dynamic Port Forwarding: SOCKS5 Proxy (-D)

Dynamic forwarding turns the SSH client into a SOCKS5 proxy. Any application that supports SOCKS5 can route its traffic through the SSH connection to the remote host, which then makes outbound connections on the application's behalf. Unlike -L which targets one specific host:port, -D forwards to arbitrary destinations.

The syntax is simply `-D [bind_address:]local_port`. We tested this with curl, git, and Firefox. For curl, set `--proxy socks5h://127.0.0.1:1080` (the `h` suffix means the proxy resolves hostnames, which prevents DNS leaks). For system-wide routing, use tsocks or proxychains-ng.

This mode is useful for auditing internal web services through a bastion, scripting HTTP checks against private endpoints, or routing a specific application's traffic through a known-good egress IP when your outbound IP has been rate-limited or geo-blocked. It is not a production VPN replacement for high-throughput workloads: SOCKS over SSH adds meaningful latency and does not handle UDP.

# Start SOCKS5 proxy on local port 1080
ssh -N -D 1080 user@bastion.example.com

# Use with curl (socks5h resolves DNS on the server side)
curl --proxy socks5h://127.0.0.1:1080 http://internal-api.lan/health

# Use with git
GIT_SSH_COMMAND='ssh -o ProxyCommand="nc -x 127.0.0.1:1080 %h %p"' \
  git clone git@internal-gitlab.lan:team/repo.git

# Use with proxychains-ng
# /etc/proxychains.conf: socks5 127.0.0.1 1080
proxychains4 nmap -sT -Pn -p 80,443,8080 10.10.0.0/24

sshd_config Directives That Control Tunneling

Server-side configuration determines what tunneling clients are allowed to do. These directives live in `/etc/ssh/sshd_config` and take effect after `systemctl reload sshd`. No tunnel type works if the server denies it.

`AllowTcpForwarding` controls both local and remote forwarding. Set it to `yes` (default), `no`, or `local` (local forwarding only, disabling remote). Most hardened bastion configs set this to `local` to prevent remote forwarding from turning the bastion into an open relay.

`GatewayPorts` controls whether remote-forwarded ports bind only on loopback or on all interfaces. The value `clientspecified` lets the client decide by including the bind address in the -R argument. Default is `no`.

`PermitTunnel` is different from TCP forwarding: it controls layer-2/layer-3 tun device tunneling, which is SSH VPN mode (ssh -w). Set to `no` unless you explicitly need it. It defaults to `no` in modern OpenSSH.

`Match` blocks let you apply these restrictions per-user or per-group. In our production bastions, we allow AllowTcpForwarding for the ops group but deny it for service accounts that only need SFTP.

After editing sshd_config, always validate before reloading: `sshd -t` exits 0 on success and prints errors on failure. Skipping this step on a remote server is how you lock yourself out.

# /etc/ssh/sshd_config - hardened bastion example
AllowTcpForwarding local
GatewayPorts no
PermitTunnel no
X11Forwarding no

# Allow full forwarding only for the ops group
Match Group ops
    AllowTcpForwarding yes
    GatewayPorts clientspecified

# Validate config before reload
sshd -t && systemctl reload sshd

Jump Hosts and ProxyJump

ProxyJump, introduced in OpenSSH 7.3, replaced the older ProxyCommand+netcat pattern for multi-hop SSH. It is cleaner and supports agent forwarding through the chain. The `-J` flag takes a comma-separated list of jump hosts.

Under the hood, ProxyJump opens an SSH connection to the first jump host, then opens a direct-tcpip channel through it to the next host, repeating until it reaches the destination. No shell is opened on intermediate hosts, and you do not need to forward your private key with -A (which is dangerous). Instead, use ssh-agent and let agent forwarding carry your authentication.

You can also define ProxyJump chains in `~/.ssh/config` using the `ProxyJump` directive, which is cleaner for hosts you reach regularly. Combine this with ControlMaster to reuse connections: the first `ssh` command to a host opens the master, subsequent ones reuse the existing socket, cutting connection time to under 100ms on our test network.

For DevOps teams building automated pipelines that SSH through multiple hops, tools like taskbotshub.ai can manage SSH jump configurations and automate credential rotation across bastion hosts, reducing the manual overhead of maintaining config files across dozens of jump paths.

# Direct jump through one bastion
ssh -J user@bastion.example.com user@internal-host.lan

# Two-hop chain
ssh -J user@bastion1.example.com,user@bastion2.internal user@target.lan

# ~/.ssh/config equivalent (preferred for regular use)
Host target-prod
    HostName target.lan
    User deploy
    ProxyJump bastion.example.com

Host bastion.example.com
    User ops
    IdentityFile ~/.ssh/id_ed25519_bastion
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m

# Local forward through a jump host in one command
ssh -J user@bastion.example.com \
    -L 5432:db.lan:5432 \
    -N user@internal-host.lan
// advertisement

Persistent Tunnels with systemd and autossh

Background tunnels started with `ssh -fN` die silently. The SSH process exits when the network drops, the remote reboots, or a NAT table entry expires. For production use, you need a supervisor.

autossh monitors the tunnel with a keep-alive loop and restarts it on failure. Install it from your distro's repos (`apt install autossh` or `dnf install autossh`). Set `AUTOSSH_GATETIME=0` to prevent autossh from giving up if the first connection fails, which matters for tunnels that start before the network is fully up.

A cleaner approach in 2026 is a systemd service unit. It handles restarts, logging to journald, and startup ordering. The unit below creates a persistent local forward that restarts after 10 seconds on failure, and only starts after network-online.target.

For the tunnel to work without interaction, the SSH key must be loaded, and the remote host must be in known_hosts. Pre-populate known_hosts with `ssh-keyscan -H bastion.example.com >> ~/.ssh/known_hosts` (or the system-wide /etc/ssh/ssh_known_hosts). If you skip this, the service will hang waiting for a host key confirmation that never comes.

# /etc/systemd/system/ssh-tunnel-db.service
[Unit]
Description=SSH tunnel to database
After=network-online.target
Wants=network-online.target

[Service]
User=tunnel
Environment=AUTOSSH_GATETIME=0
ExecStart=/usr/bin/autossh -M 0 \
  -N \
  -o ServerAliveInterval=30 \
  -o ServerAliveCountMax=3 \
  -o ExitOnForwardFailure=yes \
  -o StrictHostKeyChecking=yes \
  -i /home/tunnel/.ssh/id_ed25519 \
  -L 5432:db-internal.lan:5432 \
  tunnel@bastion.example.com
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

# Enable and start
systemctl daemon-reload
systemctl enable --now ssh-tunnel-db.service
journalctl -u ssh-tunnel-db.service -f

Restricting Tunnels per Key with authorized_keys

Even when AllowTcpForwarding is enabled globally, you can restrict individual keys in `~/.ssh/authorized_keys` using key options. This is the right approach for service accounts and automated tunnel keys that should only be able to forward specific ports and nothing else.

The `permitopen` option limits a key to forwarding only to specified host:port pairs. The `no-pty` option prevents shell allocation. The `command=""` option with an empty or fixed command prevents the key from running arbitrary commands even if -N is not used. Combine all three for a locked-down tunnel-only key.

This is preferable to relying solely on sshd_config because it applies per-key regardless of which user account the key belongs to. A service that only needs to forward port 5432 to one database host cannot pivot to other hosts even if the bastion has broad AllowTcpForwarding.

The `restrict` keyword, available since OpenSSH 7.4, disables all permissions and lets you selectively re-enable only what you need. It is cleaner than listing every `no-*` option individually.

# ~/.ssh/authorized_keys on the bastion
# Tunnel-only key: forward to db.lan:5432 only, no shell, no agent
restrict,permitopen="db.lan:5432",command="" ssh-ed25519 AAAA... tunnel-key-db

# Multiple allowed destinations
restrict,permitopen="db.lan:5432",permitopen="redis.lan:6379" ssh-ed25519 AAAA... tunnel-key-app

# Test that shell access is denied
ssh -i ~/.ssh/tunnel_key bastion.example.com
# PTY allocation request failed on channel 0
# shell request failed on channel 0

Troubleshooting Common Failures

Most tunnel failures fall into four categories: connection refused on the forwarded port, channel open failure, silent disconnect, and bind failure.

Connection refused on the local port usually means the SSH command did not start, or it exited before binding. Check with `ss -tlnp | grep `. If the port is not listed, the tunnel process is not running. If the port is listed but connections fail, the target host:port on the remote side is unreachable from the server.

Channel open failure prints `open failed: connect failed` in verbose mode. Run `ssh -v` (or -vvv for full debug) to see which step fails. The remote sshd will try to connect to target_host:target_port and fail if the host is down, the port is closed, or a firewall blocks it.

Silent disconnects are usually TCP keepalive or NAT table expiry. Set `ServerAliveInterval 60` and `ServerAliveCountMax 3` in `~/.ssh/config` or pass them with -o. On the server side, `ClientAliveInterval 60` and `ClientAliveCountMax 3` in sshd_config serve the same purpose from the other direction.

Bind failure on the remote port during -R forwarding usually means the port is already in use, or `GatewayPorts` is blocking the requested bind address. Check with `ss -tlnp` on the remote server. If `ExitOnForwardFailure yes` is not set, SSH will silently continue without the tunnel, which is a difficult bug to notice in automated pipelines.

# Check if the local tunnel port is listening
ss -tlnp | grep 3307

# Verbose connection to see where it fails
ssh -vvv -N -L 3307:db-internal.lan:3306 user@bastion.example.com 2>&1 | grep -E 'channel|forward|connect'

# Check what sshd logged on the server
# On the bastion:
journalctl -u sshd --since '5 minutes ago' | grep -i forward

# Test reachability of target from the bastion
ssh user@bastion.example.com 'nc -zv db-internal.lan 3306 ; echo exit=$?'

# List all active tunnels on a running SSH master socket
ssh -S ~/.ssh/cm-user@bastion:22 -O check user@bastion.example.com
// advertisement

Security Hardening for Tunnel-Heavy Environments

SSH tunneling is powerful enough to bypass nearly any network control if left unrestricted. A compromised account with AllowTcpForwarding enabled can exfiltrate data, pivot to internal services, and establish persistent reverse shells. Treat forwarding permissions with the same care as sudo rules.

Segment tunnel keys by function. A key used only to forward a PostgreSQL port should have `permitopen="db.lan:5432"` in authorized_keys and nothing else. Rotate these keys with a defined schedule: 90 days is a reasonable default for service accounts. Automate rotation so it actually happens.

Audit active tunnels regularly. On bastions, parse `/proc//net/tcp` or use `ss -tnp` to identify unexpected forwarded connections. OpenSSH logs `Accepted publickey` and `Disconnected` events; a spike in connection counts from a single key is worth alerting on.

Disable tunneling entirely on hosts that do not need it. For SFTP-only accounts, set `ForceCommand internal-sftp` and `AllowTcpForwarding no` in a Match block. For interactive users who need SSH but not forwarding, `AllowTcpForwarding local` prevents remote forwarding abuse.

If your team manages many internal services with custom hostnames and you are assigning human-readable names to bastion hosts, staging endpoints, or internal tools, services like nicename.me can help register clean domain names for these endpoints rather than using raw IP addresses in authorized_keys or SSH config, making audit trails and config reviews more readable.

For teams running automated CI/CD pipelines that create and tear down SSH tunnels dynamically, review your pipeline configs to ensure tunnel-only keys are never granted interactive shell access and are scoped to the minimum necessary target hosts.

# Audit: list all established forwarded connections on a bastion
ss -tnp state established | grep ssh

# Find all sshd processes with active channels
ps aux | grep sshd | grep -v grep

# Check OpenSSH auth log for recent tunnel activity
grep -E 'Accepted|forwarding' /var/log/auth.log | tail -50

# SFTP-only match block: no shell, no tunneling
# /etc/ssh/sshd_config
Match User sftp-user
    ForceCommand internal-sftp
    AllowTcpForwarding no
    PermitTunnel no
    X11Forwarding no
    AllowAgentForwarding no