Hardening My Tailnet Without Breaking My Homelab
A report about Tailscale SSH was enough to make me stop and look at my own network. I use Tailscale to connect personal devices, reach development services, and work with local models. It is useful precisely because it makes those connections feel ordinary.
That convenience also made it easy to leave a question unanswered: if one of these machines were compromised, what else would it be allowed to reach?
My first instinct was to look for a checklist. The more useful exercise was to find out which parts of that checklist actually applied to my setup. I was not running Tailscale SSH, some of my machines initiated connections I needed to preserve, and one machine existed specifically so I could start new applications without repeatedly changing infrastructure configuration.
This is a record of that review, not a universal hardening policy. The names and addresses in the examples are fictional; they are not an export of my network.
An alert is a reason to investigate, not a diagnosis
The immediate trigger was this October 25 report about Tailscale SSH. It describes research called Tailworm, including possible privilege escalation through SSH forwarding after an attacker has obtained an authorized connection.
I did not reproduce those exploits or establish that my own network had been compromised. I also did not treat a research report, a social-media recommendation, and a vendor security bulletin as interchangeable evidence.
When I checked Tailscale's security bulletins on October 25, 2026, the newest entry was TS-2026-013, an unrelated certificate issue. The news report said the researcher had tested version 1.104.2, which was also the version installed on my devices. Being current was useful, but it was not evidence that these newly reported issues had been patched.
The configuration review below was something I could do without claiming to have resolved the underlying vulnerabilities. Any later advisory or fixed release needs to be evaluated separately.
First discovery: SSH over Tailscale is not necessarily Tailscale SSH
I regularly run ssh against machines reachable through Tailscale. That did not mean I was using the Tailscale SSH server.
There are two different arrangements:
- Ordinary OpenSSH over the Tailscale network. Tailscale provides network connectivity; the host's SSH server and its authentication configuration still handle the login.
- Tailscale SSH. Tailscale handles SSH authentication and authorization using the tailnet policy, and intercepts incoming port 22 traffic on the Tailscale address of a device where the feature is enabled.
The relevant local preference was visible with:
tailscale debug prefs
RunSSH was false on the computers I inspected. Existing connections were using ordinary OpenSSH. I had no reason to enable Tailscale SSH just to add check mode, and removing its authorization would not require replacing those existing SSH logins.
I removed the Tailscale SSH authorization from the tailnet policy:
"ssh": []
That is a policy fragment, not a complete file. It denies Tailscale SSH authorization even if someone later enables the feature on a device. It does not block ordinary SSH where the network grants still permit TCP port 22.
For someone who actually depends on Tailscale SSH, that same change could remove their access. Inventory and an alternate login path have to come before applying it. Check mode can add a reauthentication barrier to an authorized Tailscale SSH connection; it is not a repair for privilege escalation inside that session.
Map connections before deciding which ones should disappear
The policy I found contained an unrestricted network grant:
{"src": ["*"], "dst": ["*"], "ip": ["*"]}
The important discovery was not simply that the rule was broad. It was that I had never written down the smaller set of connections I actually wanted.
I started with listening ports and existing connections. On Linux, these were useful read-only commands:
ss -tlnp
ss -tnp state established
ss -ulnp
On macOS, I used:
lsof -nP -iTCP -sTCP:LISTEN
lsof -nP -iTCP -sTCP:ESTABLISHED
Unprivileged output does not necessarily identify every owning process. A listener is also not proof that a service is reachable from another machine. I used the output to build a connection map, then checked reachability from the relevant source devices.
That map included SSH sessions initiated by a machine I might otherwise have classified as a passive server. A rule saying “servers cannot initiate connections” would have broken a real workflow.
I also distinguished permission from use. An approved exit-node capability was not the same as an actively advertised exit node. Permission to use Funnel was not the same as an active public endpoint. Private Serve and public Funnel were separate features to inspect, not two names for the same exposure.
The experimentation machine needed an intentional exception
A strict port list is attractive until every temporary application requires a policy edit.
One of my machines is an experimentation environment. I run applications and model servers there, and I want to open those applications from my trusted client devices. Its useful port list is not static.
My compromise was directional:
- Trusted computers could reach any TCP port on the experimentation machine.
- A mobile client could reach a smaller set of explicit application ports.
- The experimentation machine could initiate only the connections I had identified as necessary.
- Unrelated cross-device access was not granted.
That is not the smallest possible privilege set. It is an explicit usability trade-off, scoped by source, destination, and protocol instead of a tailnet-wide wildcard.
This fictional excerpt illustrates the difference:
{
"hosts": {
"example-workstation": "100.64.0.10",
"example-lab": "100.64.0.20",
"example-phone": "100.64.0.30"
},
"grants": [
{
"src": ["example-workstation"],
"dst": ["example-lab"],
"ip": ["tcp:*"]
},
{
"src": ["example-phone"],
"dst": ["example-lab"],
"ip": ["tcp:8443"]
}
]
}
This is not a replacement policy to paste into a real tailnet. It omits the rest of the access map, tests, and IPv6 aliases. The real policy accounted for both address families and retained specific SSH paths that are not shown here. A separate allow-all rule would also override the intended restriction: grants are additive, not ordered firewall rules with later denies.
I used explicit device aliases instead of introducing tags for this small network. Tags are useful when permissions follow server roles, but applying them changes device identity and has key-expiry implications. My goal was not to adopt every feature in a recommended template. It was to make the access requirements legible without changing ownership unnecessarily.
An address-based policy needs maintenance too. If a device is removed and re-enrolled with different addresses, its aliases must be reviewed. It does not automatically inherit access just because I sign in with the same account.
The broad TCP allowance also includes administrative services or a container API if I expose one on that machine. Network reachability does not replace application authentication. And a retained SSH path still represents a possible route for an attacker who has the necessary credentials or another way to exploit that access.
Validate what should fail, not only what should work
Before applying the new policy, I saved the original file, reviewed the diff, and checked that the live version had not changed underneath me. The console's save path also checked the policy revision to avoid silently overwriting another edit.
I added policy assertions for both sides of the intended behavior. These fictional test fragments show the shape:
"tests": [
{
"src": "example-workstation",
"proto": "tcp",
"accept": ["example-lab:22", "example-lab:8080", "example-lab:65535"],
"deny": ["example-phone:22"]
},
{
"src": "example-phone",
"proto": "tcp",
"accept": ["example-lab:8443"],
"deny": ["example-lab:22"]
},
{
"src": "example-phone",
"proto": "udp",
"deny": ["example-lab:8443"]
}
]
The high-numbered TCP port is intentional: it asserts that the workstation's exception includes future applications, not just today's listeners. It does not claim a service is listening there. The UDP assertion protects the distinction between tcp:8443 and a bare port selector that would grant more protocols.
Those examples are only a subset of the checks. My applied policy included IPv4 and IPv6 assertions, plus assertions denying Tailscale SSH authorization. It passed Tailscale's native validator before saving.
After rollout, I tested connections from the source machines themselves. A connection that works from my laptop says nothing about whether a different source has been restricted. I also compared against the baseline: a dashboard that was already unreachable before the policy change remained a separate service problem, not evidence that the new grants had broken it.
I used an AI assistant for read-only discovery, drafting, and verification, with a separate review before the change. That review caught a gap in the tests: some UDP denials did not cover ports explicitly allowed for TCP. I added those assertions. The review improved the checks; it was not a security certification.
Tailnet Lock solves a different trust problem
Reducing network permissions answers “what can an admitted device reach?” Tailnet Lock addresses a different question: “who can authorize a new device's node key?”
With Tailnet Lock, peers require node keys signed by trusted signing keys. This reduces reliance on the coordination server for introducing new peers. Initial setup still uses trust on first use, so selecting signing devices is not a substitute for checking the existing inventory and comparing the trusted-key sets.
It does not make an already compromised node trustworthy, patch an SSH implementation, or revoke the access deliberately granted to an existing node.
One operational detail was easy to miss: Tailnet Lock and administrative device approval are mutually exclusive. I initially enabled device approval, then replaced it with Tailnet Lock. They are alternatives, not two switches to leave on together. I had a recovery plan ready before making that transition.
The admin-console setup required at least two signing devices. I chose personal devices rather than giving every always-on machine signing authority. Availability was not the only consideration: a machine reachable from an experimental environment is not automatically the best place to put a key that can authorize new peers.
Two signing devices did not mean two-person approval for each new node. Either trusted signer could authorize one. Protecting those devices still mattered.
I initialized the lock in a private terminal and stored its disablement secrets in my password manager, outside the assistant transcript and the repository. I chose not to send an additional disablement secret to Tailscale support. That reduces one recovery trust dependency, but also removes that support-assisted disablement option; losing my own recovery material is a serious operational risk, not an advanced-security badge.
Expiration still needed attention
The review also found a machine whose authentication was close to expiring. I kept key expiry enabled and reauthenticated it rather than turning off expiry to remove the warning.
The documented command was:
sudo tailscale up --force-reauth
This can interrupt the Tailscale connection. Use a local terminal or a tested alternate path, such as SSH over the LAN, rather than your only remote-access path.
After signing in with the same account and tailnet, I checked:
tailscale status
tailscale lock status
The machine retained its addresses, its expiry date renewed, and it remained signed under Tailnet Lock. Tailscale documents automatic signature rotation on reauthentication for signed nodes; I did not need to initialize the lock again.
What I verified—and what remains outside this review
The final state was more specific than “the network is secure”:
| Check | Observed result |
|---|---|
| Saved policy | Matched the reviewed policy; native validation reported no errors or warnings |
| Tailscale SSH and Funnel | No longer authorized by the policy; ordinary SSH and private Serve were retained where required |
| Tailnet Lock | Enabled; inspected computers reported the same two trusted signing keys and authority head |
| Existing peers | Signed; no peers were filtered from the inspected lock state |
| Runtime checks after enabling the lock | All 24 checks matched their expected allowed, blocked, or pre-existing-unreachable outcomes |
| Reauthentication | Addresses preserved, expiry renewed, signature retained; 11 follow-up checks matched expectations |
The phone was visible as a signed peer and answered an encrypted Tailscale ping. I did not independently inspect its local authority display or run application requests from the phone, so I kept those limits separate from the computer-origin checks.
Automatic updates were enabled on the inspected computers, and I left the default for new devices enabled. That was not a substitute for checking the phone's App Store update settings or following future advisories.
This was not a forensic investigation or an exploit test. It did not audit every application, change host firewalls, remove direct LAN or public exposure, or prove that every service required authentication. The deliberately preserved SSH paths and broad inbound TCP exception still existed. Tailnet Lock did not erase those trade-offs.
The useful result was a smaller set of assumptions
The best part of the exercise was not enabling one feature. It was replacing an implicit assumption—these are my devices, so they can all reach each other—with a set of connections I could explain and test.
I could still start applications on the experimentation machine. I could still use the SSH connections I needed. But those requirements no longer implied access from every device to every other device on every port.
If I repeated this on another network, I would keep the sequence: inventory first, map actual connections, review the diff, validate the policy, test the allowed and denied paths, and prepare recovery before changing device admission. I would not copy the resulting policy without repeating the reasoning.
References
Sources checked on October 25, 2026. The security-report status is a snapshot, not a claim about later releases.
- Cyber Security News: reported Tailscale SSH flaws — the trigger for the review, not independent confirmation of the research findings.
- Alex Hillman's shared lockdown workflow — useful inventory and rollout questions; I adapted it rather than copying its tagging model.
- Tailscale security bulletins.
- Tailscale SSH.
- Grants syntax and policy tests.
- Tailnet Lock, including signing, recovery, trust on first use, and the device-approval limitation.
- Key expiry and reauthentication.