Files
public/docs/sso-and-identity.md
T
2026-09-01 17:59:02 +03:00

8.8 KiB

🪪 SSO & Identity

Note

Authentik is the identity provider for this fleet — self-hosted rather than an external IdP, for the same reason Vault is self-hosted in docs/secrets-and-pki.md: internal-only apps shouldn't need to trust a third party just to authenticate a login, and the two-zone reverse-proxy model in docs/dns-naming.md only works if the identity provider itself lives inside that same trust boundary.

Why Centralize Authentication At All

A homelab with dozens of self-hosted apps means dozens of different auth implementations if every app handles its own login — wildly inconsistent quality, no shared MFA, and revoking access means hunting down credentials app by app instead of doing it in one place. Centralizing on one identity provider fixes all three: every app's login gets the same enforcement regardless of how good or bad that app's own native auth actually is, and killing access anywhere starts with one action, not a checklist.

The proxy layer earns its own keep on top of that, though — it protects apps that don't have a login of their own at all. Not every self-hosted tool ships with real authentication; some are built assuming they'll only ever be reached from an already-trusted network and skip it entirely. Registering a Proxy Provider in front of an app like that puts Authentik's forward-auth check at the reverse-proxy hop, before the request ever reaches the app itself — so an app with zero security of its own still sits behind a real login, enforced by something other than the app. For apps that already have native OIDC, the proxy layer isn't doing the authenticating (the app does its own token exchange) — but for everything else, it's the only auth check that exists, period.

Every app gets the Proxy Provider registration described above — that's the universal baseline, with exactly one real exception found by checking live rather than assuming: Proxmox itself has no Proxy Provider at all, confirmed absent from Authentik's own API. It runs on native OIDC only — unlike its sibling PBS, which has both. A handful of other apps get a second, additional layer on top of the Proxy Provider baseline: real native OIDC/SAML support for their own login page, used alongside it rather than replacing it. The table below is pulled from Authentik's API directly, not from memory of what was set up — each provider's actual model type, not just its name:

App Stack
Gitea stacks/platform/gitea/
Immich stacks/media/immich/
Nextcloud stacks/productivity/nextcloud/
Paperless-ngx stacks/productivity/paperless-ngx/
Grafana stacks/platform/grafana-stack/
Audiobookshelf stacks/media/audiobookshelf/
Dockhand stacks/platform/dockhand/
Seerr stacks/media/seerr/
Proxmox / PBS hypervisor-level, not a docker stack — PBS has both layers, Proxmox has native OIDC only

This is the real list behind that table, not a transcription of it — every provider whose model type is genuinely oauth2, not just named the same as an app:

$ curl -s -H "Authorization: Bearer [REDACTED]" \
  "https://authentik.[service-to-service zone]/api/v3/providers/all/?page_size=200" \
  | python3 -c "
import json,sys
d = json.load(sys.stdin)
for r in d['results']:
    if r.get('component') == 'ak-provider-oauth2-form':
        print(r.get('name'))
" | sort
audiobookshelf
dockhand
gitea
grafana
immich
nextcloud
paperless
pbs
proxmox
seerr

Every other app runs on the Proxy Provider alone — which, per the point above, is never a weaker path: it's the same forward-auth check either way, just without a second, app-native login layered on top of it.

This is the actual live query against Authentik's API, not a paraphrase of what's assumed to be configured:

$ curl -s -H "Authorization: Bearer [REDACTED]" \
  "https://authentik.[service-to-service zone]/api/v3/providers/proxy/?page_size=1" | jq '.pagination.count'
96
$ curl -s -H "Authorization: Bearer [REDACTED]" \
  "https://authentik.[service-to-service zone]/api/v3/providers/proxy/?page_size=200" | jq -r '.results[].name' | grep -E '^proxmox'
[no output — Proxmox has no Proxy Provider at all]

Tip

Removal is a real, deliberate step, not an afterthought. Decommissioning an app means deleting the Application (by slug), then the Proxy Provider, then detaching that provider from the outpost's own provider list, then removing its icon file — in that order. Skipping the outpost-detach step is the easy mistake: the Application and Provider both look gone, but the outpost still references a provider that no longer resolves to anything, and that orphaned reference doesn't surface as an error anywhere obvious.

Dual-zone Registration

Every new app gets a Proxy Provider + Application pair registered per reverse-proxy zone it's actually exposed on — up to two registrations for the same app, one per zone from docs/dns-naming.md, driven by actual need rather than registering every app on both zones by default.

OIDC Login: the redirect crosses zones, every time

For an app using native OIDC, the login always follows the same path:

  1. User opens the app on the browser-facing zone.
  2. The app redirects the browser to Authentik to do the actual OAuth authorize/token exchange.
  3. That exchange happens against Authentik's own address on the service-to-service zone — reachable from a browser for this one hop specifically, even though that zone doesn't get the mutual-TLS treatment the browser-facing zone does.
  4. On success, the browser lands back on the app, still on the browser-facing zone.

OIDC cross-zone redirect: the browser carries every hop itself — Authentik and the app never talk to each other directly — with only the OAuth authorize/token exchange ever crossing from the browser-facing zone into the service-to-service zone

So an app's OIDC discovery/authority setting always points at the service-to-service zone, even though the app itself is normally reached on the browser-facing zone — the two zones are defined in docs/dns-naming.md. This is confirmed against one real app's actual login flow, not assumed to hold for every app without checking. Every other app-specific setting (token lifetime, scopes, flow type) doesn't generalize the same way — each app's own OIDC documentation gets read fresh rather than copying another app's provider config, since those details genuinely differ per vendor.

Note

Not every app can take the OIDC path above. Some — Jellyfin's auth plugin among them — only support LDAP: search the directory, then bind as whatever matched. Authentik's own LDAP outpost hides superuser accounts from that search by design, so the account that matters most can't authenticate this way for an app that requires it. The fix wasn't a second account or demoting the superuser — it was skipping LDAP for that app and reusing the same header-based forward-auth pattern the proxy-provider apps already use.

Git Access: least privilege, not the admin account

Automation should never run with more privilege than it needs. CI and orchestration pushes (the flows described in docs/orchestration.md) run through a dedicated, non-admin bot account with scoped write access — never through an administrator identity. This wasn't the original setup: automated pushes used to run through the personal admin account used for interactive access, the same one with full admin rights over the Git server. That got fixed by standing up a separate account with its own SSH keypair and adding it as a write collaborator on every repo it touches — routine automated commits no longer run under an admin credential at all. Hosts that only need to read get separate read-only deploy keys instead of any shared credential. The practical payoff: if an automation credential ever leaks, the blast radius is "can push to specific repos," not "has full admin control of the Git server."

Gitea repo collaborators: automation-bot with Write access, not an admin account

Gitea repo deploy keys: a host-specific key scoped to Read only


Related: docs/dns-naming.md, docs/orchestration.md, docs/secrets-and-pki.md · Stacks: stacks/identity-and-security/authentik/, stacks/platform/gitea/