Getestet mit Authentik
2026.8.1. Ein erheblicher Teil der hier beschriebenen Punkte betrifft Verhalten, das nicht dokumentiert ist. Es kann sich mit jedem Release ändern. Jedes Finding ist am Ende mit seinem Status markiert: [dokumentiert] oder [beobachtet].
Ausgangslage: Zwei Authentik-Instanzen sollen föderiert werden – eine als Identity Provider (im Folgenden example-idp), eine als Service Provider. Zusätzlich läuft eine Google-Source als zweite externe Anmeldequelle. Klingt nach einem Nachmittag Konfigurationsarbeit; die folgenden Punkte haben deutlich länger gedauert.
Die Rollenteilung dabei ist unspektakulär: Der IdP stellt einen OAuth2/OpenID-Provider bereit, der SP konsumiert ihn als OAuth Source. Eine bidirektionale „Verbindung“ zweier Instanzen gibt es nicht, und OIDC ist gegenüber SAML die einfacher zu debuggende Variante – JSON statt XML, klarere Fehlermeldungen. Die Endpunkte lassen sich über die Discovery-URL (https://<idp>/.well-known/openid-configuration) beziehen; sie wird nur beim Speichern der Source einmal gefetcht, ohne automatisches Refresh. [dokumentiert]
PKCE gehört auf S256, nicht auf Auto oder Plain. Bei einem Authentik-zu-Authentik-Setup ist die Gegenseite bekannt, es gibt keinen Grund für Verhandlung oder Legacy-Verfahren.
1. Gruppen-Synchronisation
1.1 Das Default-Verhalten
Bei einem SSO-Login über eine externe OAuth Source synchronisiert Authentik alle von dieser Source übergebenen Gruppen. Sie werden lokal angelegt, mit einem Flag in der Datenbank als source-managed markiert, und die von der Source mitgeteilten Mitgliedschaften werden übernommen.
Bei OAuth-Sources greift das, sobald ein groups-Claim vorhanden ist; bei SAML-Sources, sobald das Attribut http://schemas.xmlsoap.org/claims/Group in der Assertion steht. Das ist seit Release 2024.8 der Fall und war vorher nicht so. [dokumentiert]
Wichtig: Das Standard-profile-Scope-Mapping von Authentik enthält bereits einen groups-Claim. Wer als IdP-Betreiber gar keine Gruppen übertragen will, kommt am saubersten dorthin, indem er ein reduziertes Scope Mapping ohne groups verwendet – dann läuft die Sync-Logik auf der SP-Seite überhaupt nicht an.
1.2 Der nicht offensichtliche Seiteneffekt
Sobald Authentik eine Gruppe als source-managed kennt, verwaltet es die Mitgliedschaft auch in der Gegenrichtung: Kommt eine bisherige Mitgliedschaft beim nächsten Login nicht mehr im Claim an – etwa weil der Claim verändert oder gefiltert wurde –, wird der Nutzer aus der Gruppe entfernt.
Wer das nicht will, muss die ursprüngliche source-managed Gruppe löschen und durch eine manuell angelegte ersetzen. Der Name kann derselbe bleiben. [beobachtet]
Achtung, das führt direkt in Abschnitt 1.5: Eine manuell angelegte Gruppe mit demselben Namen, den die Source weiterhin übermittelt, ist genau die Kollision, die dort beschrieben wird.
1.3 User Property Mappings: groups wird gemergt, nicht ersetzt
Unter Edit OAuth Source → OAuth Attribute Mapping → User Property Mappings lassen sich Gruppenmitgliedschaften beeinflussen. Kontraintuitiv, aber so herum richtig: groups ist ein Attribut der User-Properties, nicht der Group-Properties.
Das Verhalten des Merge ist die eigentliche Fallgrube:
| Rückgabewert des Mappings | Effekt |
|---|---|
return {"groups": ["A"]} |
A wird zu den vom Provider übergebenen Gruppen hinzugefügt (Union) |
return {"groups": []} |
wirkungslos – die leere Liste wird ebenfalls nur gemergt |
return {"groups": None} |
der Key wird verworfen, die Provider-Gruppen entfallen |
Ein Ersetzen der Gruppenliste ist mit einem einzelnen Mapping also nicht möglich – ein Key kann entweder verworfen oder ergänzt werden, nicht erst geleert und dann gefüllt. [beobachtet]
Theoretisch lässt sich das über zwei Mappings lösen, die nacheinander laufen: erst None, dann die Zuweisung. Die Ausführungsreihenfolge folgt dabei offenbar der alphabetischen Sortierung der Mapping-Bezeichner. [beobachtet]
1.4 Direkter Zugriff auf den Akkumulator
Eine alternative Variante nutzt aus, dass properties der laufende Akkumulator ist und per Referenz im Kontext liegt:
# Verwirft die externen Gruppen komplett und setzt genau eine hartcodierte Gruppe.
# properties ist der Live-Akkumulator (per Referenz im Kontext).
# return None überspringt den unionierenden Merge, wodurch die direkte
# Zuweisung nicht wieder überschrieben wird.
properties["groups"] = ["Operations"]
return None
Vorteil gegenüber der Zwei-Mapping-Lösung: keine Reihenfolgenabhängigkeit, ein Objekt statt zwei. Nachteil: ebenfalls undokumentiert. Wer das produktiv einsetzt, sollte es in die Testmatrix für Authentik-Upgrades aufnehmen. [beobachtet]
Wer eine Gruppe zusätzlich zu den Provider-Gruppen setzen will, braucht diesen Trick nicht – dafür genügt die Union aus Abschnitt 1.3.
1.5 Group matching mode und der duplicate key-Fehler
Existiert ein Gruppenname in beiden Instanzen, hängt das Verhalten am Feld Group matching mode der Source:
identifier(Default): Authentik sucht nach einer Gruppe mit passendem Source-Identifier, findet keine, und versucht ein INSERT. Ergebnis:duplicate key value violates unique constraintund ein HTTP 500 beim Nutzer.name_deny: saubere Flow-Ablehnung mit Meldung statt Datenbankfehler.name_link: die bestehende lokale Gruppe wird verknüpft.
name_link ist der naheliegende Griff, um den Fehler wegzubekommen – und genau der falsche. Der externe Nutzer landet damit stillschweigend in einer lokalen Gruppe, die möglicherweise Rechte trägt, die für ihn nie gedacht waren. Der Fehler verschwindet, das Problem wird größer.
Empfehlung: name_deny, auch dann, wenn der Gruppen-Sync eigentlich abgeschaltet ist. Es ist die konservative Fehlerrichtung für den Fall, dass doch einmal ein Claim durchrutscht. [beobachtet]
1.6 Group Property Mappings können keine Mitgliedschaften steuern
Der Bereich Group Property Mapping beeinflusst, wie eingehende Gruppen-Objekte interpretiert werden – nicht, welchem Nutzer welche Gruppe zugeordnet wird. Dafür ist ausschließlich der groups-Key in den User Property Mappings zuständig (Abschnitt 1.3). [beobachtet]
2. Enrollment und Nutzerstatus
2.1 Intern oder extern: die User Write Stage entscheidet
Ob ein neu angelegter Nutzer internal oder external ist, legt die User Write Stage im Enrollment-Flow fest. Im mitgelieferten default-source-enrollment-write ist es External.
Wer mehrere Sources mit unterschiedlichem Nutzertyp betreiben will, braucht zwei Enrollment-Flows – und, das ist der leicht zu übersehende Teil, tatsächlich auch zwei verschiedene User-Write-Stages. Der Flow verweist nur auf die Stage; die Einstellungen liegen in der Stage selbst, nicht im Flow. Zwei Flows, die auf dieselbe Stage zeigen, sind in diesem Punkt identisch konfiguriert. [beobachtet]
2.2 Externe Nutzer erreichen das User Interface nicht
Seit 2024.8 haben Nutzer vom Typ external keinen Zugriff mehr auf das User- und Admin-Interface. [dokumentiert]
Praktische Konsequenz, die über die Application Library hinausgeht: Externe Nutzer erreichen auch Settings → Connected Services nicht und können deshalb keine weitere Source selbst verknüpfen. Wer den in Abschnitt 3 beschriebenen Weg der manuellen Verknüpfung offenhalten will, muss die betroffenen Nutzer als internal anlegen. Bulk-Umstellung im Container per ak change_user_type.
2.3 Warum „Create users as inactive“ nicht funktioniert
Die Einstellung existiert, taugt in einem Source-Enrollment-Flow aber praktisch nicht:
- Die anschließende Login-Stage kann bei einem inaktiven Nutzer nicht greifen. Der Flow endet für den Nutzer mit einem nicht-deskriptiven Fehler. Im Log erscheint
User is not active, login will not work. - Authentik hat den neuen Nutzer zu diesem Zeitpunkt bereits per Cookie gemerkt. Der Nutzer landet bei jedem weiteren Aufruf direkt wieder in diesem Fehler, ohne den Login-Screen zu sehen.
- Und der eigentliche Schaden: siehe 2.4.
[beobachtet]
2.4 Ein Flow-Abbruch verhindert die Speicherung der Source-Verbindung
Das ist der teuerste Punkt des ganzen Setups.
Wird ein Source-Enrollment-Flow vor seinem Ende abgebrochen – durch einen inaktiven Nutzer, eine Deny-Stage oder eine Redirect-Stage –, dann wird die Verbindung zum externen Auth-Provider nicht im Nutzerkonto gespeichert. Der Nutzer existiert (die User-Write-Stage lief ja vorher), aber ohne UserSourceConnection.
Der Stage, der diese Verbindung persistiert, wird vom Source Flow Manager dynamisch an den Enrollment-Plan angehängt. Er taucht in den eigenen Stage Bindings gar nicht auf – deshalb ist nicht offensichtlich, dass man ihn gerade übersprungen hat.
Folge: Kommt derselbe Nutzer erneut über die Source, erkennt Authentik ihn nicht wieder und startet einen weiteren Enrollment-Durchlauf inklusive „Choose username“-Prompt. Bei jedem Versuch entsteht ein weiteres Konto. Die Aktivierung durch einen Admin ändert daran nichts – es fehlt die Verbindung, nicht die Berechtigung. [beobachtet]
Prüfen lässt sich der Zustand über /api/v3/sources/user_connections/oauth/?user=<id> oder, für einen eingeloggten Nutzer, unter Settings → Connected Services.
2.5 Zwei Muster, die stattdessen funktionieren
Statt gegen die Flow-Engine zu arbeiten: Freischaltung nicht über aktiv/inaktiv abbilden, sondern entweder über Gruppe/keine Gruppe (1) oder intern/extern (2)
- Die User Write Stage legt den Nutzer aktiv an.
- Keine Deny- oder Redirect-Stage im Enrollment-Flow. Der Flow läuft durch, die Source-Verbindung wird gespeichert, die Login-Stage funktioniert.
- (1) Der Zugriff wird per Policy Binding an den Applications durchgesetzt. Ohne Gruppe sieht der Nutzer eine leere Application Library.
- (2) Unter System → Brands → External user settings eine Pseudo-Applikation als Default hinterlegen, die den Nutzer über seinen Status informiert („Die Freischaltung deines Kontos durch einen Administrator steht noch aus“).
- „Freischalten“ heißt dann: (1) Gruppe zuweisen bzw. (2) Nutzer auf
internumstellen. Kein zweiter Enrollment-Durchlauf, kein Username-Prompt, keine Dublette.
Trade-off: Der Nutzer hat eine gültige Session, bevor ihn jemand geprüft hat. Ohne Gruppenzugehörigkeit bzw. als externer Nutzer bedeutet diese Session aber keinen Zugriff auf die Application Library – und ein Datensatz in der User-Tabelle entsteht bei beiden Varianten gleichermaßen.
Nebeneffekt: Das löst auch das kosmetische Problem einer möglichen Deny-Stage. Deren Überschrift „Zugriff verweigert“ ist im Flow-Executor verdrahtet und über die Stage-Konfiguration nicht änderbar – nur per Custom CSS im Brand. Eine eigene, konkretere Meldung geht darunter optisch unter. [beobachtet]
3. Verknüpfung kontrollieren
Für den Fall, dass ein externer Account nur aus dem eingeloggten Zustand heraus verknüpft werden darf und niemals automatisch ein bestehendes lokales Konto übernehmen soll, sind an der Source zwei Einstellungen relevant:
User matching mode: Link users on unique identifier– gematcht wird ausschließlich über eine bereits bestehendeUserSourceConnection. Kein Matching über E-Mail-Adresse oder Username, ein unbekannter externer Account kann also kein lokales Konto übernehmen.- Enrollment Flow leer lassen – ohne Enrollment-Flow kann Authentik keinen neuen Nutzer anlegen. Das ist der eigentliche Hebel.
- Authentication Flow gesetzt lassen, sonst funktioniert der Login nach erfolgter Verknüpfung nicht.
Die Verknüpfung selbst passiert dann unter Settings → Connected Services. Jede aktivierte Source erscheint dort mit einem Connect-Button.
Zwei Einschränkungen: Nutzertyp muss internal sein (Abschnitt 2.2). Und ein unverknüpfter externer Nutzer, der den Source-Button auf der Login-Seite anklickt, bekommt eine Fehlermeldung statt einer Erklärung. Wer das UX-sauber will, baut statt eines leeren Enrollment-Flows einen minimalen Flow mit einer Deny-Stage und eigener Message – funktional identisch, verständlicher. [beobachtet]
4. Policies: die Stolperfallen
4.1 request.user ist in Expression Policies nicht der Session-Nutzer
Die subtilste Falle des ganzen Setups, und sie erzeugt keinen Fehler – nur einen falschen Zweig.
In einer Expression Policy ist request ein PolicyRequest, kein Django-HttpRequest. Dessen user wird im Verlauf eines Flows auf den pending_user aus dem Flow-Kontext gesetzt. Zusammen mit der Django-Semantik von is_authenticated – eine Property, die auf jedem echten User-Objekt unconditional True liefert und nur bei AnonymousUser False ist – ergibt das:
return request.user.is_authenticated
Dieser Ausdruck bedeutet an zwei verschiedenen Stellen im selben Setup zwei verschiedene Dinge, je nachdem ob gerade ein pending_user im Kontext liegt. In einem Enrollment-Flow nach der User-Write-Stage ist er immer True, auch für einen brandneuen, inaktiven Nutzer, der ganz sicher keine Session hat.
Die drei Zugriffe auseinanderzuhalten ist die eigentliche Erkenntnis:
| Ausdruck | Bedeutung |
|---|---|
request.context.get("pending_user") |
der im Flow gerade bearbeitete Nutzer |
request.http_request.user |
der Session-Nutzer (bei anonym: AnonymousUser) |
request.user |
mal das eine, mal das andere |
Faustregel: Prüfe die Bedingung, die tatsächlich das Problem verursacht, nicht einen Proxy dafür. Für eine Freischaltprüfung also:
user = request.context.get("pending_user")
return user is not None and not user.is_active
Für die Frage, ob eine Session besteht:
http_request = request.http_request
return http_request is not None and http_request.user.is_authenticated
Und: Policies, die verschiedene Dinge prüfen, unterschiedlich benennen. Der Name ist hier die eigentliche Dokumentation. [beobachtet]
4.2 Re-Evaluation an Stage Bindings
Policies an einem Stage Binding werden standardmäßig ausgewertet, wenn der Flow geplant wird. Zu diesem Zeitpunkt existieren pending_user und oauth_userinfo noch nicht. Eine Policy, die darauf zugreift, läuft dann in ihren Fallback-Zweig – ohne Fehler, ohne Hinweis im UI.
Die Option zum erneuten Auswerten muss am Binding aktiv sein. Erkennbar im Log an:
event=f(plan_inst): binding failed re-evaluation
logger=authentik.flows.markers marker=ReevaluateMarker
Diese Meldung bedeutet, dass die Stage übersprungen wurde – nicht, dass sie ausgelöst hat. Das ist beim Debuggen leicht verkehrt herum zu lesen. [beobachtet]
4.3 Zugriff auf eine Gruppe im Quellsystem beschränken
Um eine externe Source auf Nutzer zu begrenzen, die im Quellsystem einer bestimmten Gruppe angehören, kann folgende Expression Policy im Enrollment-Flow der Source hinterlegt werden – zusätzlich zur if-sso-Policy des default-source-enrollment-Flows:
SOURCE_SLUG = "example-idp"
SOURCE_GROUP = "Operations"
# Die Prüfung nur auf SOURCE_SLUG anwenden. Der Flow wird von mehreren Sources
# geteilt; für alle anderen passiert die Policy bedingungslos, damit deren
# Enrollment nicht von dieser Gruppenprüfung blockiert wird.
source = request.context.get("source")
if not source or source.slug != SOURCE_SLUG:
return True
userinfo = request.context.get("oauth_userinfo")
if not isinstance(userinfo, dict):
return False
raw_groups = userinfo.get("groups")
# Fall 1: einzelner String
if isinstance(raw_groups, str):
return raw_groups == SOURCE_GROUP
# Fall 2: Liste / Set / Tuple
if isinstance(raw_groups, (list, set, tuple)):
for item in raw_groups:
# Liste von Strings: ["Operations", "Admin"]
if isinstance(item, str) and item == SOURCE_GROUP:
return True
# Liste von Objekten: [{"name": "Operations"}, ...]
if isinstance(item, dict) and item.get("name") == SOURCE_GROUP:
return True
ak_message("Zugriff verweigert: erforderliche Provider-Gruppe fehlt.")
return False
Zwei Voraussetzungen, ohne die das nicht funktioniert:
- In den Flow-Einstellungen muss Policy engine mode auf
ALLstehen, nicht aufANY. Sonst genügt es, wenn eine der beiden Policies passiert – die Beschränkung auf die externe Source oder die Gruppenprüfung –, und die Prüfung ist wirkungslos. - Re-Evaluation am Binding aktivieren, siehe 4.2.
oauth_userinfoexistiert beim Planen des Flows noch nicht.
[beobachtet]
4.4 Auf welcher Seite durchsetzen?
Die Policy in 4.3 setzt die Beschränkung SP-seitig durch. Die Alternative ist ein Policy Binding an der Application im Quell-Authentik: Dann kommt ein Nicht-Berechtigter gar nicht erst bis zum Callback.
Argument für die IdP-Seite: Durchsetzung dort, wo die Daten autoritativ sind. Argument dagegen: funktioniert nur, wenn man beide Seiten kontrolliert.
Beide Varianten teilen eine Lücke, die man kennen sollte: Föderation entzieht nur den SSO-Pfad. Ein bereits angelegtes lokales Konto behält sein Passwort. Wer aus der Quellgruppe entfernt wird, verliert den Login über die Source – nicht den Zugang zum System. Wer eine echte Widerrufskette braucht, braucht zusätzlich einen Prozess zum Deaktivieren lokaler Konten. Das kann Föderation nicht leisten.
5. Login-Screen überspringen
Kommt ein Nutzer von einer externen Source und soll ohnehin über deren SSO angemeldet werden, ist die zusätzliche Anzeige des Authentik-Login-Screens samt Klick auf das Source-Symbol überflüssig. Die Source-Anmeldung lässt sich direkt adressieren:
https://<authentik>/source/oauth/login/<source-slug>/
Das triggert bei einem unbekannten Nutzer ein Source-Enrollment und bei einem bekannten, aber abgemeldeten Nutzer einen Source-Login. [beobachtet]
Ein bereits angemeldeter Nutzer bekommt beim Aufruf allerdings eine Fehlerseite: „Erlaubnis verweigert: Der Flow gilt nicht für den aktuellen Nutzer“, mit einer Callback-URL in der Adresszeile. Ursache ist das Feld Authentication im Authentication-Flow der Source (üblicherweise default-source-authentication), das auf Require no authentication steht. Umstellen auf No requirement.
Zwei Anmerkungen dazu:
Der richtige Flow. Nicht am default-authentication-flow drehen – das ist der Flow hinter der regulären Login-Seite. Dort auf No requirement gestellt, könnte jeder bereits angemeldete Nutzer über jeden Anmeldeweg seine Session auf ein anderes Konto umschreiben. Der Blast Radius steht in keinem Verhältnis. Betroffen ist der in der Source hinterlegte Authentication Flow. Sauberer als die Änderung am mitgelieferten Flow: ihn duplizieren, das Requirement nur in der Kopie ändern und die Kopie in der Source hinterlegen. Änderungen an default-*-Flows können bei Blueprint-Updates zurückgesetzt werden.
Zur Sicherheitsfrage. Require no authentication schützt vor unbeabsichtigtem Session-Umschreiben, die Angriffsklasse dahinter ist Login-CSRF. In diesem Setup wird das bereits durch den OAuth2-State-Parameter und S256-PKCE abgedeckt: Ein untergeschobener Callback ohne passenden State in der Session läuft ins Leere. Der realistische Restfall ist kein Angriff, sondern Verwechslung auf einem geteilten Gerät – und der wird durch die Umstellung besser, nicht schlechter: Der Roundtrip läuft, die externe Identität wird ausgewertet, die Session landet beim richtigen Nutzer.
6. Kleinigkeiten, die Zeit kosten
Sources erscheinen nicht automatisch auf der Login-Seite. Sie müssen explizit gebunden werden: Flows and Stages → Flows → default-authentication-flow → Stage Bindings → Edit Stage beim default-authentication-identification, dann unter Source settings zu Selected Sources hinzufügen. Der häufigste „warum sehe ich meine Source nicht"-Fall. [dokumentiert]
Der generische OAuth-Source-Typ bringt kein Icon mit. Anders als die vorkonfigurierten Social-Login-Typen. Bei einer Authentik-zu-Authentik-Kopplung muss das Icon selbst hinterlegt werden, per Upload oder URL.
Fazit
Der Großteil der Zeit ging nicht in die Föderation selbst, sondern in drei Bereiche, in denen Authentiks Verhalten von der naheliegenden Erwartung abweicht: das unionierende Merge-Verhalten bei groups, die Kopplung der Source-Verbindung an den vollständigen Durchlauf des Enrollment-Flows, und die Semantik von request.user in Expression Policies.
In allen drei Fällen war das Fehlerbild stumm oder irreführend – keine Exception, sondern ein falscher Zweig, eine übersprungene Stage, eine Dublette. Wer hier debuggt, sollte früher als gewohnt in Events → Logs schauen und den Zustand über die API gegenprüfen, statt dem UI zu glauben.