Since the start of this iteration of my homelab, having an SSO solution for all of my services has been on my mind. This post covers how I set that up with Authentik on k3s — and, because the whole cluster is managed with Flux CD, how the entire configuration lives in Git, down to the users, groups, and OIDC clients.

The end result

Every service on *.home.bstjohn.net now authenticates against a single Authentik instance at auth.home.bstjohn.net:

  • One login, every app. Sign in once and your session covers Sonarr, Radarr, Grafana, Prometheus, Mealie, Jellyfin, and a dozen others.
  • Group-based access. I’m in the infra and all-apps groups and can see everything. Family accounts sit in media and home groups and see Jellyfin and Mealie — not Prometheus.
  • The proxy is the only way in. NetworkPolicies mean the protected apps accept traffic from Traefik and their legitimate peers, and nothing else — SSO can’t be sidestepped from inside the cluster.
  • Apps with no auth story get one anyway. A single ingress annotation puts any service Traefik routes behind SSO, whether the app has ever heard of OIDC or not.
  • Everything lives in Git. Users, groups, OIDC clients, and per-app access policies are YAML in the fleet-infra repo. Delete a user in the Authentik UI and the next reconcile brings them back.

Architecture

graph TD
    browser["Browser"] --> traefik["Traefik"]

    subgraph cluster["k3s cluster"]
        traefik -->|"forward-auth subrequest"| authentik["Authentik<br>(auth.home.bstjohn.net)"]
        traefik --> proxied["Sonarr · Radarr · Grafana<br>Prometheus · ..."]
        traefik --> native["Mealie · Jellyfin<br>Headlamp · ..."]
        native -.->|"OIDC"| authentik
        authentik --> pg["PostgreSQL"]
        authentik --> redis["Redis"]
    end

Every app uses one of two integration patterns:

  1. Forward-auth — for apps with no usable SSO support. Traefik asks Authentik “is this request authenticated?” before proxying anything. The app itself is untouched.
  2. Native OIDC — for apps that speak OpenID Connect. The app redirects to Authentik for login and gets an identity token back, including group membership for role mapping.

Why Authentik

The identity provider had to be configurable the way everything else on this cluster is configured — declaratively, from Git. Most identity providers treat configuration as database state you click together in an admin UI and back up carefully. I wanted the opposite: files in a repo, applied by Flux, reverted if anything drifts.

That requirement thinned the field fast. Keycloak is the most capable provider of the bunch, but config-as-code there means exported realm JSON and bolt-on tooling. Authelia is admirably declarative — everything in one config file — but it means running a separate directory service for users, and the self-service story is thin when your user base includes family. Authentik’s blueprints hit the mark exactly: users, groups, OIDC clients, and per-app access policies are YAML documents applied by the worker, with the admin UI still there for the human moments, like password resets.

Two more things sealed it: a first-class OIDC provider for the apps that can integrate properly, and an embedded outpost, so the forward-auth endpoint for everything else is built into the main server rather than being yet another deployment. Forward-auth went on to gate half the cluster, but it was never the deciding criterion — a bonus that ended up carrying a lot of weight.

Deploying Authentik

Authentik runs as plain Kubernetes manifests in fleet-infra — four Deployments in the default namespace:

  • authentik-server — the web UI, OIDC endpoints, and embedded outpost
  • authentik-worker — background tasks and blueprint application
  • authentik-postgresql — Postgres 16 on a local-path PVC
  • authentik-redis — Redis 7 for cache and task queue

The server and Postgres both get priorityClassName: critical-infrastructure — if the node is under memory pressure, the login system should be the last thing evicted, because losing it locks you out of everything else. The ingress is standard Traefik with the wildcard certificate from the HTTPS post, serving auth.home.bstjohn.net.

Pattern 1: forward-auth for apps without native SSO

Traefik’s forwardAuth middleware sends a subrequest to Authentik’s embedded outpost before proxying each request. If the user has a session, the request goes through with identity headers attached; if not, they’re redirected to the login page.

apps/authentik/middleware-forwardauth.yaml:

yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: authentik-forwardauth
  namespace: default
spec:
  forwardAuth:
    address: http://authentik-server.default.svc.cluster.local:9000/outpost.goauthentik.io/auth/traefik
    trustForwardHeader: true
    authResponseHeaders:
      - X-authentik-username
      - X-authentik-groups
      - X-authentik-email
      - X-authentik-name
      - X-authentik-uid

The identity headers Authentik attaches (X-authentik-username and friends) are only trustworthy if a client can’t set them itself. authentik-strip-headers is a headers middleware that runs first in the chain and strips any incoming X-authentik-* headers before the forward-auth subrequest ever happens, so a header forged by a browser never survives to the app.

apps/authentik/middleware-chain.yaml:

yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: authentik-auth
  namespace: default
spec:
  chain:
    middlewares:
      - name: authentik-strip-headers
      - name: authentik-forwardauth

Putting an app behind SSO is then one annotation on its ingress:

yaml
metadata:
  annotations:
    traefik.ingress.kubernetes.io/router.middlewares: default-authentik-auth@kubernetescrd

Sonarr, Radarr, Prowlarr, Bazarr, Transmission, Prometheus, and Datasette all use this. Their built-in authentication is disabled — Authentik is the front door, and no request reaches them without passing it first.

Grafana is the interesting hybrid. It sits behind the same forward-auth middleware, but instead of showing its own login page it reads the X-authentik-username header via Grafana’s auth.proxy mode, so you land in Grafana already signed in as yourself. That only works if the header can be trusted, which brings us to the part of this setup that took longest to get right.

Trusting that header is only safe if requests can’t reach the app any other way. By default, anything else in the cluster can reach any Service directly, forward-auth and all — Traefik is a gate on the front door, not a wall around the building. The fix is a NetworkPolicy per app, restricting ingress to the Traefik namespace and to whichever pods legitimately need to talk to them:

yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: sonarr-ingress-restricted
spec:
  podSelector:
    matchLabels:
      app: sonarr
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: traefik
        - podSelector:
            matchExpressions:
              - key: app
                operator: In
                values: [prowlarr, bazarr, overseerr, jellyseerr, gatus]
      ports:
        - protocol: TCP
          port: 8989

That podSelector list is the whole difficulty. The *arr apps are a web of API calls — Prowlarr pushes indexer config to Sonarr and Radarr, Bazarr reads both of their libraries, Jellyseerr requests media from them — and Gatus needs to reach everything to health-check it. A naive Traefik-only policy locks the perimeter and breaks the stack from the inside. Prometheus needed the same care: it scrapes itself, Grafana queries it, and Gatus probes it, so those three get explicit exceptions alongside Traefik.

The lesson generalises past this cluster. Putting SSO in front of an app and disabling its own login is a trade: you’ve swapped the app’s authentication for the proxy’s, which means the proxy had better be the only way in.

Pattern 2: native OIDC

Each OIDC client is declared in the blueprint as an oauth2provider plus an application:

yaml
- model: authentik_providers_oauth2.oauth2provider
  id: mealie-oidc-provider
  identifiers:
    name: mealie-oidc
  state: present
  attrs:
    client_type: confidential
    client_id: mealie
    client_secret: !Env MEALIE_OIDC_CLIENT_SECRET
    redirect_uris:
      - url: https://mealie.home.bstjohn.net/login
        matching_mode: strict
    property_mappings:
      - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
      - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]]
      - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]]
      - !KeyOf groups-scope-mapping

On the app side it’s environment variables pointing at the discovery URL:

yaml
- name: OIDC_CONFIGURATION_URL
  value: https://auth.home.bstjohn.net/application/o/mealie/.well-known/openid-configuration
- name: OIDC_CLIENT_ID
  value: mealie
- name: OIDC_CLIENT_SECRET
  valueFrom:
    secretKeyRef:
      name: authentik-secrets
      key: MEALIE_OIDC_CLIENT_SECRET
- name: OIDC_ADMIN_GROUP
  value: 'authentik Admins'

The groups-scope-mapping referenced above is a custom scope mapping that adds the user’s Authentik groups to the token:

yaml
- model: authentik_providers_oauth2.propertymapping
  id: groups-scope-mapping
  attrs:
    scope_name: groups
    expression: |
      return {
        "groups": [group.name for group in request.user.ak_groups.all()]
      }

That single mapping powers role mapping everywhere: Mealie promotes members of authentik Admins to admins, Open WebUI does the same, and Headlamp maps groups to Kubernetes RBAC. The full OIDC roster: Mealie, Open WebUI, Jellyfin (via the SSO plugin), Jellyseerr, Headlamp, and Proxmox. That last one shows SSO escaping the cluster — Proxmox runs on bare metal, and while the Authentik half of the integration is declarative like everything else, the OIDC realm on the Proxmox side is the one piece registered by hand. A neat marker of exactly where the GitOps boundary ends.

Not everything converted. Overseerr only knows how to authenticate against Plex, and putting forward-auth in front of it would result in users seeing two login screens.

Users, groups, and who sees what

Access control lives in the blueprint too. Four groups (all-apps, media, home, infra), users with declarative group membership, and per-application policy bindings:

yaml
- model: authentik_policies.policybinding
  identifiers:
    order: 0
    target: !KeyOf sonarr-app
  state: present
  attrs:
    group: !KeyOf group-media
    order: 0
    target: !KeyOf sonarr-app
    enabled: true

A family account in the media group can open Sonarr and Jellyfin; hitting Prometheus gets a polite Authentik denial page instead. Because group membership is declared with state: present and a full groups list, the setup is genuinely GitOps: changing someone’s access in the UI gets reverted on the next blueprint apply. The change has to go through a pull request.

Client secrets nobody knows

The awkward part of declaring OIDC clients in Git is the client secrets. I didn’t want them in the repo (even encrypted), and I didn’t want to create them by hand — that’s exactly the undocumented state GitOps is supposed to eliminate.

The pattern I landed on: a Flux-managed Job runs a series of numbered, idempotent migration scripts. Each script checks whether a key exists in the shared authentik-secrets Secret, and if not, generates 48 random characters and patches it in:

bash
EXISTING=$(kubectl get secret "$SECRET_NAME" -n "$NS" \
  -o jsonpath="{.data.$KEY}" 2>/dev/null || true)
if [ -n "$EXISTING" ]; then
  echo "Key $KEY already exists, skipping"
  exit 0
fi

SECRET=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 48 || true)
kubectl patch secret "$SECRET_NAME" -n "$NS" \
  --type=merge \
  -p="{\"data\":{\"$KEY\":\"$(printf '%s' "$SECRET" | base64 | tr -d '\n')\"}}"

The Authentik worker consumes every key as an environment variable, the blueprint references it with !Env MEALIE_OIDC_CLIENT_SECRET, and the app reads the same key from the same Secret. Both sides of the OIDC handshake agree on a secret that no human has ever seen. On a fresh cluster the whole chain self-assembles — the only secret I create by hand is the initial admin password.

Wrapping up

The pattern I keep coming back to: the identity provider is infrastructure, so it should be managed like infrastructure. Authentik’s blueprints make that possible in a way most IdPs don’t — every user, group, OIDC client, access policy, and now every NetworkPolicy protecting them is a file in Git, and a rebuilt cluster converges back to a working SSO setup with one manually created password.


This builds on the rest of the home lab series: GitOps for Home Labs with Flux CD and k3s covers the Flux setup that applies everything here, and Automated Wildcard HTTPS Behind NAT with Let’s Encrypt covers the certificates every one of these hostnames relies on.