cheat sheet

SSH Tunnels & Port Forwarding

Local, remote, and dynamic SSH tunnels — port forwarding, SOCKS proxies, jump hosts, ssh_config directives, agent forwarding, autossh persistence, post-quantum key exchange, and operational recipes.

SSH Tunnels & Port Forwarding

What it is

An SSH tunnel uses an already-authenticated SSH connection as an encrypted transport for arbitrary TCP traffic, letting you reach services that would otherwise be unreachable, unencrypted, or firewalled. The three core forwarding modes are local (-L — pull a remote port to your laptop), remote (-R — push a local port out to a server), and dynamic (-D — turn the SSH connection into a SOCKS5 proxy). On top of those primitives, ProxyJump/-J chains tunnels through bastion hosts, ssh_config makes them declarative, autossh keeps them alive, and agent forwarding (-A) lets the far end use your local keys without copying them. Reach for SSH tunnels whenever you need encrypted access to an internal database, a VPN-like SOCKS proxy from a coffee-shop network, or a stable bastion path into a private VPC.

Configuration

SSH on a modern Linux/macOS box reads four files at every invocation, in this precedence order. Knowing which file owns which behaviour is the difference between a 5-second fix and a half-hour bisect of "why does this host work from my laptop but not from CI".

FileOwnerPurpose
~/.ssh/configthe userPer-host overrides — aliases, IdentityFile, ProxyJump, LocalForward. The file you edit 95% of the time.
/etc/ssh/ssh_configrootSystem-wide client defaults applied to every user. Distros often ship Include /etc/ssh/ssh_config.d/*.conf here.
/etc/ssh/ssh_config.d/*.confrootDrop-in fragments merged into the system config (e.g. 50-redhat.conf setting ServerAliveInterval).
~/.ssh/known_hoststhe userPinned host-key fingerprints. Editable, but normally maintained by ssh-keygen -R <host> and -F <host>.
~/.ssh/authorized_keysthe user on the serverPublic keys allowed to log into the user's account on the remote machine. Lines may carry from=, command=, restrict, permitlisten= options.

The most-edited file is ~/.ssh/config. It is matched top-to-bottom, first-match-wins per option, so put specific Host blocks before catch-all Host * blocks. Glob patterns (Host *.internal), negation (Host !staging-*), and Match predicates (Match host db-* user alicedev) compose well; Include ~/.ssh/config.d/* pulls in fragments the same way sshd_config does on the server side.

text
# ~/.ssh/config — typical layout

# 1. Per-host blocks first, most specific to least specific
Host db-tunnel
  HostName bastion.example.com
  User alicedev
  LocalForward 5432 db.internal:5432
  ExitOnForwardFailure yes

Host *.internal
  ProxyJump bastion
  User alicedev

Host bastion
  HostName bastion.example.com
  User alicedev
  IdentityFile ~/.ssh/id_ed25519_bastion
  IdentitiesOnly yes

# 2. Pull in extra fragments
Include ~/.ssh/config.d/work.conf

# 3. Last, the catch-all defaults
Host *
  ServerAliveInterval 30
  ServerAliveCountMax 3
  ControlMaster auto
  ControlPath ~/.ssh/cm-%r@%h:%p
  ControlPersist 10m
bash
# Check the effective config for a given host (resolves Match, Include, globs)
ssh -G db-tunnel

# Verify ownership/permissions — sshd refuses keys if these are too loose
ls -la ~/.ssh
# Required: ~/.ssh = 700, config = 600, authorized_keys = 600, *.pub = 644
chmod 700 ~/.ssh
chmod 600 ~/.ssh/config ~/.ssh/authorized_keys ~/.ssh/id_ed25519

# Lookup the recorded fingerprint for a host (without printing all hosts)
ssh-keygen -F bastion.example.com -l

# Remove a stale host key after a server is re-provisioned
ssh-keygen -R bastion.example.com

Local port forwarding (-L)

Forward a local port to a remote destination via an SSH host.

bash
# Access remote DB on localhost:5432
ssh -L 5432:localhost:5432 user@jumphost.example.com

# Access a host only reachable from the jump host
ssh -L 8080:internal-server:80 user@jumphost.example.com

# Keep alive, no shell, background
ssh -fNL 5432:db.internal:5432 user@jumphost.example.com

Remote port forwarding (-R)

Expose a local service on a port of the remote host.

bash
# Make local :3000 reachable as remote :9000
ssh -R 9000:localhost:3000 user@remote.example.com

# Bind to all interfaces on remote (needs GatewayPorts yes in sshd_config)
ssh -R 0.0.0.0:9000:localhost:3000 user@remote.example.com

Dynamic (SOCKS5 proxy) (-D)

bash
# Start SOCKS5 proxy on local :1080, route through jump host
ssh -D 1080 -fN user@jumphost.example.com

# Then configure browser/curl to use SOCKS5 localhost:1080
curl --socks5-hostname localhost:1080 http://internal-site/

Jump hosts (-J / ProxyJump)

bash
# Single jump
ssh -J user@jump.example.com user@target.internal

# Multi-hop
ssh -J user@jump1,user@jump2 user@final.internal

~/.ssh/config patterns

text
Host jump
  HostName jump.example.com
  User myuser
  IdentityFile ~/.ssh/id_ed25519

Host internal
  HostName target.internal
  User myuser
  ProxyJump jump

Host db-tunnel
  HostName jump.example.com
  User myuser
  LocalForward 5432 db.internal:5432
  ServerAliveInterval 60
  ExitOnForwardFailure yes

Then just: ssh -fN db-tunnel

Persistent tunnels with systemd

ini
[Unit]
Description=SSH tunnel to production DB
After=network.target

[Service]
User=tunnel
ExecStart=/usr/bin/ssh -NT -o ServerAliveInterval=60 \
  -o ExitOnForwardFailure=yes \
  -L 5432:db.prod.internal:5432 \
  user@jumphost.example.com
Restart=always
RestartSec=10s

[Install]
WantedBy=multi-user.target

Forwarding flag anatomy

Every forwarding flag follows the pattern [bind_address:]port:host:hostport, but the meaning of "host" depends on direction. For -L, the host/hostport is resolved on the remote SSH server after the connection is established; for -R, the host/hostport is resolved on the local machine that initiates the SSH session. Confusing the two is the classic newbie mistake — it produces tunnels that connect to the wrong machine entirely.

bash
# -L: local listener; remote does the lookup of "db.internal"
ssh -L 5432:db.internal:5432 alicedev@bastion.example.com

# -R: remote listener; LOCAL machine resolves "10.0.0.5"
ssh -R 8080:10.0.0.5:80 alicedev@remote.example.com

# Bind to a specific local interface instead of 127.0.0.1
ssh -L 192.168.1.10:5432:db.internal:5432 alicedev@bastion.example.com

# Bind to all interfaces (DANGER — exposes the tunnel on your LAN)
ssh -L 0.0.0.0:5432:db.internal:5432 alicedev@bastion.example.com

By default -L binds only to 127.0.0.1 and -R binds only to the remote's loopback. To listen on all interfaces, prefix the bind with 0.0.0.0: (or *:) and ensure the right server-side knob is set — for -R that means GatewayPorts yes in the remote sshd_config. Without it, the remote sshd silently rebinds to localhost no matter what you specify.

GatewayPorts and bind-address pitfalls

GatewayPorts is a sshd_config setting on the far end that controls whether -R tunnels may bind to non-loopback addresses on the server. The default no means ssh -R 0.0.0.0:9000:... will still only bind to 127.0.0.1 on the remote, regardless of what the client asks for. Set GatewayPorts yes (or clientspecified for finer control) to allow public exposure.

bash
# /etc/ssh/sshd_config on the remote
# yes           -> remote -R binds to 0.0.0.0 if client asks
# clientspecified -> honour the client's bind address verbatim
# no (default)  -> always bind to loopback only
GatewayPorts clientspecified

# Reload sshd after editing
sudo systemctl reload sshd

The mirror-image setting on the client is AllowTcpForwarding, which controls whether -L and -R are permitted at all. If a tunnel command returns immediately with channel 0: open failed: administratively prohibited, the remote sshd_config has AllowTcpForwarding no or PermitOpen is restricting which targets you may reach.

Dynamic SOCKS proxy in depth

-D port starts a SOCKS4/SOCKS5 server on the local machine that proxies every connection through the SSH tunnel and resolves DNS on the remote side. This is the simplest VPN substitute — point a browser at localhost:<port> and every site you visit appears to originate from the SSH server's IP.

bash
# Background SOCKS proxy on :1080
ssh -fN -D 1080 alicedev@bastion.example.com

# Use it with curl
curl --socks5-hostname localhost:1080 https://internal-wiki.example.com/

# Use it with git over HTTPS
git -c http.proxy=socks5h://localhost:1080 clone https://github.com/owner/repo

# Whole-system SOCKS via tsocks / proxychains
proxychains4 -q apt update

Always pass --socks5-hostname to curl (not --socks5). The -hostname variant resolves the target DNS name on the remote end. Plain --socks5 does the DNS lookup locally, which defeats the proxy and may leak split-horizon hostnames (db.internal) to your local resolver.

For browser use, point Firefox or Chrome at SOCKS5 host=127.0.0.1 port=1080 and enable "Proxy DNS when using SOCKS v5" (Firefox) or run Chrome with --proxy-server="socks5://127.0.0.1:1080" --host-resolver-rules="MAP * ~NOTFOUND , EXCLUDE 127.0.0.1".

SSH agent and key forwarding (-A)

ssh-agent holds your decrypted private keys in memory so you don't re-enter passphrases for every connection. Agent forwarding (-A on the command line or ForwardAgent yes in ssh_config) makes the agent socket available on the remote host, letting a process there authenticate to a third host using your local keys without ever copying the key material.

bash
# Start an agent if one isn't running
eval "$(ssh-agent -s)"

# Add a key (prompts for passphrase once)
ssh-add ~/.ssh/id_ed25519

# List loaded identities
ssh-add -l

# Remove all loaded keys
ssh-add -D

# Connect with agent forwarding (then "ssh other-host" from the remote
# uses YOUR local key, not a key stored on the remote)
ssh -A alicedev@bastion.example.com

Agent forwarding to an untrusted host is a privilege-escalation risk: any root user on that host can hijack the agent socket and use your keys for as long as the connection is open. Prefer ProxyJump (which never exposes the agent on intermediate hops) over ForwardAgent yes for routine bastion access.

ProxyJump and ProxyCommand

ProxyJump (the -J flag and ssh_config directive) is the modern, declarative way to route through one or more bastion hosts. The traffic is multiplexed through the bastion's SSH channel without exposing the agent or running shell commands. ProxyCommand is the older, more powerful escape hatch — it pipes the SSH connection through an arbitrary command, useful for jumping through HTTP proxies, nc, or cloudflared access.

bash
# Modern: ProxyJump with -J
ssh -J alicedev@bastion.example.com alicedev@target.internal

# Multi-hop ProxyJump
ssh -J alicedev@bastion1,alicedev@bastion2 alicedev@target.internal

# Legacy / advanced: ProxyCommand through netcat on the bastion
ssh -o ProxyCommand="ssh -W %h:%p alicedev@bastion.example.com" \
    alicedev@target.internal

# ProxyCommand for a Cloudflare-tunnelled service
ssh -o ProxyCommand="cloudflared access ssh --hostname %h" alicedev@target.example.com

In ssh_config:

text
Host target
  HostName target.internal
  User alicedev
  ProxyJump bastion

Host bastion
  HostName bastion.example.com
  User alicedev

# ProxyCommand alternative for legacy clients
Host target-legacy
  HostName target.internal
  ProxyCommand ssh -W %h:%p bastion

ControlMaster connection multiplexing

ControlMaster reuses a single SSH connection for multiple sessions, drastically reducing the latency of repeated ssh, scp, rsync, and git calls to the same host. The first invocation opens a master channel; subsequent invocations share it through a control socket and avoid the full TCP handshake and key exchange.

text
Host *
  ControlMaster auto
  ControlPath ~/.ssh/cm-%r@%h:%p
  ControlPersist 10m
  • ControlMaster auto — open a master if none exists, share otherwise.
  • ControlPath — socket file; %r user, %h host, %p port (use a dedicated dir like ~/.ssh/cm/ and pre-create it).
  • ControlPersist 10m — keep the master alive 10 minutes after the last session closes.
bash
# After first ssh, subsequent commands are near-instant
ssh alicedev@bastion.example.com    # opens master
scp file.tar alicedev@bastion.example.com:/tmp/  # reuses master

# Manage the master from the client
ssh -O check alicedev@bastion.example.com   # status
ssh -O stop  alicedev@bastion.example.com   # graceful shutdown
ssh -O exit  alicedev@bastion.example.com   # force kill

A stale or orphaned control socket causes "mux_client_request_session: read from master failed" errors. Delete the socket file and retry — rm ~/.ssh/cm-* is safe when no SSH sessions are open.

Persistence with autossh

autossh wraps ssh and re-launches the connection automatically if it dies, using a pair of monitoring ports to detect frozen sessions that haven't dropped TCP. It's the right tool for long-lived tunnels on laptops that suspend, or for any "always-on" forward that must survive transient network failures.

bash
# Install on Debian/Ubuntu
sudo apt install autossh

# Background tunnel that auto-reconnects
autossh -M 0 -fN -o "ServerAliveInterval 30" -o "ServerAliveCountMax 3" \
        -L 5432:db.internal:5432 alicedev@bastion.example.com

-M 0 disables autossh's own monitoring ports and relies on ServerAliveInterval/ServerAliveCountMax (built into ssh) instead, which is the recommended modern configuration. The -f flag forks to background; -N means "no remote command".

For supervised persistence on a server, prefer systemd (see the unit file above) — it gives you systemctl status, structured logs, and dependency ordering that autossh alone cannot.

Keep-alive and ExitOnForwardFailure

Idle SSH connections often die silently when a NAT or firewall drops the conntrack entry. ServerAliveInterval sends an encrypted keep-alive every N seconds; ServerAliveCountMax is how many missed replies are tolerated before the client gives up. Setting both prevents zombie tunnels that look connected but no longer pass traffic.

text
Host *
  ServerAliveInterval 30
  ServerAliveCountMax 3
  TCPKeepAlive yes
  ExitOnForwardFailure yes

ExitOnForwardFailure yes makes ssh return non-zero immediately if a -L, -R, or -D cannot be set up (port already in use, remote refuses the forward). Without it, ssh -fN silently drops into a tunnel-less interactive session and your script proceeds thinking the tunnel exists.

Post-quantum key exchange

OpenSSH has shipped hybrid post-quantum key agreement by default since 9.0 (April 2022, originally sntrup761x25519-sha512). OpenSSH 9.9 (September 2024) added the NIST-standardised mlkem768x25519-sha256 (ML-KEM from FIPS 203, derived from CRYSTALS-Kyber, hybridised with X25519). OpenSSH 10.0 (April 2025) promoted mlkem768x25519-sha256 to the first-preference KEX and removed DSA entirely. OpenSSH 10.1 (October 2025) prints a warning when a non-post-quantum key agreement is negotiated, and 10.2 (also October 2025, the bugfix release that superseded 10.1 for general distribution) extended the warning to additional cases. The push behind this is the "harvest now, decrypt later" threat — sessions captured today could be retroactively decrypted once a cryptographically relevant quantum computer exists, so the long tail of legacy NIST P-256 / classic X25519 sessions needs to be rotated out.

bash
# Check what your local ssh client offers (most-preferred first)
ssh -Q kex | head -5
# Typical OpenSSH 10.x output:
#   mlkem768x25519-sha256
#   sntrup761x25519-sha512@openssh.com
#   curve25519-sha256
#   curve25519-sha256@libssh.org
#   ecdh-sha2-nistp256

# See which KEX the server actually negotiated for a real connection
ssh -v alicedev@bastion.example.com 2>&1 | grep -i "kex: algorithm"
# debug1: kex: algorithm: mlkem768x25519-sha256

# Force a specific algorithm (for testing or to silence the 10.1+ warning)
ssh -o KexAlgorithms=mlkem768x25519-sha256 alicedev@bastion.example.com

# Or list a preference order in ssh_config — note the leading PQ entries
text
# ~/.ssh/config — explicit PQ-first preference
Host *
  KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org
  HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256
  PubkeyAcceptedAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256

If you see ssh: kex_exchange_identification: post-quantum algorithm not used (or similar) since upgrading to OpenSSH 10.1+, the server is the one that doesn't support mlkem768x25519-sha256 or sntrup761x25519-sha512@openssh.com. The fix is to upgrade sshd on the far end — GitHub, GitLab, and most major hosting providers added support during 2025. To suppress the warning on a per-host basis while you wait for the server to be upgraded, set KexAlgorithms explicitly in the relevant Host block; the warning fires when the negotiated KEX is not post-quantum, not on the mere presence of legacy algorithms in the preference list.

Recent OpenSSH features worth knowing (9.6 → 10.x)

A handful of ssh_config / sshd_config directives added since OpenSSH 9.6 (December 2023) materially change tunnel and bastion workflows. They are worth folding into hardened configs.

  • %j token (9.6) — expands to the configured ProxyJump hostname (empty if none). Useful in IdentityFile, ControlPath, and other %-tokenised directives to differentiate sessions by jump host.
  • Include with environment expansion (9.9)~/.ssh/config can now Include ~/.ssh/config.d/${ENV:-default}.conf and expand the same %-tokens as Match Exec, making per-environment configs much simpler.
  • Match invalid-user (sshd, 9.9) — match on connections that target nonexistent usernames, useful for fail2ban-style policies without touching authentication logs.
  • RefuseConnection and refuseconnection penalty (sshd, 9.9) — terminate the connection at the first authentication attempt and feed that into PerSourcePenalties, the rate-limiter introduced in 9.8.
  • PerSourcePenalties (sshd, 9.8) — first-class IP penalty system that throttles or blocks clients failing authentication, malformed handshakes, or hitting RefuseConnection. Reduces the value of installing fail2ban for the SSH service alone.
  • Core-dump scrubbing (9.9) — private keys held in ssh/sshd/ssh-agent memory are excluded from core dumps on OpenBSD, Linux, and FreeBSD.
  • Username metachar ban (10.1 security fix) — control characters in usernames passed via the command line or %-expanded from config are rejected; older versions could be tricked into shell injection through a crafted ProxyCommand.
  • Agent socket relocation (10.1)ssh-agent and sshd now place the agent forwarding socket under ~/.ssh/agent/ instead of /tmp, so processes running in tighter filesystem sandboxes no longer ambiently have access to the agent.
  • ControlPersist fix (10.2) — 10.1 introduced a regression that broke interactive sessions when ControlPersist was set; upgrade to 10.2 (or skip 10.1 entirely) if you use connection multiplexing, which most heavy SSH users should.
bash
# Confirm the OpenSSH version you have (client + server)
ssh -V
ssh alicedev@bastion.example.com 'ssh -V'

# 9.6+ — use %j in a per-jump-host ControlPath to keep sockets separated
text
Host *
  ControlMaster auto
  ControlPath ~/.ssh/cm/%C-%j   # %C is a hash of %l%h%p%r; %j is the ProxyJump
  ControlPersist 10m

Hardening ssh_config

Project- or host-level overrides in ~/.ssh/config keep command lines short and make tunnel setup reproducible. Use the same file for tunnels you maintain regularly; named entries also work with scp, rsync, git, and most other SSH-aware tools.

text
# Per-host pinned key + reduced cipher list
Host bastion
  HostName bastion.example.com
  User alicedev
  IdentityFile ~/.ssh/id_ed25519_bastion
  IdentitiesOnly yes
  HostKeyAlgorithms ssh-ed25519,rsa-sha2-512
  Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com
  # PQ-first KEX (OpenSSH 9.9+ for mlkem768x25519-sha256; 9.0+ for sntrup761)
  KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512@openssh.com,curve25519-sha256

# Database tunnel — one shot
Host db-tunnel
  HostName bastion.example.com
  User alicedev
  LocalForward 5432 db.internal:5432
  ExitOnForwardFailure yes
  ServerAliveInterval 30
  RequestTTY no
  RemoteCommand none

# Lab network — SOCKS proxy
Host lab-socks
  HostName lab-gw.example.com
  User alicedev
  DynamicForward 1080
  ServerAliveInterval 30

IdentitiesOnly yes tells ssh to use only the listed IdentityFile, not every key loaded in the agent — this prevents "too many authentication failures" when the agent has many keys for many hosts.

Common recipes

Reach a private Postgres through a bastion

bash
# One-shot tunnel + psql in one command
ssh -fNL 5432:db.internal:5432 alicedev@bastion.example.com
psql -h 127.0.0.1 -U appuser appdb
# Tear down
pkill -f "ssh -fNL 5432:db.internal"

Browse an internal site through a SOCKS proxy

bash
ssh -fND 1080 alicedev@bastion.example.com
# Firefox: about:preferences#general -> Network Settings -> Manual SOCKS5 host=127.0.0.1 port=1080
# Or via curl:
curl --socks5-hostname 127.0.0.1:1080 https://internal-wiki.example.com/

Expose a local dev server through a public host

bash
# Make local :3000 reachable as https://myhost.example.com (port 9000)
# Requires GatewayPorts clientspecified on the remote sshd_config
ssh -fNR 0.0.0.0:9000:localhost:3000 alicedev@myhost.example.com

Tunnel into a Kubernetes API through a jump host

bash
ssh -fNL 6443:kube-api.internal:6443 -J alicedev@bastion.example.com alicedev@control-plane.internal
kubectl --insecure-skip-tls-verify --server=https://127.0.0.1:6443 get pods

rsync through a bastion without copying keys

bash
# Uses ProxyJump under the hood — bastion never sees plaintext data
rsync -avP -e "ssh -J alicedev@bastion.example.com" \
      ./build/ alicedev@target.internal:/srv/app/

Chain a SOCKS tunnel through two hops

bash
ssh -J alicedev@bastion1,alicedev@bastion2 -D 1080 -fN alicedev@target.internal

Diagnosing tunnel problems

When a tunnel "works but doesn't", the failure is almost always in one of: bind address, server-side sshd_config, the resolver picking the wrong target, or a firewall closing idle conntrack. Use -v (or -vv/-vvv) on the client and check journalctl -u ssh on the server.

bash
# Verbose client output, no shell
ssh -vN -L 5432:db.internal:5432 alicedev@bastion.example.com

# Check what's listening locally
ss -tlnp | grep 5432
lsof -iTCP:5432 -sTCP:LISTEN

# Test the tunnel from a separate shell
nc -zv 127.0.0.1 5432

# Check the remote sshd config without editing
ssh alicedev@bastion.example.com 'sshd -T | grep -Ei "gatewayports|allowtcp|permitopen"'

# Inspect the active control-master socket
ssh -O check alicedev@bastion.example.com

Common error → cause map:

SymptomLikely cause
bind: Address already in useA previous ssh -fN is still running — pkill ssh or pick a different local port.
channel 2: open failed: administratively prohibitedsshd_config has AllowTcpForwarding no or PermitOpen restricts the target.
-R binds only to 127.0.0.1 on remoteGatewayPorts is no or unset on the remote sshd_config.
Tunnel "freezes" after sleeping the laptopAdd ServerAliveInterval 30 and consider autossh.
Permission denied (publickey)IdentityFile not loaded in agent, or IdentitiesOnly yes missing and agent offered the wrong key first.
mux_client_request_session: read from master failedStale ControlPath socket — delete and retry.
Could not request local forwardingMistyped -L syntax (host on wrong side) or local port already bound.

ssh vs alternatives

NeedBest tool
One-shot encrypted port forwardssh -L / -R
VPN-like full traffic routingWireGuard, Tailscale, or ssh -D SOCKS
Long-lived multi-port site-to-site linkWireGuard or Tailscale
Expose dev server to the public internetssh -R with GatewayPorts, cloudflared tunnel, or ngrok
Multi-tenant bastion with auditTeleport, Cloudflare Access, or AWS SSM Session Manager
Reverse tunnel that survives rebootsautossh + systemd unit

For long-lived production tunnels, prefer purpose-built tools (Tailscale, WireGuard, Cloudflare Tunnel) over autossh. SSH tunnels are excellent for ad-hoc and developer use but have no built-in metrics, no failover, and a single user identity per connection.

Sources