Headscale with OIDC and deny-by-default ACLs, the secure way
You're not crazy, you heard right: a fresh Headscale with no policy file lets every node talk to every other node. Add OIDC without filters and anyone your identity provider knows can join the party too.
The short answer
Configure OIDC with PKCE, verified emails, and allowed domains and groups, so only your people can join. Expire nodes weekly. Load a policy file whose grants name exactly who reaches what, and add tests that assert both what must work and what must stay denied, so a careless edit fails the check.
On this page
What goes wrong
Headscale loads no policy unless you give it one, and no policy means allow all. A laptop that joins the tailnet can reach every server on it.
A policy file that forgets the grants (or acls) section is also allow all.
So is a grant of * to * added "for a minute" while debugging.
OIDC is set up with an issuer, a client ID and a secret, and nothing else. Headscale's user filters are off by default, so every account in the identity provider can join: contractors, test accounts, a customer tenant if the IdP is shared.
Nodes never expire in the example configuration. A laptop that left with its owner keeps its access until someone deletes it by hand.
What the docs say
By default, no policy is loaded which means that Headscale allows all traffic between nodes.
Source: Headscale docs, Policy
Filters are disabled by default, users are allowed to join once the authentication with the identity provider succeeds.
Source: Headscale docs, OpenID Connect
Tests let you ensure you don't accidentally revoke important permissions or expose a critical system.
Source: Tailscale docs, Tailnet policy file syntax
Headscale's features page lists policy tests as supported, but its policy
page never explains what happens when they fail. In Headscale 0.29 a failing
test is only a warning at startup ("policy tests failed at boot; server
starting anyway"), and an error in headscale policy check. So the check has
to run in CI; the server will not stop a bad policy for you.
The secure configuration
The relevant parts of /etc/headscale/config.yaml:
server_url: https://hs.example.com # TLS at a reverse proxy or with tls_cert_path
listen_addr: 127.0.0.1:8080 # behind the proxy
metrics_listen_addr: 127.0.0.1:9090 # metrics on loopback only
grpc_listen_addr: 127.0.0.1:50443 # remote CLI off; use the unix socket
grpc_allow_insecure: false
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
node:
expiry: 7d # the example config says 0 (never)
policy:
mode: file
path: /etc/headscale/policy.hujson # without it, every node reaches every node
derp:
server:
enabled: true
verify_clients: true # only this tailnet's nodes may relay
region_id: 999
region_code: headscale
region_name: Headscale Embedded DERP
stun_listen_addr: 0.0.0.0:3478
private_key_path: /var/lib/headscale/derp_server_private.key
automatically_add_embedded_derp_region: true
ipv4: 192.0.2.10
urls: [] # no third-party relays
logtail:
enabled: false
oidc:
only_start_if_oidc_is_available: true
issuer: https://id.example.com
client_id: headscale
client_secret_path: ${CREDENTIALS_DIRECTORY}/oidc_client_secret # systemd LoadCredential
scope: ["openid", "profile", "email", "groups"] # allowed_groups needs the groups claim; the scope name varies by IdP
email_verified_required: true
allowed_domains: ["example.com"] # every filter must pass
allowed_groups: ["tailnet-users"]
pkce:
enabled: true
method: S256The policy. Grants name exactly who reaches what; the tests pin it down:
// /etc/headscale/policy.hujson
// Deny by default: only what a grant allows is reachable.
// An empty file, or one without "grants"/"acls", allows everything.
{
"groups": {
"group:ops": ["[email protected]"],
"group:dev": ["[email protected]"],
},
"tagOwners": {
"tag:prod-db": ["group:ops"],
"tag:web": ["group:ops"],
},
"grants": [
// ops: the database port and SSH on database servers
{"src": ["group:ops"], "dst": ["tag:prod-db"], "ip": ["tcp:5432", "tcp:22"]},
// dev: HTTPS on web servers, nothing on databases
{"src": ["group:dev"], "dst": ["tag:web"], "ip": ["tcp:443"]},
// web servers may reach the database port, not SSH
{"src": ["tag:web"], "dst": ["tag:prod-db"], "ip": ["tcp:5432"]},
],
// Checked by "headscale policy check": a change that opens more fails here.
"tests": [
{"src": "[email protected]", "accept": ["tag:prod-db:5432", "tag:prod-db:22"], "deny": ["tag:web:443"]},
{"src": "[email protected]", "accept": ["tag:web:443"], "deny": ["tag:prod-db:5432", "tag:prod-db:22"]},
{"src": "tag:web", "accept": ["tag:prod-db:5432"], "deny": ["tag:prod-db:22"]},
],
}Servers join with tagged, single-use, short-lived keys, passed as a file:
headscale preauthkeys create --tags tag:prod-db --expiration 10m -o json | jq -r .key > /run/ts-authkey
# on the server:
tailscale up --login-server=https://hs.example.com --auth-key=file:/run/ts-authkey && shred -u /run/ts-authkeyRun headscale policy check -f policy.hujson in CI on every change to the
policy, then reload with systemctl reload headscale.
Prove it
From secure-tests/headscale-oidc-deny-default-acls/. With
only_start_if_oidc_is_available: true and the identity provider down,
Headscale does not start:
Exited (1)
Error: initializing: creating new headscale: creating OIDC provider from issuer config: Get "http://idp:8000/.well-known/openid-configuration": dial tcp: lookup idp on 127.0.0.11:53: no such hostWith it up, Headscale starts. Four real Tailscale clients join: two people and two tagged servers:
headscale nodes listalice-laptop [email protected] 100.64.0.1
bob-laptop [email protected] 100.64.0.2
web-1 tagged-devices tag:web 100.64.0.3
db-1 tagged-devices tag:prod-db 100.64.0.4The policy and its tests pass:
headscale policy check -f policy.hujsonPolicy is validSomeone adds {"src": ["*"], "dst": ["*"], "ip": ["*"]} "for debugging". The
check fails and names every hole it opened:
Error: rpc error: code = InvalidArgument desc = test(s) failed:
[email protected] -> tag:web:443: expected DENIED, got ALLOWED
[email protected] -> tag:prod-db:5432: expected DENIED, got ALLOWED
[email protected] -> tag:prod-db:22: expected DENIED, got ALLOWED
tag:web -> tag:prod-db:22: expected DENIED, got ALLOWEDRemove the grants section entirely and the result is the same, because a
policy without grants is allow all:
Error: rpc error: code = InvalidArgument desc = test(s) failed:
[email protected] -> tag:web:443: expected DENIED, got ALLOWED
[email protected] -> tag:prod-db:5432: expected DENIED, got ALLOWED
[email protected] -> tag:prod-db:22: expected DENIED, got ALLOWED
tag:web -> tag:prod-db:22: expected DENIED, got ALLOWEDEvery key used in the test was single-use, and the server keys carried their tags:
headscale preauthkeys list1 reusable=false used=true tags=
2 reusable=false used=true tags=
3 reusable=false used=true tags=tag:web
4 reusable=false used=true tags=tag:prod-dbMistakes people make
Tests that only say "accept"
A test that checks what must work catches a broken policy. It does not catch
an open one. Write a deny for every sensitive port.
OIDC without allowed_groups
Anyone who can sign in to the IdP can join. Filter by group, and by domain if the IdP serves more than your staff.
node.expiry: 0
Nodes that never expire outlive the people who own them. Seven days is a good start. Tagged nodes are exempt and never expire, so review them by hand. Offboarding still removes nodes at once (see developer onboarding and offboarding).
Reusable, untagged keys for servers
A reusable key in a script lets anyone who finds it add machines as a person.
Use single-use keys with --tags and a short expiration.
Policy references by editable email
Headscale's OIDC docs warn that users may change their email or username in
some IdPs and take over an existing name. Only use IdPs where email is
verified and not user-editable, and keep email_verified_required: true.
The gRPC port open to the network
grpc_listen_addr is the API the Headscale CLI uses to control the server
remotely (with an API key). Keep it on loopback, keep grpc_allow_insecure: false, and use the unix socket locally.
Checklist
- Set
policy.path; never run Headscale without a policy. - Write grants for each group and tag; no
*sources or destinations. - Add
testswith bothacceptanddenyentries for sensitive ports. - Run
headscale policy checkin CI on every policy change. - Configure OIDC with
allowed_groups,allowed_domains,email_verified_requiredand PKCE S256. - Read the client secret from a file (
client_secret_path). - Set
node.expiryto 7 days or less. - Enroll servers with single-use, tagged keys that expire in minutes.
- Keep metrics and gRPC on loopback; set
derp.server.verify_clients: true.
A tailnet with no policy is a flat network with better marketing. Write the grants, and write the tests that keep them honest.
H2-CPQE
Learn it on a live range
Private access and tailnets, in Edge and Post-Quantum Networking: a real host in your browser, and every objective checked on the machine.
Start freeThe Secure Way
More on identity and access
Self-hosted identity with Zitadel, private access with Headscale, break-glass and offboarding.
All identity and access guides