What you get
Key distribution is the part of SSH nobody enjoys owning. Public keys accumulate in authorized_keys files across a fleet, nobody remembers which laptop a given key lives on, and removing access means finding every host that ever trusted it. Tailscale SSH replaces that entirely: the connecting device is already authenticated to the tailnet, so the policy file decides who may reach which host as which local user, and there is no key material to hand out or claw back. Revocation becomes a policy edit rather than a fleet sweep.
Then you add the two things almost nobody turns on. First, action: check, which forces the user back through your identity provider before a session to a sensitive host, and which “may also trigger any identity provider multifactor authentication (MFA) or other risk-based challenges” (kb-ssh). Second, session recording, which streams the terminal to a recorder node in asciinema format, producing “newline-delimited JSON files that can be searched as text” (kb-recording). The result is an SSH story with a beginning you can prove and a middle you can read back.
How it works
Enabling Tailscale SSH on a host makes tailscaled claim “port 22 for the Tailscale IP address (that is, only for traffic coming from your tailnet)” and route that traffic “to an SSH server run by Tailscale, instead of your standard SSH server” (kb-ssh, docs-ssh). The mechanism is netstack port interception plus just-in-time configuration, not a rewrite of your host. The documentation is explicit that your /etc/ssh/sshd_config and ~/.ssh/authorized_keys “will not be modified, which means that other SSH connections to the same host, not made over Tailscale, will still work” (kb-ssh). Your existing sshd stays exactly where it was, on every other address, which is what makes this safe to switch on before you trust it.
Access is decided by the ssh section of the tailnet policy file, evaluated per connection against the peer’s identity. action is either "accept", which accepts authenticated tailnet users, or "check", which requires periodic reauthentication (kb-acl-syntax). When a rule also carries a recorder field, the session is streamed to the tagged recorder nodes it names.
Build it
-
Enable the SSH server on the destination node. The documented command is:
tailscale set --sshThe
--sshflag also exists ontailscale up, which is how the docs show a key rotation:tailscale up --ssh --force-reauth(kb-ssh). The server side runs on “Linux and macOS open sourcetailscale+tailscaledCLI devices only” and requires Tailscale v1.24 or later; clients can be any device running Tailscale (kb-ssh). -
Write the baseline rule. A new tailnet ships with this default (kb-ssh):
"ssh": [ { "action": "check", "src": ["autogroup:member"], "dst": ["autogroup:self"], "users": ["autogroup:nonroot", "root"] } ]srcaccepts a user, group, tag,user:*@<domain>, or an autogroup, and cannot be a bare*.dstaccepts a user, tag, or autogroup, also never a bare*, and needs no port because only 22 applies.usersis “the set of allowed usernames on the host” and acceptsautogroup:nonrootorlocalpart:*@<domain>(kb-ssh, kb-acl-syntax). The relevant autogroups:autogroup:self“includes all devices owned by the same user,”autogroup:member“includes all members of the tailnet,”autogroup:tagged“includes all devices that have at least one tag,” andautogroup:nonrootis SSH specific, meaning “a user can log in as any user except root” (kb-targets). -
Split sensitive destinations onto a check rule. Give the hosts that matter their own tag and their own rule:
{ "action": "check", "src": ["group:platform"], "dst": ["tag:prod"], "users": ["autogroup:nonroot"], "checkPeriod": "1h" }checkPeriodhas a “minimum of one minute and a maximum of 168 hours (one week)” and defaults to 12 hours;"always"is also accepted (kb-ssh). Choose the number deliberately: it is how long a single identity proof stays good for that destination. -
Create the recorder tag. Session recording routes to tagged nodes, so declare the tag first (kb-recording):
"tagOwners": { "tag:session-recorder": ["<tag-owner>"], } -
Run the recorder. The documented container invocation is:
docker run --name tsrecorder --rm -it \ -e TS_AUTHKEY=$TS_AUTHKEY \ -v $HOME/tsrecorder:/data \ tailscale/tsrecorder:stable \ /tsrecorder --dst=/data/recordings --statedir=/data/state --ui--dst“specifies where recordings will be saved” and “accepts a local file path or an S3 region URL”;--statediris where the recorder keeps internal state;--ui“enables the recorder container web UI for viewing recorded SSH sessions” (kb-recording). Use an auth key that appliestag:session-recorder, and for S3 add--bucketwith--access-keyand--secret-key. -
Attach the recorder to the rule. The documented shape (kb-recording):
{ "action": "check", "src": ["group:platform"], "dst": ["tag:prod"], "users": ["autogroup:nonroot"], "checkPeriod": "1h", "recorder": ["tag:session-recorder"], "enforceRecorder": true }recorderis an “optional field; specify the tag attached to your recorder node.”enforceRecorderis an “optional field; defaults to false; if session recorder node is unavailable, should the session be denied?” (kb-recording).
Verify it
Prove three separate things, because they fail independently.
Prove the identity path. From node-b, ssh someuser@node-a against a host covered by the check rule. Check mode gives the user “a URL for signing in” and may add MFA or other risk-based challenges; after that the user reaches the device without re-verification for the checkPeriod, defaulting to 12 hours (kb-ssh, docs-ssh). If you get a shell with no prompt at all, your connection matched an accept rule somewhere else in the list, not the rule you just wrote.
Prove the recording path. Do something distinctive in the session, then find it in the recording. Because the format is asciinema, a .cast file is newline delimited JSON with a header and timestamped output entries, and the documented example of searching one is simply:
grep "sudo" <session-recording.cast>
You can also replay it with play <session-recording.cast> using asciinema, or open the recorder web UI at https://{recorder-name}.{tailnet-dns-name}.ts.net (kb-recording).
Prove the enforcement path. This is the test everybody skips. Stop the recorder and try to connect again. With enforceRecorder: true, “Tailscale SSH sessions will be refused. Any active Tailscale SSH sessions will be terminated” (kb-recording). If the session succeeds instead, enforceRecorder is not set on the rule your connection actually matched, and your recording coverage is best effort.
Failure signatures worth recognizing: an access denial usually means no rule matched, not that the daemon is broken; a MagicDNS resolution failure produces connection errors because you cannot connect using non-Tailscale IP addresses; and a session that dies mid command with “Access revoked” is a policy change, not a network fault.
Gotchas
-
Tagging a node removes it from
autogroup:self, silently. “Applying a tag to a device removes any user-based authentication,” and “it’s impossible for a user account identity and a tag identity to exist on the same device” (kb-tags). Sinceautogroup:selfmeans devices owned by the same user (kb-targets), a device you just tagged no longer has an owning user and stops matching. The Tailscale SSH documentation states directly that tagged devices cannot useautogroup:self(kb-ssh). The default policy usesdst: ["autogroup:self"], so the day someone tags a lab machine for a different reason, SSH to it stops working and nothing in the policy file looks wrong. -
Policy changes cut live sessions. Restricting a user’s SSH access “will stop existing SSH connections the user has established. The user will receive a message, ‘Access revoked’” (kb-ssh). Clients respond to new rules within seconds (docs-ssh). Do not edit the policy file mid maintenance window and expect the person on the console to survive it.
-
Restarting tailscaled ends sessions too. Upgrades count. That is an availability property of putting the SSH server inside the daemon (kb-ssh).
-
checkPeriod: "always"breaks automation. It “may cause unexpected behavior with automation tools that open many SSH connections in a short time span, like Ansible” (kb-ssh). Use it on a jump destination a human touches, not on anything a tool loops over. -
Widen
dstand you must revisitusers. The docs warn that if you changedstfromautogroup:selfto some other destination, you should “also consider replacingautogroup:nonroot” (kb-ssh), because a users list that was safe for your own devices is not automatically safe for shared infrastructure. -
Any OS user on the client machine inherits the access. There is no local key file authentication, so on a multi-user client “any OS user on the client machine can connect to SSH servers over Tailscale” (kb-ssh). Shared client machines are the weak point of the whole design.
-
enforceRecorderdefaults to false. Without it, “Tailscale will allow a Tailscale SSH session to connect when session recording is enabled for its SSH access rule even if the recorder nodes are unreachable” (kb-recording). A recording policy you cannot depend on is a compliance claim you cannot make. Set it to true where the receipts actually matter, and accept that you have chosen audit over availability.
Where to take it next
Separate recorder storage from recorder compute. Point --dst at an S3 region URL with a bucket whose retention and object lock are managed outside the tailnet, so a compromised node cannot erase its own transcript. That is the difference between a recording and evidence.
Build a two tier destination model. Tag the majority of hosts for accept with a generous checkPeriod nowhere in sight, and reserve check plus enforceRecorder for a small set of tagged production destinations. Fewer prompts on low value hosts means people stop treating the prompt as noise on the ones that matter.
Push identity further down the stack. As of 2026 workload identity federation is generally available, letting you authenticate Tailscale API requests with federated OIDC workload identities from third party providers, and Tailscale Services and Peer Relays are generally available as well (ts-changelog). The same instinct that removed authorized_keys from your hosts removes static API credentials from your automation.
Make the recordings answer questions on a schedule. Since .cast files are newline delimited JSON, a nightly pass that greps for privilege escalation and unexpected binaries turns a passive archive into a detection surface, using tools you already have.