Security Notes
Networking

WireGuard and Tailscale for CI/CD and Hybrid Infrastructure Security

31 min read 7 sections 9 model answers verified 2026-10 version 1 · updated 2026-10-24

Last verified2026-10 Also see: Wireless, VPNs and NAC (IPsec, TLS VPNs, segmentation) · Egress control for untrusted CI (proxies, DNS pinning, egress IP pools) · Build platform security · GitHub & CI/CD security (OIDC in workflows) · Sandbox isolation (gVisor netstack, Apple vz) · TLS and cryptography


Start here: the basics in plain words

A VPN (virtual private network) makes two machines on different networks behave as if they were plugged into the same switch, by wrapping each packet in encryption and sending it across the internet. WireGuard is a small, modern VPN protocol built into the Linux kernel. Tailscale is a product that uses WireGuard for the actual traffic and adds the part WireGuard deliberately leaves out: finding the other machines, handing out keys, getting through firewalls, and deciding who may talk to whom.

Why does this land in security interviews about CI? Because the easiest way to let a build job reach an internal artefact store or a bare-metal rack is to put the runner on the company's overlay network. At that moment, code from a pull request is standing on your internal network, and the question becomes how little of it the job can see.

Data plane

The packets themselves: WireGuard, encrypted end to end between two machines. Tailscale never sees inside them.

Control plane

The bookkeeping: which public keys exist, what address each machine has, who may talk to whom. This is Tailscale's coordination server.

Cryptokey routing

WireGuard's one big idea: a peer is its public key, and each key is allowed a fixed set of IP addresses. Packets whose addresses don't match the key are dropped.

Tailnet

One organisation's private Tailscale network. Every machine gets an address in 100.64.0.0/10.

Node key

The WireGuard key pair a machine uses for traffic. The private half never leaves the machine.

DERP

Designated Encrypted Relay for Packets. Tailscale's relay servers for when two machines can't connect directly. They forward ciphertext they cannot read.

ACL / grants

The policy file that says which users and tags may reach which machines and ports. Enforced on every node, not in the cloud.

Tag

A label such as tag:ci-runner that gives a machine an identity instead of a person. Think service account.

Ephemeral node

A machine that registers, works, and is deleted from the tailnet automatically when it disconnects. Exactly what a CI runner should be.

Tailnet lock

A setting that makes nodes refuse any new peer whose key isn't signed by one of your own signing machines, so even a compromised coordination server can't add a machine.

   control plane (Tailscale coordination server)            holds: public keys, addresses, policy
          ▲   ▲                                              never holds: private keys, your packets
   keys,  │   │  keys,
   policy │   │  policy
   ┌──────┴┐ ┌┴──────┐      WireGuard, directly (UDP) ──────────────┐
   │ CI    │ │ artif-│ ◄──────────────────────────────────────────► │  data plane
   │ runner│ │ acts  │      or via DERP relay (ciphertext only) ───┘
   └───────┘ └───────┘
Memory hook

"the drop box and the mesh." The coordination server is a drop box for public keys and rules; it can lie about who exists, never read what they say. The traffic is a mesh of WireGuard tunnels between machines that each enforce the rules locally. Every Tailscale security question is about one of the two.


WireGuard: The Cryptographic Data Plane

What is this?A VPN protocol with one fixed set of modern primitives, a one-round-trip handshake, and a routing rule tied to public keys. It has no cipher negotiation, no certificates and no concept of users. The paper (WireGuard, NDSS 2017) is short enough to read before an interview; the Linux implementation shipped in kernel 5.6 (2020).

Why it mattersInterviewers want to hear that you can separate what the cryptography guarantees from what the surrounding product does, and that you know why a 4,000-line kernel module is considered safer than an IPsec stack ten times its size.

How it works

The pieces you need to knowEach peer has a static Curve25519 key pair; the public key is 32 bytes, 44 characters in Base64, and is the peer's identity. The handshake is the Noise protocol framework's IK pattern with an optional pre-shared key mixed in afterwards, written Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s. "IK" means the initiator already Knows the responder's static key and sends its own Identity encrypted in the first message. Symmetric traffic is ChaCha20-Poly1305 (an authenticated cipher), hashing is BLAKE2s, key derivation is HKDF.

  1. 1. Initiator → responder: handshake initiationtls148 bytes
    • Fresh ephemeral key, the initiator's static public key encrypted under an ephemeral–static Diffie–Hellman, a timestamp to stop replays
    • Carries mac1, a keyed hash using the responder's public key: a packet from someone who doesn't know that key is dropped without reply
  2. 2. Responder → initiator: handshake responsetls92 bytes
    • Responder's ephemeral key; both sides now derive the same transport keys, with forward secrecy from the two ephemerals
    • If the responder is under load it may instead send a 64-byte cookie reply, a MAC of the sender's IP under a secret rotated every two minutes; the initiator must echo it as mac2 before the responder spends CPU
  3. 3. Transport datanetwork16-byte header + 16-byte tag per packet
    • Header: type, receiver index, a 64-bit counter used as the nonce. Replays and reordering outside a window fail authentication
  4. 4. Rekeytlsthe initiator starts a new handshake after 120 s or 2⁶⁰ messages; keys are rejected after 180 s
    • Handshakes are retried every 5 s with jitter, for at most 90 s; a keepalive is sent after 10 s of receiving without sending, so NAT mappings stay open

Cryptokey routing is the part most people miss. The interface keeps a table from each peer's public key to the IP ranges that peer is allowed to use. On send, the destination address picks the key to encrypt with. On receive, after decryption, the inner packet's source address must be inside the allowed set for the key that decrypted it, or the packet is dropped. So a packet that arrives on wg0 from 100.101.5.7 is cryptographically guaranteed to have come from the peer that owns that address. Firewall rules written against WireGuard addresses are therefore rules against identities, which is the foundation Tailscale's ACLs stand on.

In practiceWhat the table looks like on a Linux node (keys and endpoints abridged), with the lines that matter for security:

console
$ sudo wg show wg0
interface: wg0
  public key: HIgo…8ykw
  listening port: 41414

peer: TrMv…WXX0
  endpoint: 203.0.113.9:51820                 ← where it last spoke from; roams freely, identity is the key, not the address
  allowed ips: 100.101.5.7/32                 ← this key may only ever be this address; anything else is dropped after decryption
  latest handshake: 47 seconds ago            ← a new handshake at most every 120 s is normal; "never" means mac1 or the key is wrong
  transfer: 1.21 GiB received, 88.4 MiB sent
  persistent keepalive: every 25 seconds      ← keeps the NAT mapping alive for a peer behind a home or cloud NAT

peer: gN65…z6EA
  allowed ips: 10.10.0.0/16                   ← a subnet router: this peer may *source* packets from a whole range

Kernel versus user spaceThe Linux module is the fast path, with no copies to user space and multi-queue parallelism across the cores of an EPYC or AmpereOne node. wireguard-go is the same protocol in Go, which is what runs on macOS (including inside Apple Virtualization guests), on Windows, and inside every Tailscale client; it is slower per packet but Tailscale's segmentation-offload work (sending many packets per system call) narrowed the gap to within reach of 10 Gbit/s on modern hardware. gVisor's netstack can carry WireGuard as just another UDP flow; the sandbox sees a UDP socket, the host sees ciphertext.

🎯

On the job. A WireGuard review has four questions. Who generated the keys and where do the private halves live (a file readable only by root on the node, never in a CI secret store)? Are the allowed IPs /32 per peer, or did someone set 0.0.0.0/0 on a peer that should only be a client? Is there a pre-shared key for post-quantum hedging on long-lived site-to-site links? And what distributes the keys, because WireGuard itself refuses to answer that question.

Security angle

Silence is a feature

A WireGuard port answers nothing to an unauthenticated packet, so a scanner cannot tell it exists. The cost is that debugging "why won't it connect" is harder: look at latest handshake, not for an error message.

No agility means a flag day

If ChaCha20-Poly1305 or Curve25519 is broken, every endpoint updates at once. The paper calls this cryptographic opinionation and treats it as a strength; know that it is also an operational commitment.

Identity hiding is partial

The initiator's static key is encrypted, but mac1 is keyed by the responder's public key, so a passive observer who already knows candidate public keys can guess which server a packet is for. Not a secret-leaking weakness, but say it accurately.

Key distribution is out of scope by design

The paper says WireGuard copies OpenSSH's attitude: exchange public keys by whatever out-of-band mechanism you like. That gap is the entire reason Tailscale exists.


Tailscale: The Control Plane

What is this?A coordination server that holds every machine's public key, assigns each a stable address in 100.64.0.0/10, computes a per-machine "netmap" (the peers it may see and the packet filter it must apply), and hands out a map of relay servers. The data plane stays WireGuard between the machines.

Why it mattersThe cleanest interview answer separates what a compromised Tailscale could do (lie about peers, push a bad policy) from what it could never do (read traffic). Tailnet lock exists to shrink the first list.

How it works

  1. 1. Node authenticatesserveruser login, auth key, or an OIDC token exchange (below)
    • The client generates a node key (its WireGuard key) locally and uploads only the public half; a longer-lived machine key identifies the device to the control plane and secures that control connection
  2. 2. Control plane builds the node's netmapserverpeers filtered by policy, their public keys and endpoints, this node's packet filter, DNS config, the DERP map
    • A node only learns about peers it is allowed to reach; tight ACLs shrink the fan-out
  3. 3. Nodes find each othernetworkSTUN-style probing through NATs, with small "DISCO" discovery packets
    • Direct UDP whenever possible; otherwise a DERP relay (or, more recently, a peer relay you run yourself)
  4. 4. WireGuard tunnel, end to endtlsthe relay forwards ciphertext only
    • Private keys never leave the node, so neither the coordination server nor a DERP server can decrypt
  5. 5. Packet filter enforced on the receiving nodedefendcompiled from the ACLs
    • Even if a peer appears in the netmap, its packets are dropped unless a rule accepts them

DERP relaysEach client measures latency to the relays in its DERP map, picks a home relay and reports it; peers that cannot punch through NAT send WireGuard packets through that relay over HTTPS. Because the relay only ever sees ciphertext, the trust question about DERP is availability and metadata (who talks to whom, how much), not confidentiality. In May 2025 Tailscale rotated DERP mesh secrets after finding they were compared in non-constant time (TS-2025-003): a timing side channel against the relays' mutual authentication, not against user traffic, which is a precise example of the control-plane/data-plane split.

PolicyThe tailnet policy file is HuJSON (JSON with comments). If an acls or grants section exists, everything not listed is denied, and rules are directional: allowing A to reach B does not allow B to reach A. Rules are enforced locally on the destination node from its netmap, so the coordination server being briefly unreachable does not open anything. ACLs do not touch a machine's local LAN access, which matters for runners that sit on a rack network as well as on the tailnet.

In practiceA policy fragment for a CI fleet, with the lines interviewers probe:

json
{
  "tagOwners": {
    "tag:ci-runner":  ["group:platform"],          // ← who may *mint* this identity; owners can act as the tag
    "tag:artifacts":  ["group:platform"],
    "tag:prod-db":    ["group:dba"]
  },
  "acls": [
    { "action": "accept", "src": ["tag:ci-runner"], "dst": ["tag:artifacts:443"] },   // ← runners reach one service, one port
    { "action": "accept", "src": ["group:platform"], "dst": ["tag:ci-runner:22"] }    // ← humans reach runners, never the reverse
    // nothing lets tag:ci-runner reach tag:prod-db, or any user's laptop: default deny does the rest
  ],
  "ssh": [
    { "action": "check", "src": ["group:platform"], "dst": ["tag:ci-runner"], "users": ["ci"] }  // ← re-auth before SSH, non-root user
  ]
}

And what a runner actually sees, which is the test of the policy, not the file:

console
$ tailscale status
100.101.5.7   ci-runner-812   tag:ci-runner   linux   -
100.98.3.20   artifacts-1     tag:artifacts   linux   active; direct 203.0.113.40:41641   ← the only peer in the netmap
$ tailscale ping prod-db-1
no matching peer                                                                           ← not "blocked": the runner never learned it exists
📰

Real incident — Tailscale Windows client, DNS rebinding to the local API (2022). The pieces: tailscaled on Windows exposed a local control API on a TCP port so the GUI could talk to it, and it trusted that anything arriving on that port came from the local user; a browser resolves a hostname once, then trusts later connections to the same name even if the attacker's DNS now points it at 127.0.0.1. What went wrong: the local API checked neither the HTTP Host header nor any token, so a malicious web page could rebind its own domain to the victim's machine and issue API calls, including changing the coordination server to one the attacker ran (TS-2022-004, CVE-2022-41924, critical). Why it was bad: a malicious coordination server can push configuration and, on Windows, executables and shares, so this was remote code execution from a web page. A sibling bug (TS-2022-005, CVE-2022-41925) let a page reach the peer API on all platforms and read environment variables, which on CI machines often contain auth keys. Both fixed in 1.32.3. Lessons: "it's only listening on localhost" is not authentication; and a CI runner's environment should not contain a reusable auth key in the first place, which is where the next section goes.

🎯

On the job. Open the admin console's machine list filtered to tag:ci-runner. Every entry should be ephemeral, recently created and tagged, with key expiry showing the tag default. A runner that is not ephemeral, or that has a user's name instead of a tag, was joined by hand with someone's login and is now that person on your network.

Security angle

A malicious control plane can add peers and change policy

It can insert its own machine into your netmap and write an ACL that lets it reach everything. It still cannot read existing tunnels. Tailnet lock (below) closes the "add peers" half; nothing but your own review closes the "change policy" half, so keep the policy file in Git with review, via GitOps.

DNS and routes are pushed too

MagicDNS names and split-DNS resolvers come from the control plane; a node that accepts them (--accept-dns, on by default on most platforms) trusts the control plane for name resolution. For runners, pin DNS to your own resolver and set --accept-dns=false.

Netmap size is an availability and privacy issue

Every node receives updates about every peer it may reach. Policies that let autogroup:member reach everything mean every laptop learns about every ephemeral runner, thousands of times a day. Tight tag-to-tag rules keep runners out of human netmaps entirely.


Tags, Ephemeral Nodes and Identity Without Static Secrets

What is this?The three mechanisms that turn "a runner on the VPN" into "a disposable machine identity that exists for one job": tags, ephemeral registration, and OIDC workload identity federation instead of auth keys.

Why it mattersMost real damage in this area comes from a long-lived auth key sitting in CI secrets. Whoever steals it can add their own machine to the tailnet, with the runner's tag and the runner's access, from anywhere, for as long as the key lives.

How it works

TagsA device has either a user identity or a tag identity, never both: applying a tag removes the user. Tags are declared in tagOwners, and only the listed owners (users, groups or other tags) may apply them, except that Owners, Admins and Network admins may apply any tag. Two defaults matter for CI: tagged devices have key expiry disabled when first authenticated (a tagged machine never gets kicked off for a stale key), and tagged devices may only SSH to other tagged devices. The first default is why ephemeral removal, not expiry, is the lifetime control for runners.

Ephemeral nodesCreated with an ephemeral auth key, or by running tailscaled with --state=mem: (v1.22+), which keeps all state in memory and marks the node ephemeral with no key needed for that flag alone. The control plane removes an ephemeral node 30 to 60 minutes after it goes quiet, or immediately on tailscale logout, which --state=mem: performs automatically on exit (v1.30+). Each new ephemeral node gets a new address, so an address is never recycled onto a different job within that window. A node that stays four hours stops being treated as ephemeral for billing, a hint that a "runner" alive that long is a pet, not cattle.

Workload identity federationInstead of an auth key, the runner presents the signed OIDC token its CI platform already gives it (GitHub Actions, Buildkite, GitLab, or a cloud provider's instance identity). An admin creates a federated identity in Tailscale naming the issuer to trust, a sub pattern (for GitHub, repository and workflow; for AWS, an IAM principal ARN), optional extra claim rules, the tags the resulting node may carry, and the API scopes it gets. Tailscale fetches the issuer's public keys, verifies the token's signature, issuer, audience (api.tailscale.com/<client ID>) and expiry, applies the matching rules, and hands back a short-lived credential. Since v1.90.1 tailscale up accepts the client ID and token directly to register a node; since v1.94 --audience lets the client fetch the platform token itself on GitHub Actions, AWS and Google Cloud. Nodes registered this way default to ephemeral.

  1. Before: reusable auth keybrowserstored as a CI secret
    • Anyone who reads the secret (a malicious action, a log leak, TS-2022-005's environment read) can join the tailnet as tag:ci-runner from their own laptop, until the key is revoked
  2. After: OIDC token exchangedefendnothing stored
    • The job asks GitHub for an ID token (permissions: id-token: write), scoped to this repo, workflow, ref and run
    • tailscale/github-action@v4 with oauth-client-id, audience and tags exchanges it; Tailscale checks issuer, signature, audience, expiry and your sub rules
    • The node is ephemeral, tagged, and gone when the job ends; a stolen token is minutes old and bound to one audience
  3. What still goes wrongattackloose matching
    • A sub rule of repo:acme/* lets any repository in the org, including a fork-driven workflow, become a CI runner on your network; match the repository and the workflow file, and read the claims page in GitHub security for the pull_request_target trap

In practiceThe workflow step and the resulting node, as a reviewer sees them:

yaml
permissions:
  id-token: write          # ← lets the job mint an OIDC token; without it the action falls back to failing, not to a stored key
  contents: read
steps:
  - uses: tailscale/github-action@v4
    with:
      oauth-client-id: ${{ secrets.TS_FEDERATED_CLIENT_ID }}   # ← an identifier, not a secret that grants access by itself
      audience:        ${{ secrets.TS_AUDIENCE }}
      tags: tag:ci-runner
console
$ tailscale status --json | jq '.Self | {HostName, Tags, KeyExpiry, Online}'
{
  "HostName": "fv-az1234-812",
  "Tags": ["tag:ci-runner"],        ← tag identity, no user
  "KeyExpiry": "0001-01-01T00:00:00Z",   ← expiry disabled for tagged nodes; ephemeral removal is the lifetime
  "Online": true
}
📰

Real incident — one-off auth keys that weren't (2025). Tailscale's November 2025 bulletin (TS-2025-007) disclosed a race in the control plane: a single-use auth key presented by several registrations at the same moment could register several nodes instead of one. The pieces: "one-off" was enforced by checking a flag and then marking it used, two steps that could interleave under concurrency. Why it matters for CI specifically: fleets of runners register in bursts, exactly the condition that triggers the race, and a key that leaked from one job could ride along with a legitimate burst. It was fixed server-side with no user action, and an earlier bulletin that year (TS-2025-005) covered MDM-supplied auth keys being written to client logs. Lesson: an auth key is a bearer credential with all the usual bearer-credential problems; the durable fix is not a better key, it is no key, which is what the OIDC exchange gives you.

🎯

On the job. List federated identities and auth keys in the admin console. For each auth key still in use by CI, write down who can read it and when it was last rotated; for each federated identity, read the sub pattern aloud and ask whether a fork, a different workflow, or a deleted-and-recreated repository with the same name would match it.

Security angle

Tag owners can act as the tag

Anyone in tagOwners for tag:ci-runner can bring up a machine with that tag; the owner group is part of the runner's trust boundary.

Pre-authorised and ephemeral are separate flags

A federated client ID can carry preauthorized=true; with device approval on, leave it false so a human sees unexpected joins. With tailnet lock, use pre-signed keys instead (below).

Attribution after deletion

An ephemeral node vanishes from the machine list within the hour. Keep the control-plane audit log and your own join/leave events keyed by job ID, or an investigation three days later has nothing to pivot on.


Threat Model: What Goes Wrong When CI Joins Your Tailnet

What is this?The attack paths that open the moment code from a pull request runs on a machine that is a peer on your private network.

Why it mattersConvenience is the whole pitch (no bastion, no firewall tickets), and convenience is symmetric: the runner can reach the artefact store, and whatever runs on the runner can try to reach everything else the runner's identity is allowed.

How it works

  1. 1. Lateral movement from the runnerattackthe job is tag:ci-runner
    • Every ACL that accepts src: tag:ci-runner is available to the malicious step; a policy written as "runners can reach the internal network" is the whole internal network
  2. 2. Pivot through a subnet routerattack10.0.0.0/8 advertised into the tailnet
    • If the ACL destination is the subnet, the runner gets the subnet; and with the router's default SNAT, the target sees the router's address, so logs say "the router did it"
  3. 3. Local APIs on the nodeattackthe peer API and local API are reachable by processes on the runner
    • A job step can query tailscale status to enumerate peers, read the node's identity, or use file sharing to peers that accept it
  4. 4. Name resolutionattackMagicDNS and split DNS come from the control plane
    • A node that accepts pushed DNS lets whoever controls the policy (or the control plane) decide what artifacts.internal resolves to
  5. 5. Credential theftattackauth keys in the environment, or the OIDC token before exchange
    • Stolen key: join the tailnet from anywhere. Stolen token: a few minutes to exchange it, bound to one audience
  6. 6. Outbound beyond the tailnetattackthe overlay is not an egress firewall
    • Unless the runner is forced through an exit node, internet traffic bypasses the tailnet entirely; exfiltration needs the egress controls on the egress page, not a VPN
  7. Controls, one per rowdefendtag-to-tag, port-scoped ACLs; subnet routers with SNAT off and ACLs on specific hosts; tsnet instead of a TUN; --accept-dns=false; OIDC exchange; a real egress proxy
📰

Real incident — exit nodes and subnet routers accepting LAN traffic (2024). Tailscale's May 2024 bulletin (TS-2024-005) described a gap in inbound filtering: on a node acting as an exit node or subnet router, an ACL with "src": "*" (any source) could allow hosts on that node's local LAN, which are not tailnet members at all, to send traffic into the tailnet through it. The pieces: a router forwards between its LAN and the overlay in both directions; * was interpreted as "anyone", and the LAN counts as anyone. What went wrong: the policy author meant "any tailnet device" and the filter heard "any address". Why it matters for CI: a subnet router on a rack network, next to bare-metal runners, is exactly where untrusted LAN neighbours exist. Fixed in 1.66.0. Lesson: never use * as a source in a policy that includes routers; name tags and groups, and treat a subnet router as a firewall whose rules are the ACLs.

🎯

On the job. From a throwaway job, run tailscale status and tailscale netcheck, then try nc -zv -w2 <each peer address> 22 443 5432. Every peer in the status output is a peer the runner may talk to; every open port is a path a malicious dependency gets for free. The target state is one peer, one port.

Security angle

Runner compromise is tag compromise

Rotate nothing, revoke nothing: the runner is ephemeral. What you review after an incident is every ACL rule naming tag:ci-runner, because that is the set of things the attacker could reach.

Routers flatten identity

With SNAT on (the default), a connection from a runner through a subnet router arrives at the target with the router's address. On Linux routers, --snat-subnet-routes=false preserves the runner's 100.x source so the target's logs name the job, at the cost of a return route for 100.64.0.0/10.

The overlay hides nothing from the host

WireGuard traffic leaves the runner as UDP; a job with root in a container on the runner can read the TUN device's plaintext. Isolation between the job and the node's Tailscale identity needs the sandbox page's controls, or tsnet.


Isolation Patterns: tsnet, Tailnet Lock and Egress

What is this?The design choices that keep the job from inheriting the runner's network identity, and that keep the control plane from being a single point of compromise.

Why it matters"We use Tailscale" says nothing about security; "the runner process has no TUN device, the job's identity is a tsnet listener inside the agent, nodes are tailnet-locked, and internet egress goes through a proxy" does.

How it works

tsnet versus a system TUNtailscaled normally creates a TUN interface (tailscale0) that every process on the machine can use; the machine is the node. tsnet is a Go library that embeds the whole client, with a user-space network stack (gVisor's netstack), inside one binary: that process is the node, it needs no root and no TUN, and nothing else on the machine can send through it. For CI this inverts the trust: the platform's agent is on the tailnet to fetch work and upload artefacts; the job's shell is not on the tailnet at all. The threat profile of a TUN is "any process on the host gets the node's access"; the threat profile of tsnet is "a bug in this one Go process, and the node key in its memory".

System tailscaled + TUN

Whole machine is the node. Any process, including the job, can use it. Simple, broad, hard to attribute to a job.

tsnet in the agent

Only the agent process is the node; jobs are not. Needs no root. Node key lives in the agent's memory and --state=mem: style storage.

tsnet in a sandboxed sidecar

Agent runs under gVisor or in its own VM; the job reaches it only over a local socket with per-job credentials. Strongest, most moving parts.

Tailnet lockEvery node generates a tailnet lock key (TLK) alongside its node key. When you enable the feature (v1.46.1+), you pick between two and twenty signing nodes; a node's public node key must carry a signature from a trusted TLK before other nodes will exchange traffic with it, and the list of trusted TLKs is kept in a signed chain (the tailnet key authority) that every node verifies. The coordination server still distributes keys and signatures, but it can no longer get an unsigned node accepted: a compromised or coerced control plane cannot add a peer. It also cannot turn the lock off, because that requires one of ten disablement secrets generated at setup, which you hold. For CI fleets you pre-sign auth keys (tailscale lock sign $AUTH_KEY) or sign with the federated flow, so ephemeral runners join without a human signing each one. Caveats worth saying: nodes trust the first state they receive (check tailscale lock status on several nodes and compare), TLKs live on devices so a compromised signing laptop is a compromised authority, and removing a compromised signing key is a multi-signer ceremony (tailscale lock revoke-keys).

Egress is a separate problemThe tailnet decides what the runner can reach inside. Internet-bound traffic from the runner does not touch WireGuard unless an exit node is forced, and forcing one only moves the question to the exit node's firewall. Treat the two as different systems: tag-scoped ACLs for private destinations, a hostname-enforcing egress proxy with dedicated egress IPs for public ones, and a host firewall on the runner that allows only the tailnet peers, the proxy and the resolver. Subnet routers into cloud VPCs (AWS, GCP) are where the two meet: the router's SNAT setting decides whether the VPC flow logs see the job or the router, and the VPC's own security groups should admit the router's address only on the ports the ACLs already allow, so neither layer is trusted alone.

In practiceThe agent-side pattern in Go, and the lock state a reviewer checks:

go
srv := &tsnet.Server{
    Hostname:  "ci-agent-" + jobID,
    Ephemeral: true,                                   // ← gone when the process exits
    AuthKey:   os.Getenv("TS_AUTHKEY"),                // ← or, better, a token from the OIDC exchange
    Store:     &mem.Store{},                            // ← node key in memory only
}
ln, err := srv.Listen("tcp", ":443")                    // only this listener is on the tailnet; the job's processes are not
console
$ tailscale lock status
Tailnet lock is ENABLED.
This node is accessible under tailnet lock. Node signature:   ← if a node is "locked out", peers drop its traffic until signed
Trusted signing keys:
  tlpub:4f2e…a91c   (2 nodes)   ← two or more signers; compare this list on another node (trust on first use)
  tlpub:9b70…0d3e   (2 nodes)
🎯

On the job. ls -l /var/lib/tailscale/ on a runner image. A tailscaled.state file baked into the image means every runner from that image is the same node identity, and whoever has the image has the key. The correct finding is no state file at all: --state=mem: or tsnet with a memory store, and identity minted per job.

Security angle

tsnet moves the bug surface into your binary

netstack parsing untrusted packets now runs in your agent; keep it updated and run it with the least privilege the sandbox page describes.

Tailnet lock does not protect policy or DNS

A malicious control plane still can't add a machine, but it can still push ACL changes and DNS settings. GitOps review of the policy file and --accept-dns=false on runners close those.

Ceremonies need rehearsing

Disablement secrets you cannot find, signing nodes that were laptops someone wiped, and a revocation that needs several signers at 03:00 are the operational failure modes. Store secrets in a vault, keep signing nodes as managed servers, and run the revocation once in a test tailnet.


Interview Questions

Q
How does WireGuard's cryptokey routing work, and how does it prevent IP spoofing between tunnel peers?
Model answer

Each peer is identified only by its Curve25519 public key, and the interface keeps a table mapping each key to the IP ranges that peer is allowed to use. When a packet goes out, the destination address picks the key to encrypt with; when a packet comes in and decrypts under some key, WireGuard checks that the inner source address is inside that key's allowed range and drops it otherwise. So a packet arriving on the interface from 100.101.5.7 is cryptographically proven to come from the peer who owns that address, and firewall rules against WireGuard addresses become rules against identities. The catch is that the table is configuration: a peer given 0.0.0.0/0 can source anything, which is fine for a gateway and a disaster for a client.

Q
What does the Noise IKpsk2 handshake guarantee, and what does Tailscale's coordination server know about traffic keys?
Model answer

IK means the initiator already knows the responder's static key and sends its own identity encrypted in the first message, so after one round trip both sides have authenticated each other's static keys, derived fresh transport keys with forward secrecy from the two ephemeral keys, and the initiator's identity was never sent in the clear; psk2 mixes in an optional pre-shared key as a hedge. Replays are stopped by a timestamp in the handshake and a counter nonce in transport, and a responder under load makes you echo a cookie before it does any expensive work. The coordination server holds public keys, addresses and policy and nothing else: private node keys never leave the machine, the handshake happens directly between the two nodes, so neither Tailscale nor a DERP relay has or can derive the session keys. It can lie about who your peers are; it cannot read what you say to them.

Q
Why is a static reusable auth key in CI secrets an anti-pattern, and how does OIDC workload identity federation fix it?
Model answer

An auth key is a bearer credential: anyone who reads it, a malicious action, a leaked log, or the 2022 peer-API bug that read environment variables, can join the tailnet from their own machine with the runner's tag and the runner's access, until someone notices and revokes it, and Tailscale's own 2025 bulletins covered keys being logged and a race that let a one-off key register several nodes. With federation the job presents the OIDC token its CI platform already issues, bound to this repository, workflow and run, and Tailscale verifies the signature against the issuer's published keys, checks issuer, audience and expiry, applies your subject-matching rules and returns a short-lived credential for an ephemeral tagged node. Nothing long-lived is stored, a stolen token is minutes old and useless elsewhere, and the thing you now review is the matching rule, because repo:acme/* quietly turns every repository in the org into a runner.

Q
A compromised pull-request build runs on a node joined to your tailnet. What lateral movement exists, and how do tags and source-restricted policies stop it?
Model answer

The job inherits the node's identity, so every ACL with the runner's tag as a source is available to it, plus anything reachable through a subnet router the policy allows, plus the node's local APIs for enumerating peers, plus whatever name resolution the control plane pushes. The defence is to make that set tiny and legible: the runner is a tag, not a user, so it has no human's access; the policy is tag-to-tag and port-scoped, runners to the artefact service on 443 and nothing else, with no * sources anywhere a router exists, which was the 2024 inbound-filtering bulletin; subnet routers point at specific hosts with SNAT off so the target's logs name the job; DNS is pinned locally. Better still, put only the platform agent on the tailnet via tsnet and keep the job's shell off it entirely, so the compromised step has no node identity to inherit.

Q
What goes wrong when hundreds of ephemeral runners register and unregister at once in a large tailnet?
Model answer

Three things. Control-plane fan-out: every join and leave changes the netmap of every node allowed to see runners, so a policy that lets all members reach runners makes every laptop process thousands of updates a day; tag-to-tag rules keep runners out of human netmaps. Concurrency bugs: bursts are exactly what triggered the 2025 one-off-key race, so prefer per-job OIDC exchange over shared keys. And attribution: ephemeral nodes disappear from the machine list within the hour, addresses are fresh per node, and key expiry is disabled for tagged nodes, so your own join and leave events keyed by job ID, plus the control-plane audit log, are the only durable record, and a runner still present after four hours is a sign something is wrong.

Q
Compare the threat profile of an OS-level TUN device with tsnet embedded in a sandboxed Go binary.
Model answer

With tailscaled and a TUN interface the whole machine is the node: any process, including the untrusted job and anything it spawns, can send through tailscale0 with the node's identity, and root on the machine can read plaintext off the device. With tsnet the client and a user-space network stack live inside one process, so only that process is the node, it needs no root, the node key can live only in memory, and the job's shell has no path onto the tailnet except an interface you deliberately expose. The trade is that netstack and the WireGuard implementation now parse untrusted packets inside your agent, so that binary must be sandboxed and kept current; the win is that a compromised job no longer equals a compromised network identity.

Q
How do subnet routers interact with egress proxies and dedicated egress IP pools in a hybrid AWS, GCP and bare-metal environment?
Model answer

They solve different directions. A subnet router advertises a private range, a VPC or a rack network, into the tailnet, and approval plus ACLs decide which tags may reach which hosts in it; its SNAT default rewrites the source to the router's address, so for attribution on Linux routers you disable SNAT and add a return route for 100.64.0.0/10, and you make the VPC security groups admit the router only on the ports the ACLs already allow. Internet-bound traffic never touches the tailnet unless an exit node is forced, so public destinations go through the hostname-enforcing egress proxy and leave from the tenant's dedicated IP pool, which is what external partners allow-list. The runner's host firewall ties it together: tailnet peers, the proxy and the resolver, nothing else.

Q
How does tailnet lock protect you against a compromised or malicious coordination server?
Model answer

Normally the control plane is the source of truth for which public keys are peers, so whoever controls it can add a machine to your network. With tailnet lock, every node's key must be signed by one of your own signing nodes, between two and twenty of them, and the trusted signer list is kept in a signed chain that every node verifies, so the coordination server can still distribute keys but can no longer get an unsigned node accepted, and it cannot disable the lock because that needs one of the disablement secrets you hold. Runners join through pre-signed auth keys or the federated flow so no human signs each one. The honest caveats are that nodes trust the first state they see, so you compare tailscale lock status across nodes; signing keys live on devices; and the lock protects peer membership, not policy or DNS, which still need review.

Q
What would you look for in a WireGuard or Tailscale configuration review of a CI fleet?
Model answer

Where keys live first: no state file baked into the runner image, memory-only state or tsnet, and no auth key in CI secrets if the platform supports OIDC exchange. Then identity: every runner tagged and ephemeral, tag owners limited to the platform team, no runner joined with a person's login. Then policy: tag-to-tag, port-scoped rules, no * sources, no destination that is a whole subnet, subnet routers with SNAT off where attribution matters, SSH rules with check mode and a non-root user. Then the node: --accept-dns=false and --accept-routes only where needed, a host firewall that allows only peers, proxy and resolver, and tailnet lock enabled with disablement secrets in a vault. Finally the proof: from a throwaway job, tailscale status shows one peer, and a port scan of it shows one port.