A bonus article in the self-hosted Headscale series! After implementing it myself with a few trials and tribulations, I decided to share how exactly to make Authelia work as an OIDC identity provider for Headscale.
Why OIDC?
Headscale works great on its own. You define users locally, the get saved into the sqlite db on disk, you can reference them in ACLs, everything just works. What more is there to want?
In steps SSO… We’ll quote Okta here, because they’re experts.
Single sign-on (SSO) is an authentication tool that enables users to securely access multiple applications and services using one set of credentials, eliminating the need to remember different passwords for each service. […] SSO puts an end to the days of remembering and entering multiple passwords and eliminates the frustration of having to reset forgotten passwords. Users can also access a range of platforms and apps without having to log in each time.
Got it, so that makes a ton of sense in a large enterprise-like environment, where you have user churn, you’re hiring and firing and need to provision and deactivate accounts from a single source of truth, and that’s the important part here — a single source of truth. You can extend this configuration to be an identity provider for lots of your services, and emulate that “enterprise-like” environment. It gives you granular control, and best of all: it gives you multi-factor authentication! That’s right, our headscale setup with have MFA after this.
There’s the added benefit of some easier interfacing with other tools too, like third party webUIs for headscale, such as Headplane, which integrates very well with OIDC.
Info
As of headscale release 0.26, several breaking changes were made as part of a code-base cleanup and efficiency push. One of these is that all user objects in your ACLs must include an @ in their string. This makes perfect sense when you’re passing an email address as part of your OIDC claim, but for locally defined users, it means going through and manually adding an @ to the end of their username everywhere it appears in your ACLs. Using OIDC lends itself to the new method of referencing users.
If this is the first article you’re reading, consider yourself lucky as you can apply this to your new headscale setup without any rework!
Let’s get into it!
Adding Authelia
Note
As this is a limited deployment of SSO for me, I have chosen to host authelia on the same VPS that I’m hosting my headscale server on. This probably makes sense if you’re only using your identity provider externally, but if you’re relying on it for internal services too, maybe consider hosting it on-prem and exposing it to the internet via a Wireguard tunnel with traefik as a reverse proxy i.e. a homemade Cloudflare tunnel. Maybe that’s a blog post for another day. Leave a comment if you’d like to see that.
Assumptions:
- You’re running a server with a publicly accessible reverse proxy.
- Your server operating system has podman installed and supports quadlets.
- You already have a headscale stack running.
- If not, check out my post on setting up headscale first.
- You’re running traefik in podman already as a reverse proxy.
Configure a container for Authelia
In your /etc/containers/systemd directory, create a new file for our authelia config:
echo '[Volume]' | sudo tee authelia-config.volumeNow the container definition. The following is what goes in your authelia.container file.
[Unit]
Description=Authelia Container
After=network-online.target traefik.service
Requires=traefik.service
[Container]
ContainerName=authelia
HostName=authelia
Image=ghcr.io/authelia/authelia:latest
Network=traefik.network
Volume=authelia-config.volume:/config:Z
Environment=TZ=America/New_York
Secret=authelia_session_secret,type=mount,target=/run/secrets/authelia_session_secret
Secret=authelia_storage_encryption_key,type=mount,target=/run/secrets/authelia_storage_encryption_key
Secret=authelia_smtp_password,type=mount,target=/run/secrets/authelia_smtp_password
Secret=authelia_jwt_secret,type=mount,target=/run/secrets/authelia_jwt_secret
Secret=authelia_oidc_hmac,type=mount,target=/run/secrets/authelia_OIDC_HMAC
Environment=AUTHELIA_SESSION_SECRET_FILE=/run/secrets/authelia_session_secret
Environment=AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/run/secrets/authelia_storage_encryption_key
Environment=AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/run/secrets/authelia_smtp_password
Environment=AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/run/secrets/authelia_jwt_secret
Environment=AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE=/run/secrets/authelia_OIDC_HMAC
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=default.targetOkay, there are a number of secrets here… We need to create secrets for:
- authelia_session_secret
- authelia_storage_encryption_key
- authelia_smtp_password
- authelia_jwt_secret
- authelia_OIDC_HMAC
The SMTP password secret should be self-explanatory, that’s going to have a plaintext password for the service mailbox you’re using to send Authelia emails on behalf of.
Create a temporary file to store the secret value, that way it doesn’t accidentally end up in your bash history or anywhere else: sudo vim smtp.secret
# Change this to your email account's password and don't include this comment!
A sup3r secure passphrase123.
---
:wqCreate your secret: sudo podman secret create authelia_smtp_password ./smtp.secret
Make sure you delete the file securely afterwards: sudo shred -u smtp.secret
The rest of the secrets are going to be generated values. The Authelia Documentation does a very good job of covering environment variables and secrets. I’m not going to cover the secret creation command each time, but you can extrapolate from the above example.
This is the command we’ll use to generate our random strings:
LENGTH=64
tr -cd '[:alnum:]' < /dev/urandom | fold -w "${LENGTH}" | head -n 1 | tr -d '\n' ; echoMake one of those random strings for each of the above secrets, then create each secret as above.
The config file
Let’s reload our systemd configuration and get our volume setup so we can create a config file.
sudo systemctl daemon-reload
sudo systemctl start authelia-config-volume.serviceNow head over to: /var/lib/containers/storage/volumes/systemd-authelia-config/_data/ and we’ll get started on config.
# yamllint disable rule:comments-indentation
---
###############################################################################
## Authelia Configuration ##
###############################################################################
theme: "auto"
default_2fa_method: 'totp'
server:
address: "tcp://:9091/"
asset_path: "/config/assets/"
disable_healthcheck: false
telemetry:
metrics:
enabled: false
totp:
disable: false
issuer: "cloudauth.yourDomain.tld"
algorithm: 'SHA1'
digits: 6
period: 30
skew: 1
secret_size: 32
disable_reuse_security_policy: false
identity_validation:
reset_password:
jwt_lifespan: "5 minutes"
jwt_algorithm: "HS256"
elevated_session:
code_lifespan: '5 minutes'
elevation_lifespan: '10 minutes'
characters: 8
require_second_factor: false
authentication_backend:
password_change:
disable: false
password_reset:
disable: false
file:
path: "/config/users_database.yml"
watch: true
search:
email: false
case_insensitive: false
password:
algorithm: "argon2"
argon2:
variant: "argon2id"
iterations: 3
memory: 65536
parallelism: 4
key_length: 32
salt_length: 16
password_policy:
standard:
enabled: false
min_length: 16
require_uppercase: true
require_lowercase: true
require_number: true
require_special: true
zxcvbn:
enabled: true
min_score: 3
access_control:
default_policy: "two_factor"
session:
cookies:
- name: "authelia_session"
domain: "yourDomain.tld"
authelia_url: "https://cloudauth.yourDomain.tld"
regulation:
modes:
- "user"
max_retries: 4
find_time: "5 minutes"
ban_time: "15 minutes"
storage:
local:
path: "/config/db.sqlite3"
notifier:
disable_startup_check: true
smtp:
address: "submissions://your.emailprovider.tld:465"
username: "cloudauth@yourDomain.tld"
sender: "CloudAuth <cloudauth@yourDomain.tld>"
identifier: "cloudauth.yourDomain.tld"
identity_providers:
oidc:
claims_policies:
default:
id_token: ['groups', 'email', 'email_verified', 'alt_emails', 'preferred_username', 'name', 'picture']
jwks:
- key_id: "GenerateThisSecretLikeBefore"
## The key algorithm used with this key.
algorithm: "RS256"
## The key use expected with this key. Currently only 'sig' is supported.
use: "sig"
key: |
-----BEGIN PRIVATE KEY-----
DATA HERE
This should be the private_key.pem value of a key generated with
openssl req -newkey rsa:2048 -nodes -keyout private_key.pem -x509 -days 3650 -out public_certificate.pem
-----END PRIVATE KEY-----
authorization_policies:
headscale:
default_policy: "two_factor"
rules:
- policy: "deny"
subject: "group:denied"
lifespans:
access_token: "8 hour"
authorize_code: "1 minute"
id_token: "1 hour"
refresh_token: "90 minutes"
clients:
- client_id: "GenerateThisSecretLikeBefore" # You'll need this again on the headscale config
client_name: "Tailscale"
client_secret: 'GenerateThisSecretLikeBefore' # You'll need this again on the headscale config
claims_policy: default
public: false
redirect_uris:
- "https://tailscale.yourDomain.tld/oidc/callback"
scopes:
- 'openid'
- 'groups'
- 'email'
- 'profile'
authorization_policy: 'headscale'
require_pkce: trueNow we need our local users file: users-database.yml:
###############################################################
# Users Database #
###############################################################
# This file can be used if you do not have an LDAP set up.
users:
authelia:
disabled: true
displayname: "Test User"
password: "Your Argon Hash Value Here" # Password is 'authelia'
email: authelia@authelia.com
groups:
- admins
- devTip
Authelia has a built-in utility to generate hashes for you: sudo podman run --rm -it ghcr.io/authelia/authelia:latest authelia crypto hash generate argon2
Setup your DNS A record and give Traefik a router to properly pass traffic to your Authelia instance and give it whirl!

You should be able to sign in with whatever account you created.
Tip
Authelia also supports LDAP sync if you already have another IdP setup in your environment, like OpenLDAP, LLDAP, Active Directory, or FreeIPA!
I’ll leave it as “an exercise for the reader” to create the other users you’ll use for headscale endpoints.
Reconfiguring Headscale to Use Authelia
Wherever you have your headscale config stored (maybe /var/lib/containers/storage/volumes/systemd-headscale-config/_data…?), we’re going to head over there and add some config for OIDC.
Tip
It’s always good practice to back things up before you edit them for the first time, but don’t worry, adding OIDC won’t affect any of your existing users. Headscale can perfectly happily have local users and OIDC users. You can’t have the same username shared between the two, but that point is moot at the moment.
This is the stanza you’re going to want to add (with your values, of course) to your headscale config.yaml file:
oidc:
client_id: 'TheSameSecretFromAuthelia'
client_secret_path: '/run/secrets/headscale_oidc_secret' # This should be the same secret value from the authelia config
extra_params:
- domain_hint: yourDomain.tld
issuer: 'https://cloudauth.yourDomain.tld'
scope:
- openid
- profile
- email
pkce:
enabled: true
method: S256oidc here is a parent key, so it’s not indented at all, if you’re wondering where to put this config snippet. The headscale example config shows you all the different values that could go here.
Now the config change isn’t the only thing. Since we’re adding a podman secret, we need to define that secret in the .container file.
Head over to your headscale.container file and let’s define the secret. Add the following line under the [Container] section tag somewhere
Secret=headscale_oidc_secret,type=mount,target=/run/secrets/headscale_oidc_secretNow we should be good on the headscale side. Reload your systemd config: sudo systemctl daemon-reload, and restart headscale sudo systemctl restart headscale. If all went well, headscale should be back up and running, exactly as it was before, with all users from before working.
Let’s get an OIDC user in there.
Testing and OIDC Authentication
On a device that you want to re-enroll, or a device you have enrolled yet with Tailscale, execute the exact same login process as described in my first blog post. I’m going to be on Linux here so I’ll run:
sudo systemctl start tailscaled
sudo tailscale login --login-server https://tailscale.yourDomain.tldbut this time, you should see a URL spit out that you’ll visit to authenticate!

Copy/paste that into your browser and you should be good to go! You’ll get prompted to enroll an MFA device for you TOTP second factor the first time you sign in (with whatever user), and email confirmation will be required for that.
Now just go an extend the config by creating more users in Authelia, and adding them to Tailscale. You can apply ACLs exactly as you did before and carry on as normal, but with more security, thanks MFA!
Credit to CISA.gov for the graphic. ❤️ CISA
Support
If you’ve enjoyed this, consider helping support the infrastructure to run it.
Buy me a coffee or three with the button below.
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.