niklas‑stephan.de

Authentik on handtrixxx.com (auth.handtrixxx.com)

Last updated 05/11/2026 · authentik, handtrixxx-com, upgrade

Deployment (verified 2026-10-25)

  • Compose project /data/docker/auth.handtrixxx.com/: authentik-server, authentik-worker, authentik-db (postgres:16-alpine).
  • Image ghcr.io/goauthentik/server:2026.8.5 (upgraded from 2026.5.4 on 2026-10-25).
  • Fronted by Traefik only (the external network named caddy is Traefik’s bridge, 172.19.0.0/16); server port 9000.
  • Worker mounts /var/run/docker.sock → can provision container outposts.
  • .env keys: POSTGRES_DB/USER/PASSWORD, AUTHENTIK_SECRET_KEY, AUTHENTIK_EMAIL__*, AUTHENTIK_LOG_LEVEL, AUTHENTIK_WEB__BASE_URL, PUID/PGID/DOCKER_GID, AUTHENTIK_POSTGRESQL__HOST. No AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS set.
  • Gotcha: the .env had no trailing newline on its last line, so appending a key with >> glued it onto AUTHENTIK_LOG_LEVEL=warning. Append with a leading newline or check tail -c 1 .env first.
  • DB dumps for backup in backups/ (pg_dump -U athuser authdb); latest as of 2026-10-25: authentik_backup_20261025.dump.

Upgrade rules (learned 2026-10-25)

  • authentik’s lifecycle tooling refuses unsupported major-version skips; the docs require stepping through every major.minor series in order, latest patch of each first. Series in use: 2026.2 → 2026.5 → 2026.8.
  • Procedure that worked cleanly: back up the DB, bump both server+worker images to the newest patch of the current series (docker compose up -d, migrations run automatically), then bump to the target series. Healthcheck is ak healthcheck; containers report healthy once migrations finish.
  • Check docs.goauthentik.io/docs/releases/<year>.<minor> for breaking changes before each hop.

2026.8 breaking changes that matter here

  • Forwarded headers only from trusted proxies. AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS defaults include 172.16.0.0/12, and Traefik connects from 172.19.0.7, so nothing to change as long as that env var stays unset. If it is ever set, it replaces the defaults - forgetting Traefik’s range makes authentik treat HTTPS as HTTP (mixed content, endless loading spinner, auth errors).
  • hash_password management command no longer takes a password argument (pipe via stdin: printf '%s' "$PW" | docker compose run --rm server hash_password).
  • WebAuthn stage prevent_duplicate_devices removed (was already default-off in 2026.5.4).
  • Server entrypoint and the proxy outpost were rewritten in Rust; webtop/bookmark forward-auth keep working unchanged but are the thing to health-check after an upgrade.

Forward-auth proxy providers (Traefik pattern, verified 2026-10-25)

  • Working apps (webtop, bookmark, ai, agent) use Traefik forwardauth to http://authentik-server:9000/outpost.goauthentik.io/auth/traefik plus a router for PathPrefix(/outpost.goauthentik.io/) on the same host pointing at http://authentik-server:9000.
  • Gotcha (cause of a stubborn 404): a ProxyProvider only works if it has an **application** bound AND authorization_flow (default-provider-authorization-implicit-consent) + invalidation_flow (default-provider-invalidation-flow). Inserting the provider rows directly in postgres (authentik 2026.x: authentik_core_provider → authentik_providers_oauth2_oauth2provider → authentik_providers_proxy_proxyprovider, joined on provider_ptr_id/oauth2provider_ptr_id) leaves those NULL, and the embedded outpost (Rust /usr/bin/authentik) then answers 404 for that host while other hosts 302. Fix: through the Django ORM (docker exec authentik-server python manage.py shell) set flows, p.set_oauth_defaults(), save, and Application.objects.create(name=..., slug=..., provider=p); the outpost starts resolving immediately (no restart needed).
  • Second cause of the same 404 (found 2026-11-01): the provider must also be in the embedded outpost’s provider list. Provider 42 (pi-agent) had dropped out (list was [13,15,40]) so agent.handtrixxx.com returned the authentik “Not Found” page even with correct flows/app. Fix: Outpost.objects.get(name='authentik Embedded Outpost').providers.add(ProxyProvider.objects.get(pk=42)) in manage.py shell; takes effect in seconds. Check with Outpost.objects.all() -> providers.
  • Check: curl -H "Host: <host>" http://authentik-server:9000/outpost.goauthentik.io/start from a container on the caddy network → 302 (provider found) vs 404 (not found). The authentik image has no curl; use python urllib with a no-redirect handler inside authentik-server.
  • Providers in use: Webtop(13), Linkding(15), LiteLLM(40, now only the forward-auth anchor for ai.handtrixxx.com / OmniRoute; its OIDC SSO is unused), pi-agent(42, app slug pi-agent), OmniRoute (OAuth2, app slug omniroute, since 2026-10-31, sub = username; script /data/maintenance/omniroute-oidc/setup.py).

Native OIDC (OAuth2) apps - no outpost

For apps with their own OIDC support (SnapOtter, Gramps, Arcane), create a plain OAuth2Provider + Application instead of a proxy provider:

  • docker exec authentik-server python manage.py shell -> OAuth2Provider.objects.create(name=..., client_id=..., client_secret=<random generated value>, authorization_flow=Flow.objects.get(slug="default-provider-authorization-implicit-consent"), _redirect_uris=[{"matching_mode": "strict", "url": "<callback>", "redirect_uri_type": "authorization"}]) then Application.objects.create(name=..., slug=..., provider=p).
  • Gotcha: the authorization flow’s name is “Authorize Application” (two exist: implicit-consent and explicit-consent); query it by slug, not name.
  • Scopes/groups: attach the managed mappings - p.property_mappings.add(*ScopeMapping.objects.filter(managed__in=["goauthentik.io/providers/oauth2/scope-openid","...scope-email","...scope-profile"])). The stock profile mapping already exposes a groups claim (user’s authentik groups), so no extra groups scope is needed.
  • **grant_types** MUST be set (ArrayField, default empty): authorization_code, refresh_token, client_credentials, implicit, hybrid, password, urn:ietf:params:oauth:grant-type:device_code. An empty list makes every authorize request fail with Invalid grant_type for provider -> “The request is otherwise malformed” (cost me a login attempt on 2026-10-25). UI-created providers get these filled.
  • **signing_key** MUST be set too: an ORM-created provider with no signing key signs id_tokens with HS256/client_secret and serves an empty JWKS ({}), so strict OIDC clients fail at jwk.Parse: failed to get "kty" hint. Fix: provider.signing_key = CertificateKeyPair.objects.filter(name="authentik Self-signed Certificate").first() (the shared key UI-created providers use). Arcane login verified working 2026-10-25 after both grant_types and signing_key were set; JWKS at https://auth.handtrixxx.com/application/o/arcane/jwks/ shows 1 RSA/RS256 key (kid 177f3b418a771bb79aea9f22be6feb35).
  • Issuer is per provider: https://auth.handtrixxx.com/application/o/<slug>/ (discovery advertises the trailing slash). Arcane accepted the slash-less OIDC_ISSUER_URL (discovery + issuer match passed, verified 2026-10-25 via POST /api/oidc/url).
  • Apps with native OIDC have a config endpoint that reveals the built auth URL - for Arcane POST https://docker.handtrixxx.com/api/oidc/url with {"redirectUri":...}; GET /api/oidc/status shows envConfigured/envForced. Useful for testing SSO without a browser.
  • Arcane (docker.handtrixxx.com, slug arcane) added 2026-10-25 via /data/maintenance/arcane-oidc/setup.py (idempotent, writes OIDC_CLIENT_ID/OIDC_CLIENT_SECRET to /data/docker/docker.handtrixxx.com/.env (mode 600), never prints the value). Niklas is in group authentik Admins - useful group claim for admin role mappings.

Passkey (WebAuthn) login (configured 2026-10-25)

  • Passkey works as an optional first factor on the normal SSO login page (/if/flow/default-authentication-flow/); users without an enrolled passkey just use password.
  • Mechanism: IdentificationStage.webauthn_stage points to an AuthenticatorValidateStage named default-authentication-webauthn (device_classes=['webauthn'], not_configured_action=skip, webauthn_user_verification=preferred). The identification stage itself generates the challenge (no need to attach the validate stage to the flow). Per docs: Identification stage + WebAuthn-capable Authenticator Validation stage.
  • Enrollment: auth.handtrixxx.com -> user settings (profile) -> Authenticators tab -> WebAuthn (setup flow default-authenticator-webauthn-setup exists).
  • The existing MFA stage default-authentication-mfa-validation (static,totp,webauthn,duo,sms,email; not_configured_action=skip) runs after password as second factor; passkey-at-identification skips the password step entirely.
  • Caveat from docs: passkey login skips MFA validation - if a second factor is wanted even with passkey, adjust the policy on the MFA stage.

Follow-ups / requirements

  • Base URL: done 2026-10-25 - AUTHENTIK_WEB__BASE_URL=https://auth.handtrixxx.com in .env, referenced in compose.yml for both server and worker. Stored per-tenant in postgres (column base_url on authentik_tenants_tenant); verify with docker exec authentik-db psql -U athuser -d authdb -c 'select base_url from authentik_tenants_tenant;'. Required from 2026.11.
  • OIDC endpoints in use by consumers: /application/o/<app-slug>/, /application/o/authorize/, /application/o/token/, /application/o/userinfo/, discovery at /application/o/<slug>/.well-known/openid-configuration. Consumers: LiteLLM-UI (ai.handtrixxx.com, generic endpoints), snapotter (OIDC_ISSUER_URL=.../application/o/snapotter/).
  • Key-bound ID tokens and OAuth token exchange are opt-in per provider.

Source: Wiki.js