Single Sign-On (SSO)


Single Sign-On allows users to authenticate with Headtower through an external Identity Provider (IdP) using the OpenID Connect (OIDC) protocol. When enabled, users sign in through your IdP and Headtower automatically links them to their Headscale identity, assigns a role, and manages their session.
If your reverse proxy already performs authentication and can pass trusted user headers to Headtower, see Proxy Authentication instead.
Getting Started
Requirements
You'll need the following before proceeding:
- A working Headtower installation that is already configured.
- An Identity Provider (IdP) that supports OAuth2 and OpenID Connect (OIDC).
server.base_urlset to the public URL of your Headtower instance in your configuration file (the domain visible in the browser).- A Headscale API key with a relatively long expiration time (eg. 1 year).
Configuring the Client
You'll need to create a client in your Identity Provider that Headtower can use for authentication. As part of that step, you'll need to register a "redirect URL" — this is where the IdP sends users after they authenticate.
For Headtower, the redirect URL will be in the following format (replace the domain with the value set for server.base_url):
https://headtower.example.com/admin/oidc/callbackOnce you have created the client, make note of the following:
- Client ID
- Client Secret (if applicable)
- Issuer URL
OIDC Configuration
To enable OIDC authentication in Headtower, add the following to your configuration file:
headscale:
url: "http://headscale:8080"
api_key: "<generated-api-key>"
oidc:
issuer: "https://your-idp.com"
client_id: "your-client-id"
client_secret: "your-client-secret"
# You can also provide the client secret via a file:
# client_secret_path: "${HOME}/secrets/headtower_oidc_client_secret.txt"
# These are usually auto-discovered, but can be set manually:
# authorization_endpoint: ""
# token_endpoint: ""
# userinfo_endpoint: ""
# scope: "openid email profile"
# subject_claims: ["open_id", "email"]
# default_role: "member"
# role_claim: "headtower_role"
# allow_weak_rsa_keys: false
# extra_params:
# foo: "bar"Headtower automatically discovers OIDC endpoints from your issuer's /.well-known/openid-configuration. If your IdP does not support discovery, you'll need to set the endpoints manually.
Non-standard Subject Claims
Some providers do not return the standard OIDC sub claim in the ID token. Headtower always uses sub first, but you can configure fallback claims with oidc.subject_claims.
For Feishu/Lark, the recommended configuration is:
oidc:
subject_claims: ["open_id", "email"]This keeps identity matching stable by preferring open_id and only falling back to email if needed.
Legacy Weak RSA Signing Keys
Some legacy providers still sign ID tokens with RSA keys smaller than 2048 bits. Headtower rejects those keys by default.
If your provider cannot rotate to a stronger signing key yet, you can explicitly enable the compatibility fallback:
oidc:
allow_weak_rsa_keys: trueWARNING
This weakens ID token verification security and should only be used as a temporary workaround while your provider rotates to a 2048-bit-or-larger key.
PKCE
WARNING
Headtower currently only supports the S256 code challenge method for PKCE. You may need to ensure that your Identity Provider is configured to accept this method.
By default, Headtower does not use PKCE (Proof Key for Code Exchange). PKCE is a best practice for OIDC and enhances security — some IdPs even require it. To enable PKCE:
oidc:
use_pkce: trueHow User Matching Works
When a user signs in via OIDC, Headtower needs to link them to their corresponding Headscale user. This is important for features like showing a user's own machines, self-service pre-auth keys, and WebSSH.
Matching Strategy
Headtower uses a two-step matching strategy:
Subject match (primary): Headscale stores the IdP's
provider_idfor each OIDC user (e.g.https://idp.example.com/3d6f6e3f-...). Headtower extracts the last path segment and compares it to the resolved OIDC subject. The resolved subject usessubfirst, then falls back to any configuredoidc.subject_claims. If they match, the user is linked.Email match (fallback): If the subject doesn't match, Headtower falls back to comparing the user's email address from the OIDC
userinfoendpoint against the email stored on the Headscale user record.
Once a link is established, it's stored as a headscale_user_id in Headtower's database and reused on subsequent logins — so the matching only needs to succeed once.
Headscale Without OIDC
If your Headscale instance uses local users (created via headscale users create) rather than OIDC, automatic matching cannot work — local users have no provider_id or email to compare against.
In this case, Headtower will prompt the user during onboarding to manually select which Headscale user they are. This selection is persisted, so it only needs to happen once. After linking, all ownership-based features (viewing your own machines, self-service pre-auth keys, WebSSH) work normally.
TIP
If you skip the user selection during onboarding, you can still use Headtower — you just won't have ownership-based features. An admin can manage everything regardless of whether users are linked.
Same Client vs. Different Clients
Recommended
Using the same OIDC client for both Headscale and Headtower is the simplest and most reliable setup. The sub claim will be identical for both services, so subject matching always works.
If your Headscale and Headtower use different OIDC clients, some Identity Providers (notably Azure AD / Entra ID) may issue different sub values per client application. In this case:
- Subject matching will fail on the first login.
- Headtower will fall back to email matching, which requires that the
emailclaim is available from both your IdP'suserinfoendpoint and Headscale's user record. - Once the link is established, subsequent logins will work regardless because the link is persisted.
WARNING
If you use different clients and your IdP does not provide an email claim, Headtower will not be able to match users to their Headscale identity. Users will still be able to sign in, but they won't be linked to a Headscale user — meaning features like viewing their own machines or self-service pre-auth keys won't work.
Roles and Permissions
When SSO is enabled, Headtower uses a role-based access control system to determine what each user can do in the UI.
Available Roles
| Role | Description |
|---|---|
| Owner | Full access to everything. Cannot be reassigned. Automatically granted to the first user who signs in. |
| Admin | Full access except the owner-specific flag. Can manage all users, machines, ACLs, DNS, and settings. |
| Network Admin | Can manage ACLs, DNS, and network settings. Can view machines and users. Can generate pre-auth keys. |
| IT Admin | Can manage machines, users, and feature settings. Can configure IAM. Cannot modify ACLs or DNS. |
| Auditor | Read-only access to everything. Can generate their own pre-auth keys. |
| Viewer | Can view machines and users. Can generate their own pre-auth keys. |
| Member | No UI access. The user exists in Headtower's database but has not been granted any permissions. |
First Login (Owner Bootstrap)
The very first user to sign in via OIDC is automatically assigned the Owner role. All subsequent users are assigned the Member role (no access) by default. An owner or admin must then assign them an appropriate role through the Users page.
Automatic Role Assignment
You can change the role assigned to newly created OIDC users with oidc.default_role:
oidc:
# Valid values: admin, network_admin, it_admin, auditor, viewer, member
default_role: "viewer"This is useful when Headscale already restricts who can authenticate by domain, group, or user. For example, if Headscale only allows @example.com users to sign in and all of those users should be able to view Headtower, set default_role: "viewer".
For per-user roles from your IdP, configure oidc.role_claim with the OIDC claim that contains a Headtower role:
oidc:
role_claim: "headtower_role"The claim may be a string, such as "admin", or an array containing one of the valid roles. This lets providers such as Keycloak map groups or client roles to a final Headtower role before login. When both role_claim and default_role are configured, a valid role claim takes precedence for new users.
For users that already exist in Headtower, a valid role_claim is synced on each OIDC login. If their IdP groups or client roles start matching a different Headtower role, their Headtower permissions are updated at their next sign-in. default_role remains a creation-time fallback only and does not overwrite existing roles. The Owner role is reserved for the first-login bootstrap and cannot be granted or overwritten by default_role or role_claim.
API Key Sessions
Users who sign in with a Headscale API key (instead of OIDC) are treated as having full access. API key sessions bypass the role system entirely since possession of the API key already implies administrative access to Headscale.
Onboarding
When a new OIDC user signs in for the first time, they go through a brief onboarding flow that helps them connect their first device to the Tailnet. This flow can be skipped. Once completed, users are taken to the main dashboard.
Single Logout (RP-Initiated Logout)
Headtower supports OpenID Connect RP-Initiated Logout. When enabled, clicking "Log Out" in the UI from an OIDC-backed session will:
- Destroy the local Headtower session.
- Redirect the browser to the identity provider's
end_session_endpoint. - Pass along the original
id_tokenasid_token_hint, plus apost_logout_redirect_uriso the IdP can return the user to Headtower after it has cleared its own session.
Configuration
This feature is disabled by default because the post_logout_redirect_uri must be pre-registered in your OIDC client on the IdP. Enabling it without that registration will land users on the provider's error page after logout.
To enable it, set oidc.use_end_session: true:
oidc:
# Required: opt in to RP-initiated logout
use_end_session: true
# Optional: override the auto-discovered end_session_endpoint, or set it
# manually if your provider does not expose it via discovery.
# end_session_endpoint: "https://idp.example.com/realms/main/protocol/openid-connect/logout"
# Optional. Defaults to `<server.base_url>/admin/login?s=logout`.
# post_logout_redirect_uri: "https://headtower.example.com/admin/login?s=logout"If your provider exposes end_session_endpoint in its discovery document (Keycloak, Authentik, Auth0, Azure AD, …) Headtower picks it up automatically once use_end_session is true.
TIP
Make sure the redirect URI you supply (or the default one Headtower builds) is listed under the post-logout / valid redirect URIs in your IdP's client configuration, otherwise the provider will refuse to redirect back.
When use_end_session is false (the default), Headtower simply destroys its own session and returns the user to the login page.
Troubleshooting
Common Issues
"OIDC is not enabled or misconfigured": Check that your
oidcsection is present in the config and that the issuer URL is reachable from the Headtower server.User signs in but can't see their machines: The user's Headscale identity wasn't matched. Check that either the
subclaim matches or theemailclaim is available (see How User Matching Works)."Session cookie is empty" or login loop: Check your
cookie_securesetting. If Headtower is behind a reverse proxy with HTTPS, set it totrue. If running without HTTPS (eg. local development), set it tofalse.Invalid API Key: The
headscale.api_keymay have expired. Generate a new one withheadscale apikeys create --expiration 999d.Missing the
subclaim: If your IdP omitssub, configureoidc.subject_claimswith a stable fallback such asopen_id. Only useemailas a fallback when it is stable for your users.Redirect URI Mismatch: Ensure the redirect URI registered in your IdP matches
{server.base_url}/admin/oidc/callbackexactly.PKCE errors: If your IdP requires PKCE, set
oidc.use_pkce: true. If you see errors mentioningcode_verifier, this is almost always the cause.Missing endpoints: If your IdP does not support OIDC discovery, you'll need to set
authorization_endpoint,token_endpoint, anduserinfo_endpointmanually in the config.