Quick answer

-L forwards a port from your machine into the remote network. -R forwards from the server back to you. -D opens a SOCKS proxy. The failure that eats afternoons is -R: sshd binds remote forwards to loopback because GatewayPorts defaults to no, and a client-side bind address cannot override that. The server rebinds rather than refusing.

By LK Wood IV · 2026-08-15 · ~10 min read · St. Louis County, MO

Three flags. The third is where the afternoon goes.

ssh -L 8080:internal-host:80  user@server   # reach IN
ssh -R 8080:localhost:80      user@server   # expose OUT
ssh -D 1080                   user@server   # SOCKS proxy

-L opens a listener on your machine. -R opens one on the server. That is the entire difference. Inverting it is the beginner mistake, and it announces itself immediately, which makes it the cheap one.

The expert mistake is different, and it is the reason this page exists: -R does not fail when it does not work. It succeeds into a state that is not the one you asked for.

The -R trap

You run this on a home machine, against a VPS with a public IP:

ssh -R 8080:localhost:80 user@vps

You expect http://vps-public-ip:8080 to reach the web server at home. From the VPS itself, curl localhost:8080 works perfectly, which is the detail that sends people off debugging their home firewall, their router, and eventually their ISP, when none of those were ever in the path. From anywhere else: connection refused.

Nothing errored. The tunnel is up. It is bound to 127.0.0.1 on the VPS, which is a perfectly good place for a listener to be if that is what you asked for, and you did not.

That is sshd’s documented default. Remote forwards bind loopback unless GatewayPorts is enabled, and it defaults to no.

Then comes the second half, which is the part that costs the extra hour. You reasonably try to force it from the client:

ssh -R 0.0.0.0:8080:localhost:80 user@vps   # still loopback-only

It does not help.

ssh(1) is explicit: specifying a remote bind_address will only succeed if the server’s GatewayPorts option is enabled. Your bind address is a request, not an instruction — the server is free to override it, and by default it does exactly that, binding loopback anyway rather than refusing the forward and saying why. A refusal would have cost you thirty seconds. The override costs you the evening.

This is documented in OpenSSH’s own tracker as bug 1297, filed by someone who noticed the socket was always bound to 127.0.0.1 and [::1] and that -L had no such problem. That is the shape of the confusion: local forwards obey you, remote forwards negotiate with a server whose defaults you did not read.

Fixing it, on the correct side

On the server, in sshd_config:

GatewayPorts clientspecified

Then reload sshd. Not your session. The daemon.

I recommend clientspecified over yes deliberately. yes forces remote forwards to bind the wildcard address for every client on that server — a blunt, global change that removes the option of a loopback-only forward from everyone. clientspecified gives each client the choice, so -R 0.0.0.0:8080:... binds wide and a plain -R 8080:... stays local.

The two GatewayPorts

Here is the detail that turns a five-minute fix into a long one, and it is purely a naming accident.

There are two GatewayPorts options with near-identical wording:

FileGoverns
sshd_config (server)-R listeners the server opens on your behalf
ssh_config (client)your own -L and -D listeners

They live in different files, on different machines, and do different jobs. Editing the client one, reloading nothing, and then wondering for forty minutes why the reverse tunnel is still loopback-only is an experience I would rather you skip.

Debugging -R? Server file. Every time.

-L, properly

ssh -L [bind_address:]port:host:hostport destination

host:hostport is resolved from the server’s perspective, and that is the entire point of the flag: you are borrowing the server’s view of the network for the duration of the session, which is why it reaches things your own machine has no route to. This reaches a NAS at 192.168.1.50 that your laptop cannot route to:

ssh -L 8080:192.168.1.50:80 user@homelab-gateway

Then browse localhost:8080. That is it.

By default that listener binds loopback on your machine, so nobody else on the coffee-shop wifi can use your tunnel. That default is correct. Leave it.

-L also accepts Unix sockets on either end, which is how you reach a socket-only service like a database or a Docker daemon without publishing a TCP port for it.

-D, the one people underuse

ssh -D 1080 user@server

That is a local SOCKS 4/5 listener. Point a browser’s SOCKS proxy at localhost:1080 and its traffic emerges from the server, with DNS resolved there too if the client is configured for remote DNS.

I reach for this more than -L when I am poking at a homelab, because it needs no per-service forwards. One flag, and the whole network is reachable by its internal names for as long as the session lives.

It is not a VPN. It carries only what an application deliberately sends through the proxy, so anything not SOCKS-aware ignores it completely and silently, which is a distinction people discover at the worst possible moment. If you want everything routed, you want WireGuard or Tailscale, not this.

-J, which is not a tunnel at all

ssh -J bastion.example.com user@internal-host

ProxyJump reaches a final destination through an intermediate. Nothing is published, no listener opens, and the bastion does not see your session in cleartext — your client negotiates end-to-end with the real target.

Keep the distinction clean.

  • Administrative access to a machine behind a bastion is a -J job.
  • Exposing a service is a -L or -R job.

I have watched people build an elaborate -L chain for something -J does in one flag, in ~/.ssh/config, permanently:

Host internal-*
    ProxyJump bastion.example.com

“administratively prohibited”

channel 3: open failed: administratively prohibited

Two things about this message. The first one saves you a search.

First: the 3 is meaningless. It is OpenSSH’s local channel id from a format string. Searching the literal message with the number in it is why people find nothing.

Second: it means the server refused. Almost nothing more. There are at least three distinct causes, all server-side:

  1. AllowTcpForwarding in sshd_config disallowing that direction. It takes local and remote as values, not just yes and no, so a server can permit -L and refuse -R.
  2. A key option in authorized_keysno-port-forwarding, or restrict, which turns everything off and then re-enables only what you name after it.
  3. permitopen / PermitOpen restricting forwards to a specific host and port list that does not include the one you asked for.

Check them in that order. The authorized_keys case catches people out most often, because the server’s global config reads permissive and the restriction is sitting on the key itself, which is not where anyone looks second.

Privileged ports behave differently by direction

ssh(1) gives two different rules, and they are easy to conflate:

  • For -L: only the superuser can forward privileged ports. That is on your side.
  • For -R: privileged ports can be forwarded only when logging in as root on the remote machine. That is on the server’s side.

So -R 80:localhost:8080 to a VPS fails unless you are logging in as root there, which you should not be. Bind something above 1024 and put a reverse proxy in front of it.

Keeping a tunnel alive

An SSH tunnel is a process. Processes die. For anything you depend on, put it under supervision rather than in a terminal you will eventually close:

# /etc/systemd/system/tunnel.service
[Service]
ExecStart=/usr/bin/ssh -N -T -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes \
          -R 8080:localhost:80 user@vps
Restart=always
RestartSec=10

-N runs no remote command, -T allocates no TTY. ExitOnForwardFailure=yes earns its place: without it, ssh happily stays connected when a forward could not be established, and systemd sees a healthy process fronting a tunnel that carries nothing.

ServerAliveInterval matters because an idle tunnel crossing a NAT gateway is exactly the kind of connection a middlebox reaps without telling either end, leaving you with a socket that looks established on both sides and moves nothing.

Where this stops being the right tool

Reverse tunnels are the classic answer to “my home connection has no public IP”, and for one or two services they remain a genuinely good one, because the alternative is standing up infrastructure to solve a problem that a single flag already solves. They work. I still keep one for a couple of things.

But if you find yourself maintaining several, plus a systemd unit for each, plus GatewayPorts on a server whose config you now have to remember, you have hand-built a worse mesh VPN. That is the point to look at Tailscale or WireGuard, or at running the control plane yourself.

The honest dividing line I use is this. One or two forwards, occasional, and -R is fine.

More than that, or anything a household depends on, and the tunnel collection has quietly become technical debt with a systemd unit attached to each piece.

What I have not tested

I have not verified whether a current client surfaces the loopback downgrade under -v. OpenSSH added a debug message for this case in the 6.4 era, and I could not establish from the sources I read whether that reliably reaches a modern user’s terminal. Assume it is silent and check the listener yourself with ss -tlnp on the server.

I have not tested any of the authorized_keys restriction interactions on a live sshd during this write-up, so treat the ordering advice as a plan of attack rather than a measured result.

What getting it wrong costs

The -R trap costs an evening. That is the good outcome: you conclude nothing works, and go read the docs.

The bad outcome is GatewayPorts yes set globally on a server, forgotten, on a box that also hosts something else. Now every remote forward any account opens binds the wildcard address by default. Somebody tunnels an admin panel out to test something, leaves it up, and there is a listener on a public IP that nobody remembers creating.

clientspecified costs nothing extra and does not accumulate that debt.

Frequently asked questions

What is the difference between ssh -L and ssh -R?
-L opens a listener on YOUR machine and forwards connections through the SSH session to a host reachable from the server. You use it to reach something on the far side. -R opens a listener on the SERVER and forwards connections back through the session to a host reachable from your machine. You use it to expose something on your side. The direction of the listener is the whole distinction, and it is the thing people invert.
Why does my ssh -R tunnel only work from the server itself?
Because sshd binds remotely-forwarded listeners to the loopback address unless GatewayPorts is enabled, and that option defaults to no. The listener exists, it just answers only on 127.0.0.1 of the server. This is the documented default, not a bug in your command.
Can I fix that by specifying a bind address like -R 0.0.0.0:8080?
No. ssh(1) states that specifying a remote bind_address will only succeed if the server’s GatewayPorts option is enabled. Your client-side bind address is a request the server is free to override, and by default it does — it rebinds to loopback rather than refusing the forward.
Which GatewayPorts do I edit?
The one in sshd_config on the SERVER, for -R forwards. There is a separate GatewayPorts in ssh_config that governs your own -L and -D listeners on the client. The two have near-identical descriptions and live in different files, so editing the wrong one and seeing no change is a common and very frustrating hour.
Should I set GatewayPorts yes?
Prefer clientspecified. GatewayPorts yes forces remote forwards to bind the wildcard address for every client on that server, which is a blunt, global change. clientspecified lets each client choose its own bind address, including keeping a forward loopback-only.
What does 'channel 3: open failed: administratively prohibited' mean?
That the server refused the forward, and almost nothing else. The number is just OpenSSH’s local channel id and carries no diagnostic value, so searching the literal string with the number in it is wasted effort. It has at least three distinct server-side causes: AllowTcpForwarding disallowing that direction, a key option in authorized_keys such as no-port-forwarding or restrict, or a permitopen/PermitOpen restriction that does not match the host and port you asked for.
What is ProxyJump and how is it different from a tunnel?
-J tells your client to reach the final destination THROUGH an intermediate host, without giving that intermediate host your traffic in cleartext. Use it to reach a machine that is only routable from a bastion. It is a routing instruction rather than a published listener, which is why it is the right tool for administrative access and -L/-R are the right tools for exposing a service.
Can I forward a privileged port under 1024?
The rules differ by direction. For -L, ssh(1) says only the superuser can forward privileged ports, meaning on your client. For -R, it says privileged ports can be forwarded only when logging in as root on the remote machine. People assume one rule covers both and then cannot work out which side is refusing them.

Evidence ledger

Last updated
Methodology
This networking guide was written and edited by Lowell K. Wood IV in St. Louis County, MO. Specs and prices verified against vendor and project documentation current on the date above. Full editorial standard: methodology.
Update log
  • 2026-08-15 — Last reviewed and updated.
Corrections
Spotted an error or a stale number? Email hello@techfuelhq.com. Confirmed corrections are added to the update log above.

About the author

Written by Lowell K. Wood IV, who builds and runs TechFuelHQ from St. Louis, Missouri.