How to Allow‑list Specific Clients in Tailcat: A Complete Guide

Use the --allow flag at startup or call Server.AddAllowedClient at runtime to restrict Tailcat tunnel connections to specific WireGuard node public keys.

Tailcat enforces client access control at the network layer by validating peer identities against an allow‑list of WireGuard node public keys. This mechanism prevents unauthorized nodes from establishing tunnels, even if they possess valid Tailscale credentials. The implementation resides in the core server logic of the tailscale/tailcat repository, with both CLI and programmatic interfaces available for managing allowed clients.

How Tailcat's Client Allow‑listing Works

At its core, Tailcat stores permitted clients in an internal map keyed by key.NodePublic. The server performs a fast map lookup when handling incoming connections. If no allow‑list is configured, the map remains nil and all clients pass through.

In [tailcat.go](https://github.com/tailscale/tailcat/blob/main/tailcat.go), the critical validation occurs around line 1595:

if b.allowedClients != nil && !b.allowedClients[src] {
    return nil, fmt.Errorf("client %v not allowed", src)
}

The allowedClients field lives on the locoBackend struct and is populated through Server.AddAllowedClient or CLI flag parsing. When allowedClients is nil, this check short‑circuits, preserving backward compatibility for open servers.

Method 1: Using the --allow CLI Flag

The simplest way to restrict access is passing public keys via the --allow flag when starting the server. This is implemented in [cmd/tailcat/tailcat.go](https://github.com/tailscale/tailcat/blob/main/cmd/tailcat/tailcat.go).

Flag Syntax and Parsing

The flag accepts a comma‑separated list of base64‑encoded node public keys or the special value none to block all clients:


# Allow specific clients

tailcat serve --allow=nodekey:abc123...,nodekey:def456... 22

# Block all clients (explicit deny)

tailcat serve --allow=none 22

Behind the scenes, cmd/tailcat/tailcat.go parses each entry around line 1405 using key.NewNodePublicFromString, then invokes Server.AddAllowedClient for each valid key.

Generating Client Keys

Use the built‑in key generation command to create client credentials:


# Generate a client key pair

tailcat genkey --client > client1.key
cat client1.key | tailcat pubkey > client1.pub

# Copy the public key (nodekey:...) for the server --allow flag

cat client1.pub

Complete Server Example

#!/bin/bash

# Generate two client keys

CLIENT1=$(tailcat genkey --client | tailcat pubkey | tr -d '\n')
CLIENT2=$(tailcat genkey --client | tailcat pubkey | tr -d '\n')

# Start server allowing only these two clients

tailcat serve \
  --allow="${CLIENT1},${CLIENT2}" \
  --statedir=/var/lib/tailcat \
  0.0.0.0:22

Method 2: Programmatic Allow‑listing with Server.AddAllowedClient

For dynamic access control, import Tailcat as a library and manipulate the allow‑list at runtime. The Server.AddAllowedClient method in [tailcat.go](https://github.com/tailscale/tailcat/blob/main/tailcat.go) (lines 893‑898) provides a safe, concurrency‑aware way to add keys.

Method Signature

func (s *Server) AddAllowedClient(k key.NodePublic)

The method uses mak.Set to handle map initialization safely:

func (s *Server) AddAllowedClient(k key.NodePublic) {
    s.lb.mu.Lock()
    defer s.lb.mu.Unlock()
    mak.Set(&s.lb.allowedClients, k, true)
}

Runtime Allow‑listing Example

package main

import (
    "context"
    "log"
    "os"
    "os/signal"

    "tailscale.dev/tailcat"
    "tailscale.dev/tailcat/key"
)

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    // Initialize server with default configuration
    srv := &tailcat.Server{
        Port: 22,
    }

    if err := srv.Start(); err != nil {
        log.Fatalf("server start failed: %v", err)
    }

    // Add permitted clients from environment or external source
    allowedKeys := []string{
        os.Getenv("ALLOWED_CLIENT_1"),
        os.Getenv("ALLOWED_CLIENT_2"),
    }

    for _, ks := range allowedKeys {
        if ks == "" {
            continue
        }
        k, err := key.NewNodePublicFromString(ks)
        if err != nil {
            log.Printf("invalid key %q: %v", ks, err)
            continue
        }
        srv.AddAllowedClient(k)
        log.Printf("added client: %v", k)
    }

    <-ctx.Done()
    srv.Close()
}

Method 3: SSH‑level Authorization (Separate from Tunnel Allow‑list)

Tailcat's ssh subcommand supports an additional layer of access control via --ssh-authorized-keys. This operates at the SSH protocol layer and is distinct from the WireGuard tunnel allow‑list described above.

Key differences:

  • Tunnel allow‑list (--allow): Controls who may establish the encrypted WireGuard tunnel. Denied clients cannot reach the SSH server at all.
  • SSH authorization: Controls who may authenticate via SSH once connected. A client may pass tunnel allow‑listing but fail SSH key verification.

For defense‑in‑depth, configure both layers as shown in [cmd/tailcat/ssh_authorized_keys.go](https://github.com/tailscale/tailcat/blob/main/cmd/tailcat/ssh_authorized_keys.go):

tailcat serve \
  --allow=nodekey:abc123... \
  --ssh-authorized-keys=/etc/tailcat/authorized_keys \
  22

Testing Allow‑list Behavior

The repository includes comprehensive tests in [tailcat_test.go](https://github.com/tailscale/tailcat/blob/main/tailcat_test.go) (lines 293‑331) demonstrating allow‑list enforcement. These tests verify that:

  1. Servers with empty allow‑lists accept all clients
  2. Servers with populated allow‑lists reject unknown keys
  3. Runtime mutations via AddAllowedClient take effect immediately

Excerpt from the test suite:

func TestServerAllowlist(t *testing.T) {
    s := newTestServer(t)
    
    // Initially nil allow‑list permits all
    if s.lb.allowedClients != nil {
        t.Fatal("expected nil allowedClients initially")
    }

    // Add specific client
    allowed := key.NewNode().Public()
    s.AddAllowedClient(allowed)

    // Verify map populated
    s.lb.mu.Lock()
    if !s.lb.allowedClients[allowed] {
        t.Fatal("expected client to be allowed")
    }
    s.lb.mu.Unlock()

    // Connection from disallowed client should fail
    // ... test helper simulates handshake with random key
}

Comparison of Allow‑listing Approaches

Approach Use Case Persistence Performance Impact
--allow flag Static, known client sets Configured at startup; restart required to modify None; map built once
AddAllowedClient Dynamic, runtime membership changes In‑memory only; implement persistence externally Negligible; lock‑contended only on mutation
none value Maintenance mode, emergency lockdown Immediate effect N/A (blocks all)

Security Considerations

  • Key rotation: When rotating client keys, add the new key before removing the old to avoid connection interruptions.
  • Audit logging: Wrap AddAllowedClient calls with logging to maintain an audit trail of runtime permission changes.
  • Least privilege: Start with --allow=none and explicitly add required clients rather than relying on default open behavior.

Summary

  • Tailcat's client allow‑list operates on WireGuard node public keys stored in locoBackend.allowedClients
  • Use --allow at startup for static configuration; parse keys with key.NewNodePublicFromString
  • Call Server.AddAllowedClient for dynamic, runtime access control
  • The special none value creates an empty allow‑list, blocking all clients
  • SSH authorization (--ssh-authorized-keys) provides a separate, complementary security layer
  • Test coverage in tailcat_test.go validates allow‑list behavior across connection scenarios

Frequently Asked Questions

What happens if I don't specify the --allow flag?

If no --allow flag is provided, allowedClients remains nil and Tailcat permits connections from any authenticated Tailscale node. This default behavior ensures backward compatibility but should be explicitly restricted in production deployments.

Can I remove a client from the allow‑list at runtime?

The current API only supports adding clients via AddAllowedClient. To remove access, restart the server with an updated --allow list or implement a custom wrapper that maintains a separate revocation layer. The underlying map structure supports deletion, but no public method exposes this functionality.

How does Tailcat validate that a presented key is legitimate?

Tailcat relies on the WireGuard handshake and Tailscale control plane to cryptographically verify that a peer possesses the private key corresponding to its claimed public key. The allow‑list check occurs after this verification, ensuring that only cryptographically authenticated identities are evaluated against your access policy.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →