I was setting up a machine recently and noticed something odd.
OpenSSH password authentication was disabled. The user I was logging in as did
not have anything useful in ~/.ssh/authorized_keys. I do not expose SSH on
this box to the internet either.
Regular SSH should have failed. This still worked:
| |
I got a shell as the local Unix user vinay.
That was the bit I wanted to understand. OpenSSH did not accept a password, and
it did not find a public key in authorized_keys. In this path, OpenSSH was not
the thing deciding whether I could log in. Tailscale was.
I have been using Tailscale more deliberately lately. I previously wrote about setting up a Tailscale exit node, where the question was mostly: how does traffic leave my machine and reach the internet through a home gateway?
This post is me working through Tailscale SSH. I started with the assumption that it was mostly a convenience wrapper around SSH. It is not quite that.
The questions I had were:
- Is this still SSH?
- Is
sshdinvolved? - Where did my SSH keys go?
- What exactly does Tailscale authenticate?
- What does the Tailscale control plane know?
- What can DERP see?
- What role do local Linux users still play?
- What permission does
tailscaledhave that lets it start a shell as that Unix user?
TLDR
Tailscale SSH still gives you an SSH session, but it is not an OpenSSH-server login. The authentication decision moves from per-host SSH keys to Tailscale identity and tailnet policy.
The path is:
- Your devices are already connected through Tailscale’s WireGuard mesh.
- The destination machine has Tailscale SSH enabled with
tailscale up --ssh. - The tailnet policy contains
sshrules saying which Tailscale users, groups, or tagged devices can log in to which machines as which local Unix users. - When you connect to port 22 over the Tailscale address,
tailscaledhandles the SSH connection on the tailnet interface. - Tailscale checks the source device/user identity against the central policy.
- If allowed, the privileged
tailscaledservice starts a session as the requested local Unix user.
The comparison I keep in my head is:
OpenSSH usually asks: “Does this client have a key accepted by this host?”
Tailscale SSH asks: “Does this tailnet identity, from this device, have policy permission to become this local user on this node?”
The local Unix account still matters. Tailscale does not bypass Linux
permissions. If I log in as vinay, I get vinay’s files, groups, shell, and
sudo rules. Tailscale can create the session because tailscaled runs as a
privileged system service on the destination machine.
Normal SSH first
Before Tailscale enters the picture, this is the usual OpenSSH flow:
A simplified version of the flow:
- The client connects to the server on TCP port 22.
- Client and server negotiate encryption for the SSH transport.
- The server proves its identity using a host key.
- The client authenticates as a local username, usually with a private key.
sshdchecks files like~/.ssh/authorized_keysfor that local user.- If accepted,
sshdcreates a login session as that Unix user.
The protocol is not the problem. The annoying part is access distribution:
- every server needs the right public keys;
- removing someone’s access means removing keys everywhere;
- host keys need to be trusted or managed;
- if you have many machines, access review becomes annoying.
This is not an argument against OpenSSH. I use it everywhere. The point is that key distribution and access policy become your distributed system.
OpenSSH certificates solve a related key-distribution problem, but they still
use sshd as the login broker. Tailscale SSH moves the authorization decision
into tailnet policy.
What Tailscale changes
Tailscale already gives every device in your tailnet a cryptographic identity and a stable private address. My laptop and my server do not need a public inbound port open to find each other. They can establish a WireGuard-encrypted path using Tailscale’s control plane for coordination, NAT traversal, and policy information.
Tailscale SSH builds on that. Instead of asking “does this host have this public key for this local user?”, it asks “does this tailnet identity have permission to become this local user on this host?”
A typical setup has two parts.
On the server:
| |
In the tailnet policy:
| |
Read that as:
Members of this tailnet may SSH to devices tagged
server, but only as the local Unix uservinay.
In practice, I would usually make this stricter: specific users or groups, specific tags, specific destination machines, and specific local accounts.
The packet path
When I SSH to a Tailscale machine, the outer network path looks like the normal Tailscale path.
The cafe WiFi, airport WiFi, or hotel network sees an encrypted Tailscale flow. If the connection goes through DERP, the DERP relay forwards encrypted packets; it does not get to read the SSH session.
There are two layers to keep distinct:
- Tailscale/WireGuard layer: gets packets between the two tailnet devices.
- SSH layer: creates an interactive shell/session on the destination.
So this is not “SSH without encryption” just because WireGuard already encrypts traffic. The SSH protocol is still involved. The important difference is the authentication and authorization source of truth.
Who answers port 22?
This is the part I had wrong at first.
With normal SSH, the process answering port 22 is usually sshd. With Tailscale
SSH enabled, tailscaled answers SSH connections that arrive over the Tailscale
interface. Same port number, different daemon, different authentication path.
Tailscale SSH does not need the OpenSSH server to accept the login.
sshd_config, authorized_keys, OpenSSH AllowUsers, OpenSSH DenyUsers, and
password-login settings are part of the OpenSSH decision path. A Tailscale SSH
connection does not ask sshd whether the key is allowed. It asks Tailscale
policy whether this tailnet identity may become the requested local user.
The distinction matters:
So I can have a machine where OpenSSH is not accepting a user, or regular key-based SSH is unusable, but Tailscale SSH still works from inside the tailnet.
The destination can be behind NAT, on a home connection, with no port forwarding, and still be reachable from my laptop.
This is the part that finally made the socket model click for me. A port is not just a number floating around on the machine. A listener is tied to a local address and a port.
So 100.x.y.z:22 and 192.168.1.50:22 are different sockets. Tailscale SSH can
answer port 22 for connections arriving over the tailnet address while OpenSSH
answers port 22 somewhere else.
The same idea is not limited to SSH:
| |
All three are “port 80”, but they are not the same endpoint. The address matters as much as the port.
This also reminded me of how I use Nginx at home, though the mechanism is a bit
different. I run Unbound for DNS and use .home.arpa names for homelab services.
Several names can point at the same machine:
| |
Both requests arrive at the same machine on port 80. Nginx can still send them
to different upstream services because HTTP carries the requested hostname in the
Host header:
| |
So the exact comparison is not “different IP, same port” anymore. It is “same IP, same port, different HTTP host”. But it rhymes with the same lesson: port 80 alone does not tell the whole story. The OS first routes traffic to a socket based on address and port; then a protocol-aware service like Nginx can make a second decision based on HTTP metadata and reverse proxy internally.
The caveat is 0.0.0.0. A service bound to 0.0.0.0:80 asks the OS to listen
on all IPv4 interfaces, so it may prevent another process from binding a more
specific address like 192.168.1.50:80. But the basic shape is still: listeners
are about address plus port, not port alone.
How can Tailscale become a Unix user?
Tailscale is not mapping my Tailscale account into a Unix user in some global directory. It is using a privileged local daemon to create a normal local session.
On Linux, tailscaled usually runs as root under systemd:
| |
It needs elevated privileges for normal Tailscale networking work too: creating
or managing the tailscale0 interface, installing routes, configuring DNS, and
handling traffic for the tailnet. Tailscale SSH uses that same privileged daemon
as the login broker.
Once the ACL says a login is allowed, tailscaled can look up the requested
local user through the operating system’s user database: /etc/passwd, NSS,
LDAP, or whatever the machine is configured to use.
Conceptually, the privileged process can then do the standard Unix identity switch:
So if the machine has a user like this:
| |
then the resulting shell is a process with vinay’s UID, GID, supplementary
groups, home directory, and shell. The shell is not running as my Tailscale
account. It is not running as some special cloud user. It is running as the local
Unix user vinay.
The model I ended up with:
There is a real security trade here. Enabling tailscale up --ssh is not a
harmless UX toggle. It makes tailscaled a login authority for that machine.
That means I trust three things:
- the
tailscaleddaemon on the destination machine; - the tailnet SSH ACLs that decide who can log in;
- the tailnet admins who can change those ACLs.
The power moved. It moved from per-host OpenSSH config to a privileged local Tailscale daemon plus centralized tailnet policy. That may be exactly what I want, but it is not nothing.
The OpenSSH way to get multiple policies
There is another way to think about this from the OpenSSH side: I could run more
than one sshd.
This was a small duh moment for me. I have run multiple instances of services on
a dev box before. Postgres on different ports, for example. But I had never
really thought of SSH that way. I mostly treated sshd as “the SSH service on
the machine”, singular.
But sshd is just a daemon listening on a socket. A server can run multiple
OpenSSH instances as long as they do not bind the same IP/port pair. For
example:
| |
Each one can have its own config file:
| |
The reason to do this is to keep different SSH policies for different network paths.
For example, the regular daemon can stay restrictive, while a second OpenSSH daemon listens only on the Tailscale IP and a non-default port:
# /etc/ssh/sshd_config_tailnet
Port 2222
ListenAddress 100.x.y.z
PasswordAuthentication no
PermitRootLogin no
AllowUsers deploy
AuthorizedKeysFile .ssh/authorized_keys
This is still OpenSSH. The login decision is made by sshd, using
sshd_config, authorized_keys, PAM, and the usual OpenSSH machinery. Tailscale
is only providing the network path.
The comparison looks like this:
| |
That distinction matters for automation.
What happened to authorized_keys?
With traditional SSH, access usually lives on the destination host:
| |
With Tailscale SSH, access lives in the tailnet policy:
| |
That changes the operational model.
If I want to revoke access, I change policy or remove the device/user from the tailnet. I don’t need to remember every machine that might contain an old public key.
The practical shift is that SSH authorization becomes centralized and identity aware.
This explains the case I started with: regular SSH can be disabled for a user while Tailscale SSH still works.
For example, suppose vinay has no public key in
/home/vinay/.ssh/authorized_keys, or sshd_config contains rules that prevent
vinay from logging in through OpenSSH. A normal ssh vinay@server path that
lands in sshd fails. A Tailscale SSH path can still succeed if the Tailscale
SSH policy says my tailnet identity is allowed to log in as vinay.
The two paths are different:
authorized_keys is not the source of truth for Tailscale SSH. The ACL is.
The local user still has to exist on the destination machine. If policy says I
can log in as vinay, then vinay needs to be a real local account, with a
usable shell, home directory, groups, and permissions. Tailscale can authorize
becoming that user; it does not invent the Unix user or override what that user
can do once the session exists.
Tailscale decides whether I may become vinay; Linux decides what vinay can do
after that.
accept vs check
Tailscale SSH policies can require an additional check before allowing access. This is useful for sensitive machines or high-privilege accounts.
Conceptually:
| |
accept means policy allows the connection.
check means policy allows it only after a recent interactive verification. That
might mean reauthenticating through the identity provider before the session is
allowed.
I like this distinction because it separates two questions:
- Is this identity allowed in principle?
- Do we want a fresh human confirmation for this sensitive action?
For a personal homelab, this may feel like overkill. For a production box or a root login, it is a sane extra guardrail.
Trust boundaries
The trust question matters more than the syntax: who am I trusting, and for what?
The local network
The local network can see that my machine is talking to Tailscale peers or DERP, and it can see timing and volume. It should not be able to read the session contents.
DERP
DERP can relay encrypted packets if a direct connection is not possible. DERP is in the transport path but not supposed to be in the plaintext path. It forwards ciphertext.
Tailscale control plane
The control plane is trusted for identity, coordination, and policy distribution. It can tell devices who is allowed to talk to whom, distribute network maps, and enforce the administrative model of the tailnet.
It is not a server sitting in the middle reading my shell session.
Tailnet admins
Tailnet admins are powerful. They can change ACLs and SSH rules. If an admin can write policy that grants access to machines, that is a real security boundary. For a personal tailnet, I am the admin. In a company, this becomes an organizational trust question.
The destination machine
Once I log in, the destination machine is the destination machine. Tailscale does not protect me from a compromised host. If I SSH into a malicious or compromised box, my terminal session is on that box.
How I debug the model
These are the commands I use to keep the layers straight.
Check whether the peer is reachable through Tailscale:
| |
Check the Tailscale IP and MagicDNS name:
| |
Try SSH explicitly:
| |
On the destination, confirm Tailscale SSH is enabled:
| |
And remember the three separate gates:
- Can the devices reach each other on the tailnet?
- Does SSH policy allow this source identity to access this destination?
- Does the requested local Unix user exist and have the expected permissions?
Most of my confusion came from mixing these layers together.
Ansible, GitHub Actions, and why I still used OpenSSH
Tailscale SSH is pleasant when I am the one typing the command. Deployment tooling is a different story. A lot of it assumes normal OpenSSH semantics.
Ansible is where I ran into this. Its default shape is:
| |
It knows how to pass SSH arguments, use an inventory hostname, select a private key, set a port, become another user, copy files, and reuse connections. That all fits OpenSSH very naturally.
In one deployment, I had trouble making Ansible use Tailscale SSH cleanly from a
GitHub workflow. The target machine was on my tailnet, but the deployment path
was automation, not me typing tailscale ssh. I did not want the workflow to
need an exit node just to reach SSH, and I did not want to expose SSH publicly.
The compromise was simple: run OpenSSH on the tailnet, on a different port, and point the workflow at that.
| |
Then the Ansible inventory stays ordinary:
| |
or in YAML:
| |
This is less elegant than using Tailscale SSH policy everywhere, but it fits the tooling. Tailscale provides the private network path. OpenSSH provides the login surface Ansible already understands. Ansible does not need to know anything special.
The trade-off is that I now have two SSH-like paths to reason about:
tailscale ssh vinay@my-serverfor human access controlled by Tailscale SSH ACLs;ssh -p 2222 [email protected]for automation controlled by OpenSSH keys and the tailnet-onlysshd_config.
I am fine with that as long as I keep the boundary clear. Tailscale SSH and
OpenSSH-over-Tailscale are not the same thing. One uses tailscaled to authorize
and create the login. The other uses sshd; Tailscale only supplies the private
route.
Why this feels nicer than needing an exit node for SSH
The practical benefit for my setup is not “this saves me from public SSH”. I do not expose SSH publicly anyway.
The benefit is that I do not need to route all my traffic through a home exit node just to administer one machine.
Without Tailscale SSH, one possible remote-admin model is:
| |
That works, but it makes SSH access depend on the broader network path being set up correctly. I need the right routing, possibly LAN access through the exit node, and then normal SSH authentication on the destination.
With Tailscale SSH, the model is narrower:
| |
The parts I like are:
- no exit node required just for SSH;
- no router port forwarding;
- no per-host
authorized_keysdrift; - access is tied to SSO/tailnet identity;
- revocation is centralized;
- policies can be reviewed in one place;
- sensitive logins can require re-checks;
- the network path still benefits from Tailscale’s NAT traversal and DERP fallback.
For my homelab, that is the win. I can reach the one machine I care about over the tailnet without turning my whole connection into a full-tunnel VPN through home.
The model I am keeping
Tailscale SSH is not “a different terminal” and it is not “SSH replaced by a VPN”.
I now think of it as:
SSH session semantics, Tailscale network reachability, and centralized identity-based authorization.
The SSH server decision moves closer to the tailnet identity layer. The network path is the same private mesh I already use for other Tailscale services. The local Unix account remains the final operating-system boundary.
The login answers five separate questions:
That decomposition is the main thing I wanted from this post. Once I can name those layers, Tailscale SSH stops looking like a special exception and starts looking like a composition of WireGuard, identity, policy, a privileged daemon, and ordinary Unix users.
References
- Tailscale SSH documentation
- Tailscale ACL syntax
- Tailscale userspace networking
- How Tailscale works
- My previous post on Tailscale exit nodes
If you liked this post, you might also like Merrilin, the reading app I’m building. It has spoiler-aware AI companions, series-aware questions, live sync, themes, quote sharing, and e-ink support.
I’ve written more about it in the launch post.