Authentik Federation in Practice: Undocumented Behavior in Sources, Groups, and Enrollment

A field report on federating two Authentik instances via OIDC: undocumented behavior in group sync, source enrollment, expression policies and login flows – with concrete patterns that work instead.

Tested with Authentik 2026.8.1. A considerable part of the points described here concerns behavior that is not documented. It may change with any release. Each finding is marked with its status at the end: [documented] or [observed].

Starting point: two Authentik instances are to be federated – one as the identity provider (referred to below as example-idp), one as the service provider. In addition, a Google source runs as a second external login source. Sounds like an afternoon of configuration work; the following points took considerably longer.

The division of roles is unspectacular: the IdP provides an OAuth2/OpenID provider, and the SP consumes it as an OAuth source. There is no bidirectional "connection" between two instances, and OIDC is the easier variant to debug compared to SAML – JSON instead of XML, clearer error messages. The endpoints can be obtained via the discovery URL (https://<idp>/.well-known/openid-configuration); it is fetched only once when the source is saved, with no automatic refresh. [documented]

PKCE belongs on S256, not on Auto or Plain. In an Authentik-to-Authentik setup the other side is known; there is no reason for negotiation or legacy methods.


1. Group synchronization

1.1 The default behavior

On an SSO login via an external OAuth source, Authentik synchronizes all groups passed by that source. They are created locally, marked as source-managed with a flag in the database, and the memberships communicated by the source are adopted.

For OAuth sources this kicks in as soon as a groups claim is present; for SAML sources, as soon as the attribute http://schemas.xmlsoap.org/claims/Group is in the assertion. This has been the case since release 2024.8 and was not so before. [documented]

Important: Authentik's default profile scope mapping already contains a groups claim. If you, as the IdP operator, don't want to transmit any groups at all, the cleanest way to get there is to use a reduced scope mapping without groups – then the sync logic on the SP side never starts.

1.2 The non-obvious side effect

Once Authentik knows a group as source-managed, it also manages the membership in the opposite direction: if an existing membership no longer arrives in the claim at the next login – for instance because the claim was changed or filtered – the user is removed from the group.

If you don't want that, you have to delete the original source-managed group and replace it with a manually created one. The name can stay the same. [observed]

Caution, this leads directly into section 1.5: a manually created group with the same name that the source continues to transmit is exactly the collision described there.

1.3 User property mappings: groups is merged, not replaced

Under Edit OAuth Source → OAuth Attribute Mapping → User Property Mappings, group memberships can be influenced. Counterintuitive, but correct this way round: groups is an attribute of the user properties, not of the group properties.

The behavior of the merge is the real pitfall:

Return value of the mapping Effect
return {"groups": ["A"]} A is added to the groups passed by the provider (union)
return {"groups": []} no effect – the empty list is also only merged
return {"groups": None} the key is discarded, the provider groups are dropped

Replacing the group list is therefore not possible with a single mapping – a key can either be discarded or extended, not first emptied and then filled. [observed]

In theory this can be solved with two mappings that run one after the other: first None, then the assignment. The execution order apparently follows the alphabetical sorting of the mapping names. [observed]

1.4 Direct access to the accumulator

An alternative variant exploits the fact that properties is the running accumulator and sits in the context by reference:

# 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

Advantage over the two-mapping solution: no ordering dependency, one object instead of two. Disadvantage: also undocumented. Anyone using this in production should add it to the test matrix for Authentik upgrades. [observed]

If you want to set a group in addition to the provider groups, you don't need this trick – the union from section 1.3 is enough.

1.5 Group matching mode and the duplicate key error

If a group name exists in both instances, the behavior depends on the source's Group matching mode field:

  • identifier (default): Authentik looks for a group with a matching source identifier, finds none, and attempts an INSERT. Result: duplicate key value violates unique constraint and an HTTP 500 for the user.
  • name_deny: clean flow rejection with a message instead of a database error.
  • name_link: the existing local group is linked.

name_link is the obvious move to make the error go away – and exactly the wrong one. The external user silently ends up in a local group that may carry permissions that were never intended for them. The error disappears; the problem gets bigger.

Recommendation: name_deny, even if group sync is actually switched off. It is the conservative failure direction in case a claim slips through after all. [observed]

1.6 Group property mappings cannot control memberships

The group property mapping section influences how incoming group objects are interpreted – not which user is assigned which group. That is exclusively the job of the groups key in the user property mappings (section 1.3). [observed]


2. Enrollment and user status

2.1 Internal or external: the user write stage decides

Whether a newly created user is internal or external is determined by the user write stage in the enrollment flow. In the bundled default-source-enrollment-write it is External.

If you want to run several sources with different user types, you need two enrollment flows – and, this is the easily overlooked part, actually also two different user write stages. The flow only references the stage; the settings live in the stage itself, not in the flow. Two flows pointing to the same stage are configured identically in this respect. [observed]

2.2 External users cannot reach the user interface

Since 2024.8, users of type external no longer have access to the user and admin interface. [documented]

A practical consequence that goes beyond the application library: external users also cannot reach Settings → Connected Services and therefore cannot link another source themselves. If you want to keep open the manual linking path described in section 3, you have to create the affected users as internal. Bulk conversion in the container via ak change_user_type.

2.3 Why "Create users as inactive" doesn't work

The setting exists, but it is practically useless in a source enrollment flow:

  1. The subsequent login stage cannot take effect for an inactive user. The flow ends for the user with a non-descriptive error. The log shows User is not active, login will not work.
  2. By this point Authentik has already remembered the new user via cookie. On every subsequent visit the user lands straight back in this error without seeing the login screen.
  3. And the real damage: see 2.4.

[observed]

2.4 An aborted flow prevents the source connection from being saved

This is the most expensive point of the entire setup.

If a source enrollment flow is aborted before its end – by an inactive user, a deny stage or a redirect stage – the connection to the external auth provider is not saved in the user account. The user exists (the user write stage ran before that, after all), but without a UserSourceConnection.

The stage that persists this connection is appended dynamically to the enrollment plan by the source flow manager. It doesn't show up in your own stage bindings at all – which is why it isn't obvious that you've just skipped it.

Consequence: if the same user comes in via the source again, Authentik doesn't recognize them and starts another enrollment run, including the "Choose username" prompt. Every attempt creates another account. Activation by an admin changes nothing – what's missing is the connection, not the permission. [observed]

The state can be checked via /api/v3/sources/user_connections/oauth/?user=<id> or, for a logged-in user, under Settings → Connected Services.

2.5 Two patterns that work instead

Instead of working against the flow engine: don't model approval via active/inactive, but either via group/no group (1) or internal/external (2)

  • The user write stage creates the user as active.
  • No deny or redirect stage in the enrollment flow. The flow runs through, the source connection is saved, the login stage works.
  • (1) Access is enforced via policy bindings on the applications. Without a group, the user sees an empty application library.
  • (2) Under System → Brands → External user settings, set a pseudo-application as the default that informs the user about their status ("Your account is still awaiting approval by an administrator").
  • "Approving" then means: (1) assigning a group or (2) switching the user to internal. No second enrollment run, no username prompt, no duplicate.

Trade-off: the user has a valid session before anyone has reviewed them. Without group membership or as an external user, however, this session grants no access to the application library – and a record in the user table is created equally in both variants.

Side effect: this also solves the cosmetic problem of a possible deny stage. Its heading "Access denied" is hard-wired in the flow executor and cannot be changed via the stage configuration – only via custom CSS in the brand. A custom, more specific message gets visually lost underneath it. [observed]


3. Controlling linking

For the case where an external account may be linked only from the logged-in state and must never automatically take over an existing local account, two settings on the source are relevant:

  • User matching mode: Link users on unique identifier – matching happens exclusively via an already existing UserSourceConnection. No matching via email address or username, so an unknown external account cannot take over a local account.
  • Leave the enrollment flow empty – without an enrollment flow, Authentik cannot create a new user. This is the real lever.
  • Keep the authentication flow set, otherwise login won't work after linking has been completed.

The linking itself then happens under Settings → Connected Services. Every enabled source appears there with a connect button.

Two limitations: the user type must be internal (section 2.2). And an unlinked external user who clicks the source button on the login page gets an error message instead of an explanation. If you want clean UX, instead of an empty enrollment flow build a minimal flow with a deny stage and a custom message – functionally identical, easier to understand. [observed]


4. Policies: the pitfalls

4.1 request.user in expression policies is not the session user

The subtlest trap of the entire setup, and it produces no error – just a wrong branch.

In an expression policy, request is a PolicyRequest, not a Django HttpRequest. Its user is set to the pending_user from the flow context during a flow. Combined with Django's semantics of is_authenticated – a property that returns True unconditionally on every real User object and is False only for AnonymousUser – this results in:

return request.user.is_authenticated

This expression means two different things in two different places in the same setup, depending on whether a pending_user currently sits in the context. In an enrollment flow after the user write stage it is always True, even for a brand-new, inactive user who most certainly has no session.

Keeping the three accessors apart is the real insight:

Expression Meaning
request.context.get("pending_user") the user currently being processed in the flow
request.http_request.user the session user (if anonymous: AnonymousUser)
request.user sometimes the one, sometimes the other

Rule of thumb: check the condition that actually causes the problem, not a proxy for it. For an approval check, therefore:

user = request.context.get("pending_user")
return user is not None and not user.is_active

For the question of whether a session exists:

http_request = request.http_request
return http_request is not None and http_request.user.is_authenticated

And: name policies that check different things differently. Here, the name is the real documentation. [observed]

4.2 Re-evaluation on stage bindings

Policies on a stage binding are evaluated by default when the flow is planned. At that point, pending_user and oauth_userinfo don't exist yet. A policy that accesses them then runs into its fallback branch – without an error, without any hint in the UI.

The option for re-evaluation must be enabled on the binding. Recognizable in the log by:

event=f(plan_inst): binding failed re-evaluation
logger=authentik.flows.markers marker=ReevaluateMarker

This message means that the stage was skipped – not that it fired. That is easy to read the wrong way round while debugging. [observed]

4.3 Restricting access to a group in the source system

To limit an external source to users who belong to a particular group in the source system, the following expression policy can be added to the source's enrollment flow – in addition to the if-sso policy of the default-source-enrollment flow:

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

Two prerequisites without which this doesn't work:

  1. In the flow settings, Policy engine mode must be set to ALL, not ANY. Otherwise it's enough for one of the two policies to pass – the restriction to the external source or the group check – and the check has no effect.
  2. Enable re-evaluation on the binding, see 4.2. oauth_userinfo doesn't exist yet when the flow is planned.

[observed]

4.4 On which side should you enforce?

The policy in 4.3 enforces the restriction on the SP side. The alternative is a policy binding on the application in the source Authentik: then an unauthorized user doesn't even get as far as the callback.

Argument for the IdP side: enforcement where the data is authoritative. Argument against: it only works if you control both sides.

Both variants share a gap you should be aware of: federation only revokes the SSO path. An already created local account keeps its password. Anyone removed from the source group loses the login via the source – not access to the system. If you need a real revocation chain, you additionally need a process for deactivating local accounts. Federation cannot do that.


5. Skipping the login screen

If a user comes from an external source and is supposed to be signed in via its SSO anyway, additionally showing the Authentik login screen and clicking the source icon is superfluous. The source login can be addressed directly:

https://<authentik>/source/oauth/login/<source-slug>/

For an unknown user this triggers a source enrollment, and for a known but logged-out user a source login. [observed]

An already logged-in user, however, gets an error page when calling it: "Permission denied: The flow does not apply to the current user", with a callback URL in the address bar. The cause is the Authentication field in the source's authentication flow (usually default-source-authentication), which is set to Require no authentication. Change it to No requirement.

Two remarks on this:

The right flow. Don't touch the default-authentication-flow – that's the flow behind the regular login page. Set to No requirement there, any already logged-in user could rewrite their session to a different account via any login path. The blast radius is out of all proportion. What's affected is the authentication flow configured in the source. Cleaner than changing the bundled flow: duplicate it, change the requirement only in the copy, and configure the copy in the source. Changes to default-* flows can be reset by blueprint updates.

On the security question. Require no authentication protects against unintended session rewriting; the attack class behind it is login CSRF. In this setup that is already covered by the OAuth2 state parameter and S256 PKCE: a smuggled-in callback without a matching state in the session goes nowhere. The realistic remaining case is not an attack but a mix-up on a shared device – and that gets better with the change, not worse: the round trip runs, the external identity is evaluated, and the session ends up with the right user.


6. Little things that cost time

Sources don't appear on the login page automatically. They have to be bound explicitly: Flows and Stages → Flows → default-authentication-flow → Stage Bindings → Edit Stage on default-authentication-identification, then add them to Selected Sources under Source settings. The most common "why can't I see my source" case. [documented]

The generic OAuth source type doesn't come with an icon. Unlike the preconfigured social login types. For an Authentik-to-Authentik coupling, the icon has to be provided yourself, via upload or URL.


Conclusion

Most of the time didn't go into the federation itself, but into three areas where Authentik's behavior deviates from the obvious expectation: the union-style merge behavior for groups, the coupling of the source connection to a complete run of the enrollment flow, and the semantics of request.user in expression policies.

In all three cases the failure mode was silent or misleading – no exception, but a wrong branch, a skipped stage, a duplicate. Anyone debugging here should look at Events → Logs earlier than usual and cross-check the state via the API instead of trusting the UI.