Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 11 min read

Tailscale Exit Node Logic: Why Auto-Select Fails for Security and How to Fix It

The Tailscale tray icon says "Connected," and that tells you nothing about where your web traffic is going. If no exit node is active, Tailscale only carries traffic to your tailnet. Everything else (browsing, DNS lookup

The Tailscale tray icon says "Connected," and that tells you nothing about where your web traffic is going. If no exit node is active, Tailscale only carries traffic to your tailnet. Everything else (browsing, DNS lookups to non-tailnet resolvers, the background update checks your OS runs) goes out through whatever Wi-Fi you're on, just as it would without Tailscale. On a hotel or airport network, that's the traffic you wanted protected.

So you turn on an exit node. That leaves one question: should Tailscale pick the node for you (auto), or should you pin one and enforce it? Tailscale's docs present auto-select as the convenient default, and on a desktop that never moves it's usually fine. On a laptop that roams between networks you don't control, auto-select is solving a different problem from yours.

This post compares the two approaches, explains why auto-select falls short as a security control, and shows the setup I'd use instead.

Why you'd have to choose

The decision shows up as soon as you have more than one exit node. Common setups:

  • A home exit node running on a small VM or LXC (I covered the unprivileged-container version in Tailscale Kernel TUN in Unprivileged LXC)
  • A cloud VPS exit node for when home upload bandwidth is bad
  • Mullvad exit nodes through the Tailscale add-on
  • Exit nodes shared with you from someone else's tailnet

With one exit node, there's nothing to decide. With several, the client has to pick one. You can let it pick (--exit-node=auto:any) or you can pick yourself (--exit-node=exit-home). The two options look like a convenience question. They're really a question about what you're optimizing for.

Underneath that sits a second decision most people never make on purpose: what should happen when the chosen exit node is unreachable? Should traffic stop (fail closed), or fall back to the local network (fail open)? The two approaches answer this differently, and the answer matters more than which node gets picked.

Option A: Auto-select (auto:any)

What it is

Recent Tailscale clients can choose the exit node themselves. You can ask what the client would pick:

# Shows the exit node Tailscale would suggest right now
tailscale exitnode suggest

# Tell the client to keep choosing automatically
tailscale set --exit-node=auto:any

The suggestion is based on reachability and latency (and region, for Mullvad-style location-based nodes). As conditions change, the client can switch to a different node. Admins can also push this through MDM/system policy (ExitNodeID set to auto:any) so the user can't turn it off.

Strengths

  • No hostname to keep current. Add or retire an exit node and clients adjust on their own.
  • Good latency. If you travel between regions and have exit nodes in several of them, auto picks the close one. That's useful if you mostly care about speed.
  • Survives a dead node. When one exit node goes offline, auto moves to another one rather than leaving you stuck on a dead route.
  • Fleet-friendly. For an org with dozens of regional exit nodes and centrally managed laptops, auto plus MDM policy takes almost no ongoing work.

Weaknesses

The issue is what auto doesn't know, and there are two gaps.

It doesn't know which exit nodes you trust. "Any" means any exit node your ACLs let this device use. If a friend's shared node, a test VPS you forgot about, or a Mullvad node in a country you'd rather not appear from is eligible, it's in the pool. Auto won't treat your home node, which you patch and control, differently from a node someone else runs. Every eligible exit node can see your plaintext egress traffic after it leaves the WireGuard tunnel. That makes "who runs this box" a security question, and auto answers it by measuring latency.

It doesn't know anything about your local network. Auto has no idea whether you're on the home LAN you trust or on airport Wi-Fi. Tailscale doesn't have a built-in "trusted SSID" setting. Whether the exit node gets enabled at all is up to you or your MDM policy. Auto only decides which node to use after something else has decided to use one.

There's a smaller operational point too. The egress IP can change during a session when auto moves you to another node. Sites that tie sessions to IPs (banking, some SSO providers, anything doing geo-velocity checks) may log you out or show a challenge page. That doesn't make you less secure, but it's annoying enough that people turn exit nodes off, which does.

When it shines

Auto-select works well when every eligible exit node belongs to one operator you trust, when ACLs keep the pool tight, and when policy forces the exit node on. That describes a well-run corporate fleet. It doesn't describe a homelab tailnet where your shared-node experiments live next to your actual egress box.

Option B: Pinned exit node, enforced by the OS

What it is

You choose a specific exit node by its MagicDNS name and make the operating system enforce it, so it doesn't depend on you remembering to click the toggle. The naive version looks like this:

# The naive pin: works until it doesn't
sudo tailscale up --exit-node=100.64.0.10 --exit-node-allow-lan-access=false

There are two problems with this. Pinning by tailnet IP is fragile. If you rebuild the exit node and it gets a new address, every client stays pinned to an address that no longer exists. Use the hostname instead (--exit-node=exit-home). Also, tailscale up with flags will complain if you leave out settings you set earlier. tailscale set changes only what you pass, which makes it the right tool for automation.

The second problem is more interesting. A pinned exit node fails closed. If exit-home is unreachable (home ISP outage, the VM is rebooting, a NAT traversal problem after you roam to a new access point), the client keeps sending your default route into a tunnel that goes nowhere. In practice that looks like half-loaded pages, DNS timeouts, and a browser stuck on "Resolving host…" forever. From a security standpoint, that's the correct behavior. It's also exactly what makes people turn the exit node off at the airport gate and forget to turn it back on.

Making the pin hold up: a NetworkManager dispatcher

The fix is to stop treating the exit node as a setting you flip by hand and treat it as policy that runs on network events. NetworkManager runs every executable in /etc/NetworkManager/dispatcher.d/ with the interface and action as arguments. That's enough to say: "whenever a Wi-Fi connection comes up, enforce the exit node, preferring my own node and falling back to a second trusted one."

#!/usr/bin/env bash
# /etc/NetworkManager/dispatcher.d/50-wifi-safe
# Enforce a trusted exit node whenever Wi-Fi comes up. Idempotent.
IFACE="$1"; ACTION="$2"
PREFERRED="exit-home"     # MagicDNS names, not IPs
FALLBACK="exit-vps"

case "$ACTION" in up|connectivity-change) ;; *) exit 0 ;; esac
[ "$(nmcli -g GENERAL.TYPE device show "$IFACE" 2>/dev/null)" = "wifi" ] || exit 0

# Give tailscaled a moment after roaming before probing peers
for _ in 1 2 3 4 5; do
  tailscale status >/dev/null 2>&1 && break; sleep 2
done

pick=""
for node in "$PREFERRED" "$FALLBACK"; do
  if tailscale ping --c 1 --timeout 3s "$node" >/dev/null 2>&1; then
    pick="$node"; break
  fi
done

# No trusted node reachable: still pin the preferred one. Fail closed, never open.
tailscale set --exit-node="${pick:-$PREFERRED}" --exit-node-allow-lan-access=false
logger -t wifi-safe "iface=$IFACE action=$ACTION exit-node=${pick:-$PREFERRED}"
sudo chown root:root /etc/NetworkManager/dispatcher.d/50-wifi-safe
sudo chmod 755 /etc/NetworkManager/dispatcher.d/50-wifi-safe   # NM skips files that aren't root-owned

A few details here are deliberate:

  • No SSID allowlist. The script treats every Wi-Fi network as hostile, including your home network. That's the "trust the tailnet, not the network" model from the earlier post on dispatcher hardening. Allowlists rot: the coffee shop renames its SSID, someone sets up an evil twin of your home network name, and suddenly you're trusting something you shouldn't. If you really want home traffic to skip the exit node, match on something harder to spoof than an SSID.
  • The fallback list is short and curated. That's the actual difference from auto:any. You still get failover, but only among nodes you've decided to trust.
  • Total failure still fails closed. If neither node responds, the script pins the preferred one anyway. You lose connectivity instead of quietly sending traffic in the clear. The logger line means you can check journalctl -t wifi-safe and see why.

Locking down the local side

An exit node protects egress. It does nothing about other devices on the hotel network probing your laptop. Two pieces cover that, and both have persistence gotchas I've written about separately.

Bind Wi-Fi connections to the restrictive firewall zone through NetworkManager, so the binding survives reboots (runtime-only firewall-cmd changes vanish, as covered in Firewall Zone Persistence):

# Every saved Wi-Fi profile goes to the public zone
for c in $(nmcli -g NAME,TYPE connection show | awk -F: '$2=="802-11-wireless"{print $1}'); do
  sudo nmcli connection modify "$c" connection.zone public
done

# tailscale0 isn't NM-managed, so assign it permanently via firewalld
sudo firewall-cmd --permanent --zone=trusted --add-interface=tailscale0
sudo firewall-cmd --reload

(The for loop splits on whitespace, so SSIDs with spaces need a while read loop instead. Most of mine don't have spaces, but yours might.)

Then turn off the name-resolution protocols that answer to anyone on the LAN. Use a drop-in rather than editing resolved.conf directly. On recent Fedora the vendor defaults live elsewhere, and editing the main file can shadow them in ways that are hard to notice (Systemd Config Merges has the details):

# /etc/systemd/resolved.conf.d/50-untrusted-lan.conf
[Resolve]
LLMNR=no
MulticastDNS=no
sudo systemctl restart systemd-resolved
resolvectl status | grep -E 'LLMNR|mDNS'   # both should read "no"

Diagnosing "half-loaded pages"

When the pinned approach breaks, the symptom is always the same: some pages load from cache, others hang, and DNS looks flaky. The cause can be on either end, and the fix depends on which one it is.

# Is the local daemon healthy? Look for health warnings at the bottom.
tailscale status

# Which exit node is set, and does the client think it's online?
tailscale status --json | jq '.ExitNodeStatus'

# Can we actually reach it, and via DERP or direct?
tailscale ping exit-home

# Is the problem DERP/UDP reachability from this network?
tailscale netcheck

How to read it: if tailscale status shows health warnings about DNS forwarding failing or no DERP connection, the problem is local. The network you're on is blocking UDP, a captive portal hasn't been accepted, or tailscaled is wedged. If status looks clean but ExitNodeStatus.Online is false or tailscale ping times out, the exit node itself is down, and that's what the fallback in the dispatcher exists for.

Captive portals deserve a mention. Until you accept the portal, nothing gets through, including the tunnel to your exit node. Most portals intercept HTTP, which never reaches them because your traffic is pointed at a dead route. The practical fix is to temporarily clear the exit node with tailscale set --exit-node=, accept the portal, and then reconnect Wi-Fi so the dispatcher puts the pin back. Tailscale's captive portal detection helps here on recent clients, but I wouldn't count on it at every hotel.

Weaknesses of Option B

  • More moving parts: a script, file permissions, firewall zones, a resolved drop-in.
  • Linux/NetworkManager-specific. macOS and Windows need different mechanisms (MDM policy is the realistic choice there).
  • Captive portals need a manual step.
  • If both trusted nodes go down, you're offline until one comes back. That's the point of the design, but it's still a cost.

Decision framework

Criterion Auto-select (auto:any) Pinned + dispatcher
Who chooses the egress operator Latency and reachability You, from a short trusted list
Awareness of local network trust None Treats every Wi-Fi as hostile
Behavior when the chosen node dies Moves to any other eligible node Moves to the next trusted node, else fails closed
Egress IP stability Can change mid-session Stable unless failover fires
Setup effort One command or MDM key Script, permissions, firewall, resolved drop-in
Ongoing maintenance Near zero Update the node list when you rebuild exit nodes
Platform coverage All Tailscale clients Linux with NetworkManager
Best fit Managed fleet, single-operator exit pool Personal laptops on a mixed-trust tailnet
Main risk Egress through a node you didn't mean to trust Offline when every trusted node is down

My pick and why

On a roaming Linux laptop, I'd use the pinned exit node with dispatcher enforcement every time.

My reasoning: the exit node operator sees your traffic after decryption. That makes the choice of exit node a trust decision, and trust decisions shouldn't come from a latency measurement. Auto-select is a good availability feature, and it was built as one. Once your tailnet holds nodes with different owners (shared nodes, experiments, third-party providers), "the fastest eligible node" and "a node I trust with my traffic" stop meaning the same thing, and auto has no setting that tells them apart.

The pinned approach without automation has a real failure mode, though. It fails closed, which means it's annoying, which means people disable it. The dispatcher takes care of that. It reapplies the pin on every network change, handles failover inside a list you control, and only cuts connectivity when no trusted option exists. You get most of auto's convenience and keep the trust decision for yourself.

If you run a fleet where every exit node is yours and ACLs keep the pool clean, auto plus MDM enforcement is a reasonable choice. I'd still audit the eligible pool regularly with tailscale exit-node list to check that it contains only what you expect.

Things I'd watch out for:

  • Pin by MagicDNS name, never by tailnet IP. Rebuilt nodes get new addresses, and clients pinned to the old one fail with no useful error.
  • Use tailscale set in scripts, not tailscale up. up wants the full flag set restated and will refuse or reset settings you didn't mention.
  • Make firewall and resolver changes permanent. A hardening setup that disappears after a reboot is worse than none, because you believe it's still there.
  • "Connected" isn't "protected." Check ExitNodeStatus and an external IP check (curl -s https://ifconfig.me) before trusting a hostile network. The tray icon only reports whether the tunnel is up.

If you're building the exit node side of this, Tailscale Subnet Routers covers the router configuration that pairs with it. If you want help designing this kind of zero-trust egress for a team instead of a single laptop, that's the kind of infrastructure work I do.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.