Tailscale SSH: connection refused, permission denied, and which server actually answered
Posted in Tailscale, Ssh, Networking, Linux
By Dušan Dželebdžić

A while ago I wrote up the "tailnet policy does not permit access" error, and it became the post Google sends the most people to on this site. Search Console has since been showing me the searches that land on it, and a good chunk of them are looking for something that post doesn't answer: tailscale ssh connection refused, tailscale ssh permission denied publickey, tailscale port 22 connection refused. Different errors, same confusion, and one mental model sorts all of them out.
On a machine running Tailscale, there can be two SSH servers on port 22. Which one you reach depends on how you arrived. And every one of those errors is really the answer to a single question: which server, if any, picked up?
Two servers, one port
Tailscale SSH doesn't replace sshd. When you enable it on a node with tailscale set --ssh, tailscaled starts intercepting traffic to port 22, but only traffic that arrives from your tailnet, on the node's Tailscale IP. Connections arriving on the LAN address or the public address still land on whatever OpenSSH is (or isn't) running there. A DNS name is neither here nor there; what matters is which address it resolves to. Tailscale's docs are explicit that it doesn't touch sshd_config or authorized_keys.
So a single host can, at the same time:
- answer
ssh 100.x.y.zwith Tailscale SSH (no keys, tailnet identity, maybe a browser check), - answer
ssh 192.168.1.20with regular OpenSSH and its usual key or password prompt, - and answer neither on a port 22 that nobody is listening on.
Once you see it that way, the client errors stop looking like variations on "it's broken" and start reading like a routing table.
Timed out: the packet never got there
ssh: connect to host 100.101.102.103 port 22: Operation timed outBefore any refusal or denial, there's the case where nothing comes back at all. That's not an SSH problem yet, it's a tailnet problem: the node is offline or its key has expired (tailscale status on your side tells you), the target runs --shields-up, or the network part of the policy (the grants or acls section, not the ssh section) doesn't allow TCP port 22 to that node, in which case Tailscale silently drops the packets. One more: if your client runs Tailscale in userspace-networking mode (containers, mostly), plain ssh can't reach the tailnet at all, and tailscale ssh with its built-in proxy is the way in.
"Connection refused": nobody answered
ssh: connect to host 100.101.102.103 port 22: Connection refusedThe packet made it across the tailnet, reached the node, and got a TCP reset. Nothing accepted the connection: usually because nothing is listening, occasionally because a host firewall rejects instead of drops. tailscale ping will succeed all day. The usual reasons:
Tailscale SSH isn't enabled on the target, and sshd isn't there either. Tailscale gives you connectivity, not services. If the target is an iPad, a phone, a Windows PC without the OpenSSH server installed, or a Linux box where nobody ever ran tailscale set --ssh or installed openssh-server, port 22 is simply closed. Enable one of the two servers:
sudo tailscale set --ssh # Tailscale SSH (Linux, or macOS with the open-source tailscaled)
sudo apt install openssh-server # or the traditional wayNote which platforms can even run the Tailscale SSH server: Linux, and macOS only via the open-source tailscaled variant. The App Store Mac client, Windows, iOS, Android, Synology and QNAP can connect out, but they won't answer on port 22 for you. On those, OpenSSH is your only option.
You're connecting to your own Tailscale IP. Tailscale SSH doesn't serve the machine it runs on. A connection to your own 100.x address never enters tailscaled; the kernel routes it locally, straight to whatever sshd is listening there. No sshd, and ssh $(tailscale ip -4) gets refused on a box that every other node connects to fine. If you need loopback SSH (a git remote that resolves to your own tailnet address, say), you need a local sshd for it.
sshd is listening, but not on the Tailscale interface. A ListenAddress pinned to the LAN IP means port 22 is open on 192.168.1.20 and closed on 100.x.y.z. ss -tlnp | grep :22 on the target shows exactly which addresses it's bound to. Tailscale SSH won't appear in that list at all, by the way: it lives inside tailscaled's own network stack, not the kernel's socket table, so tailscale debug prefs | grep RunSSH is the check for that one. Same story with firewalls: a rule that allows port 22 on eth0 but was never told about tailscale0.
"Permission denied" before the handshake: your own machine said no
This one fools people because it contains the words "permission denied" and has nothing to do with authentication:
debug1: Connecting to pihole [100.90.36.34] port 22.
debug1: connect to address 100.90.36.34 port 22: Permission denied
ssh: connect to host pihole port 22: Permission deniedLook at where it fails: at connect to address, before any SSH banner is exchanged. The client's operating system refused to open the socket. On Windows that's usually another VPN client, a firewall, or an endpoint security product sitting on the network stack, and the message can't tell you which. One r/Tailscale thread spent a week on ACLs before discovering that turning off a commercial VPN client fixed it instantly. The server was never involved.
"Permission denied (publickey)": OpenSSH answered
dusan@100.101.102.103: Permission denied (publickey).Now something did answer, and it wants a key. That's OpenSSH, which means the connection never reached Tailscale SSH. Either it isn't enabled on the target, or you didn't come in the front door Tailscale is guarding:
- You used the LAN IP, a public hostname, or a DNS name that resolves to a non-Tailscale address. Tailscale SSH only claims port 22 on the Tailscale IP. Use the MagicDNS name or the
100.x.y.zaddress. - The target never ran
tailscale set --ssh. The Machines page in the admin console can filter on "Tailscale SSH" and labels the hosts that have it on. No label, no Tailscale SSH, and OpenSSH keeps doing what it always did. - You're coming from outside the tailnet entirely. Tailscale SSH can't be reached through a non-Tailscale address, by design.
- Your ssh config sent you somewhere else. A
Hostentry with its ownHostName,PortorProxyJumpquietly changes where the connection goes.ssh -G hostprints what will actually be used.
There's a smaller sub-case worth knowing: some SSH clients refuse to complete a login with the none authentication method Tailscale uses. Tailscale's workaround is to append +password to the username (ssh dusan+password@host) and type anything at the prompt. It isn't a password check, it's a handshake formality.
"Permission denied (tailscale)": Tailscale answered and said no
dusan@host: Permission denied (tailscale).or, in newer clients:
tailnet policy does not permit accessThis is the good news case. Tailscale SSH picked up, identified you, checked the ssh section of the tailnet policy, and rejected the combination of who you are, which device you're going to, and which user you asked for. The earlier post covers the basics (an ssh rule has to exist, and autogroup:nonroot really does exclude root). The two I see trip people up beyond that:
Tags change who owns the device. A tagged machine is owned by its tag, not by you, so dst: ["autogroup:self"] stops matching it the moment you tag it. Your rule needs the tag in dst, and src has to match the identity of the device you're coming from: your user, a group, or autogroup:member when it's your laptop, the source's tag when it's another tagged machine. The classic symptom: SSH worked until the day you got organised and tagged your servers. It cuts the other way too: a tagged source device can only Tailscale-SSH into other tagged devices, never into one owned by a user, and tag-to-tag rules can't use check mode.
The user has to exist on the host. users in the policy is a list of OS accounts Tailscale is allowed to log you in as. It won't create any. Ask for deploy on a box that has no deploy user and you'll get a variation of failed to look up deploy followed by the same denial.
And one thing that isn't an error at all: if your policy uses "action": "check" (the default rule does), Tailscale opens a browser tab asking you to re-authenticate before the session starts. People see the terminal pause, assume the connection hung, and hit Ctrl-C. It's waiting for you, not failing.
The sixty-second triage
Before touching the ACL editor, run the connection with -v and read where it stops:
ssh -v user@host- Hangs, then times out: the packet never arrived. Node offline, key expired,
--shields-up, or a network policy without port 22. - Dies at
connect to address ... Permission denied: your own machine blocked it. Look at local VPNs and firewalls. - Dies at
connect to host ... Connection refused: nothing is listening. Enable Tailscale SSH or sshd, check interfaces, check that you're not connecting to yourself. - Gets a banner and offers
publickey(or password): OpenSSH answered. Tailscale SSH isn't enabled on the target or you didn't come in via the Tailscale IP. - Gets a banner, then
Permission denied (tailscale)or the policy message: Tailscale SSH answered. Now, and only now, go fix the policy.
Two more gotchas while you're in there. Running tailscale set --ssh on a host hangs any existing SSH sessions you have open to its Tailscale IP, so expect the terminal you ran it from to freeze if that's how you got in. Switching either server on or off also changes the host key your client sees on that address, so a sudden remote host identification has changed right after toggling Tailscale SSH is that, not an intruder. And the tailscale ssh CLI wrapper isn't available on the sandboxed App Store build of the Mac client; the docs simply tell you to use plain ssh, which works because of everything above.
Takeaway
"Connection refused" over Tailscale is not a Tailscale error. It's the honest report that nothing on the far end is listening. The permission-denied family is more useful than it looks: the word in the parentheses tells you which SSH server you reached, and therefore which of two completely different configurations you need to fix. Read the error, pick the server, then start debugging.
Got a tailnet that refuses you for reasons you can't see? Tell me what's going on and I'll take a look.
Previous: 280 vulnerabilities, one unused package