Identity and access

Developer onboarding and offboarding, the secure way

Onboarding takes a week of tickets. Offboarding takes one email to IT, and the developer's laptop is still on the VPN three months later, because the email was about the SSO account.

The short answer

Give access through groups in the identity provider, never to named people in each system, and enroll devices with single-use keys. Offboarding is a script, not a ticket: list everything the person has, expire their devices and sessions at once, delete accounts and keys, rotate shared secrets they saw, and check the list is empty.

Updated Houssam Hammoudi, CTOTested with Headscale 0.29.4, Tailscale 1.102.4

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

Access is granted person by person, system by system: an SSO account, a tailnet device, a database role, a cloud login, a password store entry. Nobody has the full list, so nobody can take it all back.

Offboarding disables the SSO account and stops there. Devices already enrolled in the tailnet keep their keys. Tokens already issued keep working until they expire. Some never expire.

Servers the person enrolled are forgotten. In Headscale, a tagged node is not owned by the person who joined it, so deleting the person leaves it in place.

Shared secrets the person could read (the pass store, a vendor account, a deploy key) stay the same, and a copy in their home directory is still valid.

What the docs say

Setting the value to "0" means no default expiry (nodes never expire unless explicitly expired via headscale nodes expire).

Source: Headscale, config-example.yaml (v0.29.4)

Default key expiry for non-tagged nodes, regardless of registration method (auth key, CLI, web auth). Tagged nodes are exempt and never expire.

Source: Headscale, config-example.yaml (v0.29.4)

The node expiration is the amount of time a node is authenticated with OpenID Connect until it expires and needs to reauthenticate.

Source: Headscale docs, OpenID Connect

Expiry is a backstop, not offboarding. A 7-day expiry still leaves a week. The test shows that expiring and deleting a node logs the laptop out within seconds.

The secure configuration

Onboarding:

  • Create the person once, in the identity provider, and put them in groups (dev, ops, oncall). Every system maps groups to access. Nobody is named in a system's policy file except through a group.
  • Enroll each device with a single-use key that expires in an hour, tied to the person. Servers use tagged keys issued by ops, never personal keys.
  • Grant time-limited elevation (on-call, production write) through a group with an end date, not a permanent role.
  • Record the device, keys and group memberships in the ticket.

Offboarding, the same day:

  1. Remove the person from every IdP group and disable the IdP account.
  2. Run the per-system offboarding scripts. For Headscale:
bash
#!/bin/sh
# offboard-headscale.sh USER: show what USER has, expire and delete their
# nodes, remove their pre-auth keys, then delete the user. Run where the
# headscale CLI can reach the server (unix socket), or set HS, for example
# HS="docker exec headscale headscale".
set -eu
u=$1
HS=${HS:-headscale}
headscale() { $HS "$@"; }
id=$(headscale users list -o json | jq -r --arg u "$u" '.[] | select(.name==$u) | .id')
[ -n "$id" ] || { echo "no such user: $u"; exit 1; }
echo "== inventory for $u (id $id)"
headscale nodes list -u "$u" -o json | jq -r '.[]? | "node \(.id) \(.given_name) \(.ip_addresses[0]) connected=\(.online // false) expires=\(if .expiry.seconds then (.expiry.seconds|todate) else "never" end)"'
headscale preauthkeys list -o json | jq -r --arg id "$id" '.[]? | select((.user.id|tostring)==$id) | "preauthkey \(.id) used=\(.used // false) expires=\(.expiration.seconds|todate)"'
echo "== expire every node now (the client is logged out at once)"
for n in $(headscale nodes list -u "$u" -o json | jq -r '.[]?.id'); do headscale nodes expire -i "$n" >/dev/null && echo "expired node $n"; done
echo "== delete nodes, keys and the user"
for n in $(headscale nodes list -u "$u" -o json | jq -r '.[]?.id'); do headscale nodes delete -i "$n" --force >/dev/null && echo "deleted node $n"; done
for k in $(headscale preauthkeys list -o json | jq -r --arg id "$id" '.[]? | select((.user.id|tostring)==$id) | .id'); do
  headscale preauthkeys delete --id "$k" >/dev/null 2>&1 && echo "deleted preauthkey $k" || true
done
headscale users destroy --identifier "$id" --force >/dev/null && echo "deleted user $u"
  1. Disable the person's OpenBao entity so every token they hold stops working at once (see an insider kill switch).
  2. Re-encrypt the pass store without their key and rotate every secret they could read (see operator secret stores).
  3. Review tagged servers and deploy keys they created; re-enroll or rotate them.
  4. Remove their public SSH keys, Git signing keys and personal access tokens from the forge.
  5. Run every inventory again and attach the empty result to the ticket.

Prove it

From secure-tests/developer-onboarding-offboarding/. Dana (group dev) enrolls a laptop with a single-use key and reaches the staging server the policy allows:

bash
headscale preauthkeys create --user 1 --expiration 1h
headscale policy check -f policy.hujson
wget -qO- http://100.64.0.1/   # from dana's laptop
text
Policy is valid
staging: ok

Offboarding day:

bash
offboard-headscale.sh [email protected]
text
== inventory for [email protected] (id 1)
node 2 dana-laptop 100.64.0.2 connected=true expires=2026-10-01T22:11:25Z
preauthkey 1 used=true expires=2026-09-24T23:11:15Z
== expire every node now (the client is logged out at once)
expired node 2
== delete nodes, keys and the user
deleted node 2
deleted preauthkey 1
deleted user [email protected]

On dana's laptop, seconds later, the client is logged out and the staging server is gone:

bash
tailscale status
wget -qO- http://100.64.0.1/
text
Logged out.
wget: server returned error: HTTP/1.1 502 Bad Gateway
failed

What is left: the other user, and the tagged server, which belongs to no person:

bash
headscale users list; headscale nodes list
text
[email protected]
staging-1	tagged-devices

Mistakes people make

Disabling SSO and calling it done

SSO stops new logins. Devices, tokens and sessions issued before keep working until they expire, and some never do. Expire them directly.

Access granted to people, not groups

When a policy file names [email protected], offboarding needs someone to find every file. When it names group:dev, removing dana from the group is enough.

Personal keys on servers

A server enrolled with dana's key is dana's device. Offboarding her disconnects it, or worse, nobody notices it is hers. Servers use tagged keys.

Forgetting what the person enrolled

Tagged nodes and deploy keys are not owned by the person, so deleting the person does not remove them. The inventory must include what they created.

Not rotating shared secrets

Removing a person from the pass store protects new values only. Anything they could read must change.

headscale nodes expire --disable

It sets a node to never expire. It is the opposite of what you want for a person's device.

Checklist

  • Map every system's access to IdP groups, not to named people.
  • Enroll devices with single-use, one-hour keys tied to the person.
  • Enroll servers with tagged keys issued by ops.
  • Set node.expiry to 7 days or less as a backstop.
  • On offboarding, remove the person from all groups and disable the IdP account.
  • Run each system's offboarding script: inventory, expire, delete.
  • Disable the person's OpenBao entity.
  • Re-encrypt the pass store and rotate every secret the person could read.
  • Review tagged nodes, deploy keys and tokens the person created.
  • Run the inventories again and record the empty result.

Good offboarding is boring and fast, which is exactly what you want from the last day of anyone's access.

H2-CIAE

Learn it on a live range

SSO and lifecycle, in Identity and Access Engineering: a real host in your browser, and every objective checked on the machine.

Start free

The 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