Build a Loopback-Only Reverse SSH Tunnel

This procedure builds one narrow reverse tunnel from a private host to a dedicated bastion. The bastion listener is explicitly 127.0.0.1; it is not reachable from the bastion’s public interface. An administrator must authenticate to the bastion and then authenticate again to the private service. The example forwards SSH only so that the second hop remains visible and auditable; use the same pattern for a specifically approved application port.

Do not “fix” a failed tunnel with 0.0.0.0. First inspect server policy, the exact listener, client egress, and host keys. A reverse tunnel is useful only when its scope is smaller than the access problem it solves.

Define the two-hop design

RoleExample identity or addressRequired boundary
Public bastionbastion.example.netIndependently administered; public SSH is hardened and rate-limited.
Forwarding accounttunnelUnprivileged, no interactive use, one permitted loopback listener.
Private hostthe host running the tunnel clientDedicated local service and egress rule; no inbound WAN rule.
Administratorbastion-admin then private-adminTwo separately authorized SSH authentications.

The names above are documentation values. Inventory the real hosts, accounts, service owner, destination, expiry, and bastion operator before deployment. Do not reuse a personal administrator key for the tunnel identity.

Prepare the bastion policy

Use a distribution-supported sshd drop-in and check the installed OpenSSH version’s manual. Keep forwarding disabled for ordinary users, then make a single exception for the dedicated account:

# General policy, for example in /etc/ssh/sshd_config.d/40-forwarding.conf
GatewayPorts no
AllowTcpForwarding no

# The Match block must be last or otherwise placed according to sshd_config rules.
Match User tunnel
    AllowTcpForwarding remote
    PermitListen 127.0.0.1:2222

Do not add DisableForwarding yes to the exception: that setting disables remote forwarding too. Use DisableForwarding yes for accounts that must never forward. If your OpenSSH release lacks PermitListen, do not silently substitute a broad exception; upgrade or document a compensating control such as an isolated bastion account and host firewall.

sudo sshd -t
sudo sshd -T -C user=tunnel,host=bastion.example.net,addr=198.51.100.20 \
  | grep -E '^(allowtcpforwarding|gatewayports|permitlisten|disableforwarding)'
sudo systemctl reload ssh 2>/dev/null || sudo systemctl reload sshd

Use the service name present on the distribution. Keep an existing administrative session open while testing a new one. A configuration check is not a connectivity test.

Install a restricted tunnel key

Create tunnel without a privileged group or useful home data. Set a shell that cannot be used interactively, but do not rely on the shell alone:

sudo useradd --system --create-home --home-dir /var/lib/ssh-tunnel \
  --shell /usr/sbin/nologin tunnel
sudo install -d -m 0700 -o tunnel -g tunnel /var/lib/ssh-tunnel/.ssh
sudo install -m 0600 -o tunnel -g tunnel /dev/null \
  /var/lib/ssh-tunnel/.ssh/authorized_keys

Account-creation flags vary. Inspect the result with getent passwd tunnel and correct ownership and mode using the platform’s documented tools. Add only the real public key through a controlled secret-management process. The line shape is:

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] private-host-tunnel

The bracketed marker is intentionally not a usable key. Never manufacture a key by copying this text, and never put the private half in this article or in a systemd unit. Do not use restrict here because its no-port-forwarding component would also reject the required remote forward. Confirm the key options with the installed authorized_keys(5).

Make the client request exactly one listener

On the private host, first verify that the destination is local and approved:

ss -lntp | grep -E '127\.0\.0\.1:22([[:space:]]|$)'
ssh-keyscan -H -t ed25519 bastion.example.net

Review the host-key scan through an independent channel before adding it to the client’s controlled known_hosts. Do not use StrictHostKeyChecking=no or accept a changed fingerprint without incident review.

# Run as the dedicated local service identity.
ssh -N -T \
  -o ExitOnForwardFailure=yes \
  -o ServerAliveInterval=30 \
  -o ServerAliveCountMax=3 \
  -o StrictHostKeyChecking=yes \
  -o UserKnownHostsFile=/var/lib/ssh-tunnel/.ssh/known_hosts \
  -i /var/lib/ssh-tunnel/.ssh/id_ed25519 \
  -R 127.0.0.1:2222:127.0.0.1:22 tunnel@bastion.example.net

-T refuses a pseudo-terminal and -N requests no remote command. ExitOnForwardFailure fails closed when the listener cannot be created. The private key path is a protected file reference, not a secret embedded in the command; its permissions, backup, rotation, and access should be managed by the host’s secret-management process.

Verify the socket and complete both authentications

On the bastion, verify that the only new listener is loopback:

ss -lntp | grep ':2222'
sudo journalctl -u ssh --since "10 minutes ago" --no-pager 2>/dev/null \
  || sudo journalctl _COMM=sshd --since "10 minutes ago" --no-pager

The expected listener is 127.0.0.1:2222 (and, if IPv6 is intentionally configured, a separately reviewed ::1 listener). A 0.0.0.0 or :: listener is a stop-and-investigate condition. From the administrator workstation, authenticate first to the bastion and then to the private host through the loopback listener:

ssh -J bastion-admin@bastion.example.net \
  -p 2222 private-admin@127.0.0.1

The -J connection authenticates to the bastion as bastion-admin; the target connection separately authenticates as private-admin. The private host must still enforce its normal key, certificate, MFA, account, and command policy. Check the private host’s SSH logs and the bastion’s logs for both events.

Test failure and shutdown behavior

  1. Stop the local destination service. Confirm the SSH carrier may remain established but application connections fail; this distinguishes tunnel health from service health.
  2. Occupy bastion port 2222 with a controlled test listener. Confirm ExitOnForwardFailure=yes causes setup to fail instead of silently running without the tunnel.
  3. Attempt a public connection to the bastion’s address on 2222. It must fail because the bind is loopback-only; record the firewall and socket evidence.
  4. Try a shell, agent forwarding, X11 forwarding, a different remote port, and a second destination with the tunnel key. Each must be denied.
  5. Stop the client cleanly and confirm the bastion listener disappears. Remove the key and confirm a new connection fails.

Keep a change record and an expiry. A successful TCP connection proves neither that the service is authorized for the administrator nor that the private host’s egress remains narrow.

Continue with Run a Persistent Reverse SSH Tunnel Safely only after this foreground test passes.

Sequence navigation

Previous: Understand Reverse SSH Tunnels and Their Risks · Next: Run a Persistent Reverse SSH Tunnel Safely

Official references