Passwordless VPN authentication with passkeys
Recently, I came across this video of Alex (self-hosted podcast & tailscale) around authenticating tailscale with Pocket ID. This was fascinating. I am a headscale user for a long time and must say that authenticating tailscale clients in headscale is a little bit of a challenge. The default method opens a webpage on the client device, giving a headscale command to authenticate, and giving the device’s key.
This poses two practical challenges:
- Passing this key to the machine with headscale access for approval
- Admin becomes a bottleneck in the process as the process of authenticating users has to be done within a few minutes of triggering the login.
Notably, headscale also supports OIDC authentication. Official instructions include instructions on how to authenticate with Google OAuth or Azure. I tried it with Pocket ID, and the setup seems quite nice due to the use of passkeys.
Passkeys
For those who may not know, passkeys offer passwordless authentication based on cryptographic keys. The broad idea is not to use a password but generate a public-private key pair on the device, upload the public key on the website/provider and retain the private keys on the device. Next, when logging in, a challenge is given to the client, and that is solved using the private key of the client (a usual challenge-response method used by many other systems). These passkeys can be stored on OS locally (iOS, Android), password managers and even Yubikeys. Though, since one needs a way to access passkeys if the device is lost, the system design is not 100% passwordless, but imagine remembering the password of iCloud or Google or a password manager to log in, sync and unlock passkeys stored on the device. Beyond that point, experience can be mostly passwordless (with platforms which support it).
Setup
Pocket ID setup is quite straightforward as a Docker container running behind a reverse proxy. Their official setup instructions are here. Their instructions for headscale integration are here. The only challenge I had was that initially, after authenticating, Pocket ID kept throwing an error of “invalid callback URL”. Turns out for some reason, my headscale URL had:443 in the URL instead of just the https URL without port. Once updated, it worked well.
Demo
Here’s how the authentication process looks with a dummy user account (where ACL is blocking connection to all other devices)
Deployment notes
- I had a challenge due to conflict in the local headscale username Vs the username from Pocket ID. To deal with that, I had to remove the local user, but that had many machines, which I didn’t want to re-authenticate. To deal with that, I ended up using
headscale nodes move
command to a temporary user, removing a conflicting account, logging in via Pocket ID and moving devices back. - While the majority of my self-hosted apps are behind a VPN but Pocket ID has to be public to avoid cyclic dependency. One should be able to authenticate without a VPN to get to the VPN in the first place.
- It makes sense to use groups within Pocket ID. That way, other users on the same Pocket ID server do not get access to VPN by default. One can whitelist based on groups or email ID, etc.