Understand Reverse SSH Tunnels and Their Risks
A reverse SSH tunnel uses -R to put a listening socket on the SSH server, while the SSH client opens the final destination. In the form -R [bind_address:]port:destination_host:destination_port, the server listens on bind_address:port; when a connection arrives, sshd asks the client to connect to destination_host:destination_port from the client’s network location. It is not a mirror image of -L, and it is not automatically a secure publication mechanism.
127.0.0.1 on a dedicated bastion, require a second authenticated hop, and document the destination, owner, expiry, and egress approval. Do not turn CGNAT or a difficult firewall into a reason to publish a wildcard listener.
Read -R in the correct direction
| Element | Where it exists | What it does |
|---|---|---|
127.0.0.1:2222 in the example | SSH server (the bastion) | Listening socket reached by a second, authenticated SSH connection. |
127.0.0.1:22 in the example | SSH client (the private host) | Destination the client opens after the bastion receives a connection. |
| SSH transport | Between client and server | Authenticated, encrypted carrier for forwarding channels; it does not grant application authorization. |
# Run on the private host; the bastion is the SSH server.
ssh -N -o ExitOnForwardFailure=yes \
-R 127.0.0.1:2222:127.0.0.1:22 tunnel-bastion
With this command, an administrator who has authenticated to the bastion can connect to 127.0.0.1:2222 on the bastion. The tunnel client then connects to port 22 on the private host. It does not cause the private host’s port 22 to listen on the bastion’s public address, and it does not bypass the private host’s second SSH authentication.
Know the listener policy
OpenSSH commonly makes a remote forward listen on the server’s loopback address when no bind address is supplied. Treat that default as an implementation and configuration detail, not as a control you can skip in a review. GatewayPorts no on the server prevents remote forwards from becoming publicly reachable; GatewayPorts clientspecified lets a client request a bind address, and GatewayPorts yes makes non-loopback exposure easier. A wildcard such as 0.0.0.0 or :: is never the safe default for this series.
Use an explicit loopback bind even when the server policy is strict:
# Bastion-side policy for a single, dedicated forwarding identity.
AllowTcpForwarding remote
GatewayPorts no
PermitListen 127.0.0.1:2222
AllowTcpForwarding remote permits remote (-R) forwarding while rejecting local and dynamic forwarding. PermitListen narrows the address and port that a remote forward may bind. On OpenSSH versions that support it, DisableForwarding yes disables all forwarding features, including agent, X11, TCP, and stream-local forwarding; use it for ordinary accounts, not in the narrow exception above. Check effective behavior rather than assuming a drop-in was selected:
sudo sshd -t
sudo sshd -T -C user=tunnel,host=bastion.example.net,addr=198.51.100.20 \
| grep -E '^(allowtcpforwarding|gatewayports|permitlisten|disableforwarding)'
Distribution packaging and OpenSSH versions differ. Read the installed sshd_config(5), validate with sshd -t, and reload only through the operating system’s documented service name while retaining a known-good administrative session.
Understand port 0 and its operational cost
A remote port of 0 asks sshd to allocate an unused port:
ssh -N -vv -o ExitOnForwardFailure=yes \
-R 127.0.0.1:0:127.0.0.1:22 tunnel-bastion
The allocated port is reported in verbose client output (and through the relevant SSH client APIs). This avoids selecting a collision-prone fixed port, but it requires an authenticated discovery and hand-off mechanism. Do not scrape an unprotected log and do not publish the allocated value as a secret-free access shortcut. For a supervised service, a fixed, reserved loopback port with an explicit PermitListen rule is often easier to audit. In either case, inspect the actual listener with ss -lntp.
Constrain the forwarding identity
Create a dedicated, unprivileged account on the bastion. It should have no useful files, no administrative group membership, no interactive shell, and no reason to access other destinations. An authorized_keys entry can add restrictions supported by the installed OpenSSH release:
no-pty,no-agent-forwarding,no-X11-forwarding,no-user-rc,command="/bin/false",permitlisten="127.0.0.1:2222" ssh-ed25519 [approved-public-key-recorded-in-key-management] tunnel-private-host
The bracketed public-key marker is documentation, not a key to install. Put the real public key in a protected configuration-management or key-management workflow; never paste a private key into a unit file, command line, ticket, or article. Do not combine restrict with this entry when forwarding is required: restrict includes no-port-forwarding on OpenSSH, so use the individual restrictions and a tested permitlisten exception instead. Where a release does not support permitlisten or DisableForwarding, use the strongest available server policy and record the limitation.
Use a forced command only as a belt-and-braces shell denial and test it with the exact client mode. A forwarding-only ssh -N session does not request a shell channel, while an attempted interactive session should fail. Confirm that the installed version’s authorized-key options have the intended effect before relying on them.
Account for the risks
- Unintended publication: a wildcard bind, permissive
GatewayPorts, or a broadPermitListencan expose an internal service to the internet. - Trust confusion: the destination sees the private host’s local connection, not the original administrator. Preserve application authentication and authorization.
- Pivoting: a forwarding identity can become a path to every host allowed by the client’s egress policy unless destinations and firewall rules are narrow.
- Persistence: a user service, cron entry, or unmanaged supervisor can survive an incident and reconnect after a key is thought to be revoked.
- Monitoring gaps: sshd logs prove authentication and forwarding requests, not that the resulting application traffic was benign. Correlate bastion sockets, private-host processes, and egress records.
Set safe client defaults
Host tunnel-bastion
HostName bastion.example.net
User tunnel
IdentityFile ~/.ssh/id_ed25519_tunnel
IdentitiesOnly yes
ForwardAgent no
RequestTTY no
ExitOnForwardFailure yes
ServerAliveInterval 30
ServerAliveCountMax 3
StrictHostKeyChecking yes
Verify the bastion host key out of band before first use and keep it in a controlled known_hosts file. ExitOnForwardFailure makes setup fail if the requested listener cannot be created; it does not prove that the destination service is healthy. Keepalive options detect a dead carrier; they do not replace application health checks or a lease and expiry process.
Prefer safer alternatives
Use Tailscale for named private administration when both endpoints can run it; its identity policy, encrypted transport, and NAT traversal usually avoid operating a public bastion. Use Cloudflare Tunnel for an approved public web application or Cloudflare Access-protected HTTP service; it is not a transparent authorization layer for ordinary SSH. A reverse SSH tunnel to an independently administered bastion is a deliberate fallback and a useful learning exercise when those products do not fit, not the default architecture.
Continue with Build a Loopback-Only Reverse SSH Tunnel for a bounded implementation.
Sequence navigation
Previous: Operate and Recover SSH on the Raspberry Pi 5 · Next: Build a Loopback-Only Reverse SSH Tunnel
dispelled