SSD Nodes Learn 🎉 VPS from $4.99/mo
Guides Matt ConnorBy Matt Connor · Updated 2026-08-12

SSH over a Tor onion service, no open ports

Put sshd behind a Tor onion service so the VPS answers on no inbound port. Setup, v3 client authorisation, and the exact order that avoids a lockout.

What SSH over a Tor onion service changes

SSH over a Tor onion service lets you administer a VPS that accepts no inbound connection on any port. The server dials out to the Tor network and holds that connection open. Your SSH session arrives back down it, so nothing has to listen on the public IP address.

The effect on the log is immediate. A box with a public SSH port collects thousands of failed password attempts a day from scanners. Move sshd behind an onion service and drop inbound traffic at the firewall, and /var/log/auth.log then records only the sessions you started.

The cost is that tor sits in the path of every admin session. It is a userspace daemon that has to start and bootstrap after every reboot before you can log in. Plan for that before you close the port, because the failure mode here is losing access to a machine you cannot physically reach.

Get a way back in before you touch anything

Do not start until you have a recovery path that does not use SSH.

Open your provider's console now, the VNC or serial console in the control panel, and log in with it. If the root password is unknown, reset the root password from the panel first and confirm it works. A console you have never tested is not a recovery path.

The order below matters. Each step is proven before the next one runs, and port 22 stays open until the onion route works.

  1. Install tor and confirm it bootstraps.
  2. Define the onion service and read the address.
  3. Connect over the onion while port 22 is still open.
  4. Add client authorisation, then connect again.
  5. Bind sshd to loopback and close port 22.
  6. Reboot, then connect over the onion again.

Keep your current SSH session open the whole way through. An established session survives a firewall change that would block a new one, so it is your first line of rescue.

Install tor on the server

Ubuntu ships tor in its own repository and that build is often behind. The Tor Project's repository carries the version their documentation describes. Add it with the commands from their apt repository guide.

sudo apt update
sudo apt install -y apt-transport-https wget gpg
wget -qO- https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc | gpg --dearmor | sudo tee /usr/share/keyrings/deb.torproject.org-keyring.gpg >/dev/null

Write /etc/apt/sources.list.d/tor.sources. Suites takes your release codename, which lsb_release -cs prints (noble on Ubuntu 24.04).

Types: deb deb-src
URIs: https://deb.torproject.org/torproject.org/
Suites: noble
Components: main
Signed-By: /usr/share/keyrings/deb.torproject.org-keyring.gpg
sudo apt update
sudo apt install -y tor deb.torproject.org-keyring
sudo journalctl -u tor@default -n 20 --no-pager

The log should end with Bootstrapped 100% (done). Stuck below that means tor cannot reach the network, which is nearly always an outbound firewall rule or a badly wrong clock.

The unit name is a trap. systemctl status tor reports Active: active (exited) even when everything is healthy, because Debian and Ubuntu package tor as a multi-instance master unit whose only job is to pull in the real instance. The daemon itself runs as tor@default.service. Use that name for status and for journalctl. Start, stop and reload of tor do still reach the instance, so sudo systemctl reload tor works as you expect.

Define the onion service for port 22

Add two lines to /etc/tor/torrc.

HiddenServiceDir /var/lib/tor/ssh/
HiddenServicePort 22 127.0.0.1:22

The second line tells tor to accept virtual port 22 on the onion address and connect to 127.0.0.1:22 on the box. Tor reaches sshd over loopback, which is exactly why sshd can later stop listening on the public address.

sudo systemctl reload tor
sudo cat /var/lib/tor/ssh/hostname

That prints 56 base32 characters followed by .onion. Those characters are the service's public key in encoded form. There is no certificate authority and no name registration anywhere in this.

Let tor create /var/lib/tor/ssh/ itself. Make it by hand with the wrong owner or a mode looser than 0700 and tor refuses to use it, and the journal reports the directory as too permissive. The files inside are the service identity: hs_ed25519_secret_key is the address. Back that directory up with mode 600 and keep the copy off the box, because losing it means a new address and a config edit on every client.

Connect from your workstation

Your workstation needs a tor client, which needs no configuration at all. On Debian or Ubuntu that is sudo apt install -y tor netcat-openbsd. Tor then listens on 127.0.0.1:9050 as a SOCKS5 proxy. SOCKS is a generic proxy protocol, and version 5 can carry a hostname instead of an IP address, which is the part that matters here.

OpenSSH has no SOCKS client of its own, so a helper program makes the connection. Add this to ~/.ssh/config.

Host myvps
  HostName xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.onion
  User admin
  ProxyCommand /usr/bin/nc -X 5 -x 127.0.0.1:9050 %h %p
  ServerAliveInterval 30

-X 5 selects SOCKS5 and -x 127.0.0.1:9050 points at the local tor. %h hands the onion name to tor as a name, so tor resolves it inside the network. This has to be the OpenBSD netcat. GNU netcat has no -X option and stops with nc: invalid option -- 'X'.

ssh myvps

The first connection is slow, because tor builds a circuit before anything else happens. Accept the host key fingerprint the way you would anywhere else. From here on the usual SSH key handling applies unchanged. The transport moved. The authentication did not.

For a one-off you can skip the config entry: torsocks ssh admin@xxxxx.onion does the same job.

Add v3 client authorisation

As it stands, anyone who learns the address can reach your SSH banner and start guessing. Onion addresses cannot be enumerated from the directory system, so the address behaves like a secret, but it leaks in ordinary ways: shell history, and config files committed to a git repository. Client authorisation closes that gap. The service publishes its descriptor encrypted to a client key, so someone holding the address and no key cannot even locate the service.

Generate an x25519 key pair on the client. This is the pipeline from the Tor Project's client authorisation guide, with one change.

openssl genpkey -algorithm x25519 -out /tmp/k1.prv.pem
grep -v " PRIVATE KEY" /tmp/k1.prv.pem | base64 -d | tail --bytes=32 | base32 | sed 's/=//g' > /tmp/k1.prv.key
openssl pkey -in /tmp/k1.prv.pem -pubout | grep -v " PUBLIC KEY" | base64 -d | tail --bytes=32 | base32 | sed 's/=//g' > /tmp/k1.pub.key

The published version of those lines uses base64pem -d, which a stock Ubuntu install does not carry. The command then stops with base64pem: command not found. GNU base64 -d decodes the same PEM body, so use it instead.

On the server, install the public key.

sudo install -d -m 700 -o debian-tor -g debian-tor /var/lib/tor/ssh/authorized_clients
echo "descriptor:x25519:PASTE_PUBLIC_KEY_HERE" | sudo tee /var/lib/tor/ssh/authorized_clients/laptop.auth >/dev/null
sudo chown debian-tor:debian-tor /var/lib/tor/ssh/authorized_clients/laptop.auth
sudo systemctl reload tor

Only files ending in .auth are read. Save it as laptop.auth.txt and tor ignores the file with no error printed, and the service quietly stays open to anyone with the address.

On the client, install the private key. On Ubuntu the tor daemon runs as the debian-tor user and cannot read files in your home directory, so keep the directory somewhere that user reaches.

sudo install -d -m 700 -o debian-tor -g debian-tor /var/lib/tor/onion_auth
echo "ADDRESS_WITHOUT_DOT_ONION:descriptor:x25519:PASTE_PRIVATE_KEY_HERE" | sudo tee /var/lib/tor/onion_auth/myvps.auth_private >/dev/null
sudo chown debian-tor:debian-tor /var/lib/tor/onion_auth/myvps.auth_private
sudo chmod 600 /var/lib/tor/onion_auth/myvps.auth_private

Add ClientOnionAuthDir /var/lib/tor/onion_auth to the client's /etc/tor/torrc and reload tor. If you run tor as your own user instead, for example the Homebrew build on macOS, point ClientOnionAuthDir at ~/.tor/onion_auth with mode 0700.

The address inside that file is the 56 characters without the .onion suffix. Delete /tmp/k1.prv.pem and /tmp/k1.prv.key when you are done.

Now test both directions. ssh myvps should still connect. From a machine holding no key, the same address should fail. That failure is your proof the authorisation is active.

Close port 22, in this order

Set a safety net first. This one command undoes both of the changes below after fifteen minutes if you lock yourself out.

sudo systemd-run --on-active=15m --unit=ssh-rescue \
  /bin/sh -c 'ufw allow 22/tcp; rm -f /etc/systemd/system/ssh.socket.d/override.conf; systemctl daemon-reload; systemctl restart ssh.socket'

Cancel it with sudo systemctl stop ssh-rescue.timer once you have confirmed the onion route still works.

Next, stop sshd listening on the public address. Ubuntu 24.04 activates ssh through a socket unit, so ListenAddress in sshd_config is ignored: ssh.socket owns the listening socket, not sshd. Check which case you are in.

systemctl is-enabled ssh.socket

If that prints enabled, run sudo systemctl edit ssh.socket and add this.

[Socket]
ListenStream=
ListenStream=127.0.0.1:22

The empty ListenStream= clears the value inherited from the packaged unit. Leave that line out and you add a second listener while keeping the public one, which is the most common way this step fails without saying so.

sudo systemctl daemon-reload
sudo systemctl restart ssh.socket
ss -tlnp | grep ':22'

ss should show 127.0.0.1:22 and nothing on 0.0.0.0:22. If ssh.socket was disabled, put ListenAddress 127.0.0.1 in /etc/ssh/sshd_config.d/10-onion.conf, run sudo systemctl restart ssh, then check with the same ss line. That output is the proof in either case.

Then the firewall, which is ordinary ufw rule management on a VPS. Run sudo ufw status numbered first and delete whichever SSH rule it lists.

sudo ufw status numbered
sudo ufw delete allow OpenSSH
sudo ufw default allow outgoing
sudo ufw default deny incoming
sudo ufw status verbose

Leave outgoing traffic allowed. Tor connects out to relays on ports such as 443 and 9001, so an outbound default-deny policy stops tor bootstrapping and removes your only remaining way in at the same moment. Most providers also run a separate network firewall in the control panel. Close 22 there as well, or the port stays reachable whatever ufw reports.

If Docker runs on this box, check its published ports before you call the job done. Docker writes its own rules into the same tables and publishes container ports straight past ufw, so a ufw deny policy is not the full picture.

Reboot before you trust it

systemctl is-enabled tor@default
sudo reboot

If the first command does not report the service as enabled, run sudo systemctl enable tor@default before you reboot. Wait two minutes, then ssh myvps. Tor has to bootstrap after boot, so the onion address starts answering some time after the machine itself is up.

If it never comes back, open the console and read sudo journalctl -u tor@default -b. A torrc syntax error or a directory permission problem is printed there. You can also check a torrc edit before applying it.

sudo -u debian-tor tor --verify-config

What this costs next to a WireGuard tunnel

Compared with a WireGuard VPN on your own VPS, an onion service is slower and less predictable. Be honest with yourself about the trade before you commit to it.

Latency. A client circuit is three relays and the service side adds another three, so your keystrokes cross roughly six machines picked at random around the world. Interactive typing has a visible delay, and file copies are slow. WireGuard adds one hop. Measure your own case with time ssh myvps 'echo ok', because the figure depends on which circuit tor happened to build and it changes when tor builds another.

A userspace daemon in the critical path. WireGuard is in the kernel and comes up with the network. Tor is a process that must start, bootstrap, and reach a guard relay before anything works. When it fails, you are on the provider console.

Clock accuracy. Onion service descriptors are published against time periods, so a badly wrong clock breaks address lookup with no clear message anywhere. timedatectl should report System clock synchronized: yes.

What you get back is exposure that no longer depends on a firewall rule being correct. There is no port to scan and no banner to grab, and the address is itself a public key, so the endpoint proves its identity before SSH even starts.

The practical answer is usually both. Run WireGuard as the daily path, and keep the onion service as the route that still works when the WireGuard config is wrong. That leaves one UDP port open rather than a public SSH port. None of it replaces hardening sshd itself: key-only authentication and a non-root login still matter, because an onion service protects the network path and nothing beyond it.

Failure modes and the errors you will see

Tor never passes Bootstrapped 0%. Outbound traffic is blocked, or the clock is far out. Check the outgoing policy with sudo ufw status verbose, then run timedatectl.

systemctl status tor says active (exited). That is normal on Debian and Ubuntu. Read tor@default instead.

The descriptor cannot be found. Tor returns SOCKS extended error F0, "Onion Service Descriptor Can Not be Found". Either the descriptor is not published yet, which takes a short while after a reload, or tor on the server is not running.

F4, "Onion Service Missing Client Authorization". The client has no matching .auth_private that tor can use. Check that ClientOnionAuthDir is in torrc, that the directory is mode 0700, that the filename ends in .auth_private, and that debian-tor can read it.

F5, "Onion Service Wrong Client Authorization". The private key does not match the .auth file on the server. A trailing = or a stray newline inside the base32 string causes this.

nc: invalid option -- 'X'. GNU netcat is installed rather than the OpenBSD one. Run sudo apt install -y netcat-openbsd.

Could not resolve hostname. ssh tried ordinary DNS, which has no answer for .onion, so the ProxyCommand never ran. The Host pattern in ~/.ssh/config does not match the name you typed.

Permission denied (publickey). The tunnel worked and tor is done. Treat this as an ordinary permission denied publickey problem and leave tor out of it.

FAQ

Does an onion service really mean no open ports on my VPS?

Yes, once sshd is bound to 127.0.0.1 and the firewall drops inbound traffic. Tor makes an outbound TCP connection to a relay and your session travels back through it, so nothing on the box accepts a connection on the public address. Prove it with ss -tlnp on the server and a port scan from somewhere else. Do not forget the provider's own network firewall in the control panel, which is a separate control from ufw and has to be closed as well.

Is the .onion address enough security on its own for SSH?

No. The address is 56 characters and cannot be guessed or enumerated from the directory system, so it behaves like a secret, but it leaks through shell history and config files. Add v3 client authorisation. With it, the service descriptor is encrypted to your client key, so someone holding only the address gets extended error F4 and never reaches sshd at all.

What happens if tor fails to start after a reboot?

You lose SSH access completely, because the onion address is then the only way in. That is why the provider console has to be tested before you close port 22. Tor also needs time to bootstrap after boot, so the address answers later than the machine responds to ping. If it never answers, log in on the console and read sudo journalctl -u tor@default -b, where a torrc syntax error or a permissions problem on /var/lib/tor/ssh is printed.

Is SSH over Tor slower than WireGuard?

Yes, by a wide margin. A connection to an onion service crosses about six relays chosen at random, while WireGuard is one encrypted hop straight to your server. Typing feels laggy and transfers are slow. A common setup is WireGuard for daily work with the onion service kept as the emergency route that survives a broken VPN config.