Count your services, then count their login pages. For years my honest tally was: about thirty services, six with real passwords, ten sharing a password I’d rather not discuss, and the rest “temporarily” unauthenticated on the theory that nobody knows the URL. Security through apathy.

The fix isn’t discipline (post 4 covered how well discipline scales). The fix is making authentication cheaper than not having it. That’s what SSO does here: protecting a new app costs one label, so everything gets protected, and the apathy path stops existing.

Topology: one brain, many hands

The Authentik core runs on hermes: server, worker, its own postgres and redis, the stateful-with-db pattern from post 3. Every host, including hermes itself and including url, runs a lightweight outpost container that enforces auth locally and keeps a websocket open to the core.

Confession: I originally ran an independent Authentik per host. It seemed robust. It was actually three user databases, three configs drifting apart, three upgrade dances, and no shared session, so “single” sign-on involved signing in three times. The core/outpost topology is the shape the product wants: state in one place, enforcement everywhere, sessions that mean something.

The two integration modes

Forward-auth, for apps that can’t (or shouldn’t be trusted to) do their own auth. The proxy intercepts each request and asks the local outpost who this is; unauthenticated users get bounced to the login flow:

# defined ONCE, in each proxy's dynamic config:
http:
  middlewares:
    sso:
      forwardAuth:
        address: http://sso-outpost:9000/outpost.goauthentik.io/auth/traefik
        trustForwardHeader: true
        authResponseHeaders:
          - X-authentik-username
          - X-authentik-email
          - X-authentik-groups

Per app, the entire cost of protection:

      - traefik.http.routers.app.middlewares=sso@file

Those response headers are a quiet bonus: apps that understand proxy-auth headers (more than you’d expect) get not just a locked door but a logged-in user with groups.

OIDC, for apps that speak it: the deploy UI, the request portal, the code editor. They get a real provider in Authentik and run the redirect dance themselves. Proper tokens, proper logout, permissions mapped from Authentik groups. Users see the same account and session either way and can’t tell which mode an app uses, which is the point.

The cookie mechanics from post 2 pay off here: the forward-auth session rides a cookie on the canonical domain, so one login covers every *.example.dev admin tool. This is why everything sensitive lives under one roof.

Config as code, where the product allows

Authentik applies YAML blueprints from a watched directory: providers, applications, the app library. Mine bind-mount straight from the repo, so a rebuilt Authentik comes back configured instead of blank:

# config/sso/blueprints/app-vault.yaml (the shape)
entries:
  - model: authentik_providers_proxy.proxyprovider
    identifiers: {name: vault}
    attrs:
      external_host: https://vault.example.dev
      mode: forward_single
  - model: authentik_core.application
    identifiers: {slug: vault}
    attrs:
      name: Vault
      provider: !KeyOf vault

The honest caveat: not everything is blueprint-able. Some settings stay UI-made (session lengths among them, below), and those belong in your rebuild runbook. “After rebuild, re-apply the following by hand” is an ugly sentence that beats rediscovering each item in production.

Four pitfalls, ranked by how down production went

1. The outpost dials the core by hostname. Keep it the INTERNAL name. The outpost’s AUTHENTIK_HOST is where it phones home AND part of token validation. Point it at a hostname that later answers with a redirect (say, after a domain change) and the redirect strips the Authorization header. The outpost gets 403, every protected app on that host returns 500, and no log says “your hostname went stale.” Reference the internal domain variable so it moves when the domain moves, mechanically. This one took everything down twice before the rule existed.

2. Outposts match the core’s version, exactly. A version-skewed outpost falls into a websocket reconnect loop (close code 1006, on repeat) and auth goes intermittent by the minute. Core and outposts upgrade as one commit, all hosts, same day.

3. Set an explicit session lifetime. The default session is browser-scoped: close the browser, log in again, every user, all day. One field fixes it (validity on the login flow’s stage; mine is 14 days). It’s UI-made, not blueprint-managed, so onto the rebuild runbook it goes. Users notice this one loudest.

4. Make identities survive a domain move. OIDC apps store the issuer URL and a subject ID. Configure providers with per-provider issuer mode and a hashed user ID as the subject, and the subject survives an issuer-host change. A domain migration then becomes “update the issuer URL in each app” instead of “every OIDC account is an orphan attached to a dead issuer.” Costs nothing at setup. Saves a weekend later.

Break-glass, before you need it

The day SSO fronts everything is the day an SSO outage locks you out of the tools you’d use to fix the SSO. Before that day: a second admin account with a recovery path around the SSO, tested while you can still get in, written into the runbook. Cut the spare key before the door locks.

Milestone: one account, one session, and every service in the lab behind it for one label apiece.

Next: making git push deploy all of this.