~ posts tags
639 words
3 minutes

Using Tailscale as an OpenID Connect provider for homelab authentication

2025-05-04

I’ve been using Tailscale to authenticate to some applications on my homelab for a while. I use caddy with caddy-tailscale and proxy auth — essentially the same as laid out in Tailscale Authentication for NGINX.

On each request to a protected resource Caddy will:

It’s terribly easy to misconfigure and break the security however it is also extremely convenient! Unfortunately it also rather niche and many apps don’t support it — either for that reason or for security.

what could possibly go wrong with proxy authentication?

Reverse proxy authentication varies in implementation but usually involves passing a username only in an HTTP header. The proxy in question would then populate that header and ensure incoming requests have it stripped. The problem with this scheme is fairly straightforward — any connections which bypass the proxy may authenticate as any user.

It’s worth noting that while OAuth2 is harder to mess up than proxy authenticate it does still share a flaw — all it guarantees is the connection was sourced from your device. In a sense password authentication on a server hosted over tailscale is two-factor authentication — You need credentials (“something you know”) and access to a device on the tailnet (“something you have”). Using Tailscale for authentication leaves only a single factor — any connection coming from my device is assumed to be me. That’s a security model I’m satisfied with but readers should consider their own before setting up authentication like this.

If proxy authentication isn’t viable, what else is there to avoid the credential mess? Well, most self-hosted applications I run support OpenID Connect. Can we take advantage of the whois api to sign OIDC requests?

A few days of hacking and a proof of concept later… I learned that it already exists. Tailscale publishes tsidp. My search engine skills are getting pretty weak clearly since it’s been around for years. At least I understand the oauth2 flow a bit better now and I don’t even have to productionize the app to use it myself :D.

It’s pretty easy to configure. Tailscale identity is implicitly trusted so we don’t need to maintain a database. It uses tsnet to add its own named node to my tailnet and doesn’t require any configuration for OIDC client ID or secret.

A basic NixOS module looks like this or alternatively there are docker containers.

1
{
2
options,
3
config,
4
lib,
5
pkgs,
6
...
7
}:
8
9
with lib;
10
let
11
cfg = config.services.tsidp;
12
in
13
{
14
options.services.tsidp = with types; {
15
enable = mkEnableOption "should tsidp be enabled";
16
dataDir = mkOption {
17
type = types.path;
18
default = "/var/lib/tsidp";
19
description = "tsidp state dir";
20
};
21
};
22
23
config = mkIf cfg.enable {
24
users.users.tsidp = {
25
home = cfg.dataDir;
26
createHome = true;
27
isSystemUser = false;
28
isNormalUser = true;
29
description = "Tailscale IdP";
27 collapsed lines
30
};
31
32
systemd.services.tsidp = {
33
enable = true;
34
description = "Tailscale OpenID Connect service";
35
wants = [ "network-online.target" ];
36
after = [ "network-online.target" ];
37
wantedBy = [ "multi-user.target" ];
38
39
environment = {
40
TS_HOSTNAME = "idp";
41
TS_USERSPACE = "false";
42
TAILSCALE_USE_WIP_CODE = "1";
43
TS_STATE_DIR = "${cfg.dataDir}";
44
};
45
46
serviceConfig = {
47
Type = "simple";
48
RestartSec = 5;
49
Restart = "always";
50
User = "tsidp";
51
WorkingDirectory = "${cfg.dataDir}";
52
ExecStart = "${pkgs.tailscale}/bin/tsidp";
53
};
54
};
55
};
56
}

A minute or two after starting it up…

1
100.75.0.110 idp tsheinen@ linux -

To configure an application to use it all we need to do is point a client which supports authorization code flow at https://idp.tailnet.ts.net. Client ID and secret are ignored.

Configuration for Komga looks like this and it’ll be similar for any application which allows login using oauth2.

1
spring:
2
security:
3
oauth2:
4
client:
5
provider:
6
tsidp:
7
issuer-uri: https://idp.tailnet.ts.net
8
user-name-attribute: username
9
registration:
10
tsidp:
11
authorization-grant-type: authorization_code
12
client-id: unused
13
client-name: Tailscale
14
client-secret: unused
15
provider: tsidp
16
redirect-uri: '{baseUrl}/{action}/oauth2/code/{registrationId}'
17
scope: openid,email,profile