Mapping identity-provider groups to downpipes roles
Group-to-role mapping lets an account drive downpipes roles from the groups its identity provider already manages, instead of granting a role to each person by email. It is optional and additive: a mapping entry affects a caller only when their verified sign-in actually carried a matching group, so an account that never configures it, or whose provider sends no groups, behaves exactly as the per-email role table alone.
This page is the canonical home for the mapping semantics, written for a self-hoster who needs to know precisely how a group becomes a role and where the honest limits sit. It covers how groups are extracted from a verified token per provider, how a group-mapped role is combined with an explicit per-email grant to give the effective role, and why a group can never confer owner. Wiring a connection so it emits groups in the first place lives on the connect pages, linked at the end.
The reason to map groups rather than people is that your directory becomes the single place you manage access: move someone into the ops group in your provider, and their downpipes role follows on their next sign-in, with no second grant to maintain here. If you prefer, you can leave the groups claim blank entirely for sign-in only and assign every role per person.
How a group becomes a role
A role mapping is one stored entry per group, holding the role that group confers. When a caller signs in, the engine reads the group list their verified token carried, looks each group up in the mapping, and folds the matches into the caller’s effective role alongside any explicit per-email grant. Nothing about the mapping decides whether a request is allowed on its own; it only contributes a role, and the role is then checked against the capability map the same way a per-person grant is.
The mapping is stored as a small table in your account’s scheduler Durable Object, one row per mapped group, and it is read in full on each request that needs to resolve a role. Reading the mapping is not a write, so any authenticated role may read it; the two writes are gated separately, covered below.
Extracting groups from the verified token
The engine reads membership from a verified token using two configured claim names, in a fixed order. The connection’s roles claim is preferred when set, then the groups claim. Either may be absent, which is simply no groups and keeps mapping additive.
The exact extraction, from extractGroups in the OIDC flow engine, follows four rules.
Prefer the roles claim, then the groups claim
If the connection has a roles claim configured (for example Entra App Roles in the
rolesclaim, or Keycloak inrealm_access.roles), that is read first. If it resolves to a value the engine uses it; otherwise it falls back to the groups claim. A dotted name likerealm_access.rolesis walked as a path into the token; a name with no dots is a single top-level key.Apply a claim namespace prefix where configured
Auth0 emits custom claims under a namespace, so the connection can carry a claim namespace. When it does, the namespace is treated as a literal key prefix and the lookup reads the single key
namespace + claimName. Because that key is often a URL containing dots, it is never split on the dot, unlike a bare dotted path.Coerce a single bare-string membership to a one-element array
Some providers emit a one-group membership as a bare string rather than an array. JumpCloud’s
memberOfis the named case: a one-group user gets a string, a multi-group user gets an array. A bare non-empty string is coerced to a one-element array so a one-group user is not silently dropped to no groups, which would be a real under-grant.Bound the list before it is trusted
The resulting list is normalised the same way the Access path bounds its groups claim: array entries only, each trimmed, empty or over-long or control-character-bearing entries dropped, deduplicated with first occurrence kept, and capped at 200. A single group name is bounded to 256 characters. Anything that is not an array of strings yields an empty list.
The 200-group cap and the 256-character name bound are the same values the Cloudflare Access caller path enforces, so a group list reaches role resolution already trusted and bounded whichever way the caller signed in.
The effective role: strongest wins, explicit grant breaks ties
A caller can have both an explicit per-email role grant and one or more group-mapped roles. The engine resolves these into one effective role with a single rule, in resolveRole: take the strongest of the explicit email grant (or the viewer default when there is no grant) and the highest group-mapped role, and an explicit email grant wins a tie because it is the more specific, named authority.
The honest basis of the role is reported back so the console can explain it without guessing. The source reads group only when a group strictly out-ranks the email grant, email when the explicit grant is the winner (including a tie), and default when neither lifted the caller above viewer.
| Explicit email grant | Highest group-mapped role | Effective role | Reported basis |
|---|---|---|---|
| none (viewer default) | operator |
operator |
group |
viewer |
approver |
approver |
group |
operator |
operator |
operator |
email (tie to the named grant) |
approver |
operator |
approver |
|
access-admin |
none | access-admin |
|
| none | none | viewer |
default |
An expired time-boxed email grant reads as viewer, so a group mapping can still lift a caller whose direct grant has lapsed. Because the rule only ever takes the higher of the two inputs, adding a group mapping can lift a caller but never demotes one below the role their explicit grant already gives.
A group mapped to a custom role
The strongest-wins rule above resolves the built-in axis. A mapping that names a custom role instead does not compete on that axis at all: it pins its own built-in contribution to the viewer floor and adds the custom role’s capability set on top of whatever built-in the caller resolves to. So the two combine rather than one beating the other, and a caller covered by both a group mapping to operator and a custom role holds the operator set plus the custom role’s set. A caller naming more than one custom role, across an email grant and a group mapping, holds the union of all of them, and the console shows the first as the primary skin and landing.
Three consequences follow, and each of them is the safe direction. A custom role is additive, so it never removes a capability a built-in grant already conferred. A mapping naming a custom role that has since been deleted is ignored, dropping the holder to their built-in floor rather than failing open. And the owner-reserved bar is re-applied when the set is read, so a group can no more reach keys.ceremony through a custom role than through a built-in one.
Composing the role in the first place, and the rule that decides which custom roles you are offered when you set the mapping, are on custom roles.
A group-mapped role is still a role, with that role's capabilities
Resolving a group to access-admin confers exactly the access-admin capability set, including the access.policy capability that allows managing roles and the group mapping itself. That is by design, the same as granting access-admin to a person by email. The one role a group can never reach is owner, and the two owner-reserved capabilities behind it stay out of reach for any group. See roles and capabilities for the full role-by-capability table.
A group can never confer owner
A group may map to any role except owner. So a group can confer viewer, operator, restore-operator, approver or access-admin, or a custom role your account has composed, but owner stays an explicit, named, per-person grant, because the break-glass owner is a specific person and not a directory group. This bar is enforced in two independent places.
At the write boundary, setting a group’s role to owner is rejected with a precise reason, so an owner mapping cannot be stored at all. At resolution, the engine re-applies the cap: if a stored mapping somehow held owner, it is clamped down to approver as a fallback value, not because approver is the highest role a group can reach. The clamp is not a rank ceiling: access-admin, which outranks approver, remains fully reachable by a group mapping; the clamp only ever catches a stored owner value and passes every other role through unchanged. Keeping owner off the group axis also keeps the last-owner guard coherent, because that guard counts only explicit owner grants and is never affected by groups.
No group mapping grants keys.ceremony or posture.riskaccept
The two owner-reserved capabilities, the key ceremony and accepting a posture risk, are held only by the owner built-in role. Since no group-reachable role holds them, no group confers them, and the owner clamp means a tampered owner mapping resolves to approver rather than owner. If a caller can run the key ceremony, that caller holds an explicit owner grant, never a group.
The honest limits
A handful of limits are deliberate and worth stating plainly, because each one is a place where an over-claim would be wrong.
Groups are capped at 200. The engine carries at most 200 groups from one sign-in, deduplicated and bounded, and a single group name is held to 256 characters. A directory with more than 200 groups still works; only the first 200 distinct, valid group names from a given token are carried into resolution.
Groups are never carried in the engine session cookie. For the engine’s own native OIDC, OAuth2 and SAML sessions, the verified groups are written to a per-subject server-side snapshot at sign-in and re-read on every request, so a group change takes effect on the next request rather than waiting out the session lifetime, and the cookie itself stays free of group data. On the Cloudflare Access path, the signed Access token carries the groups on each request, so they are likewise re-read per request and never persisted in a downpipes cookie.
An Entra membership beyond 200 groups fails closed. When a user belongs to more groups than Entra will inline, Entra omits the groups claim and signals the overflow with distributed-claim markers instead. The engine does not treat that absence as no groups, because doing so would silently drop a group-elevated user. Instead, when a membership claim is configured and those overflow markers are present, the sign-in is refused with a reason pointing you at App Roles or a tighter groups filter, so authorisation is never quietly downgraded. The Entra preset maps authorisation through App Roles in the roles claim precisely to avoid this case.
Who can manage group mappings
Two writes change the mapping: setting a group’s role, and deleting a group’s mapping. Both gate on the access.policy capability, which the owner and the access-admin roles hold, and both are step-up gated, so a cookie-borne session is asked for a fresh passkey assertion before either write lands. The delete is gated as well as the set, because removing a mapping is an identity mutation of the same consequence. Reading the mapping is ungated beyond authentication, since it is the account’s own directory data and not a secret. The engine re-resolves the caller’s role from the forwarded email and groups and re-checks access.policy at the write boundary, so a group-conferred access-admin can manage the mapping end to end, and the owner cap still means none of this can ever grant owner.
This is a different actor from connection management. Adding, editing, deleting, testing or enabling a native identity-provider connection gates on keys.ceremony, which is owner-exclusive. A group can confer access.policy but never keys.ceremony, so a group-conferred access-admin can manage which group maps to which role, but cannot add a hostile connection and self-escalate through it. Managing mappings and managing connections are deliberately held by different authorities.
Which providers emit groups in the token
Whether group mapping is even available depends on the provider sending membership in the token the engine verifies. downpipes ships presets for 8 named providers plus generic OIDC and OAuth2. The table below states, from the preset registry, the claim each one reads by default and whether it emits groups in the token without extra configuration.
| Provider | Claim the preset reads | Emits groups in the token? |
|---|---|---|
| Microsoft Entra ID | roles (App Roles) |
Only after you assign App Roles |
| Okta | groups |
Only after you add a groups claim on a custom authorisation server |
| Google Workspace | none | No. Google does not put group membership in the id_token, so this preset signs people in and you assign roles per person |
| Keycloak | realm_access.roles, then groups |
Only after you turn on the Add to ID token mappers |
| JumpCloud | memberOf |
Only after you map the memberOf group attribute |
| Auth0 | namespaced roles |
Only after a Post-Login Action sets the namespaced roles claim |
| GitLab | groups_direct |
Direct memberships only, in the id_token |
| GitHub | team and org membership from the API | Yes, with the read:org scope; GitHub is OAuth2 with no id_token, so membership comes from the API |
| Generic OIDC | the claim name you set | Only if you name a claim your provider emits |
| Generic OAuth2 | provider-specific | Only where the provider exposes membership |
For Google in particular, do not assume a token group can be mapped: the id_token carries no groups, so role mapping from the token is not available there and roles are per person until directory sync is added.
The exact string the engine receives is the string you map. Entra by default sends group object IDs rather than display names; Okta and Keycloak send display names; GitHub sends membership in the form your org and team produce. Use the precise value the engine receives as the group name in the mapping, which you can confirm from the verified identity after signing in.
Reading the resolution code precisely
The combine is resolveRole in the scheduler Durable Object. It computes emailRole from the per-email entry (viewer when absent or expired), groupRole as the highest group-mapped role with owner already barred, and returns the group basis only when ROLE_RANK[groupRole] strictly exceeds ROLE_RANK[emailRole]; otherwise the explicit grant wins when it lifted the caller above viewer, and failing both the result is the viewer default. The rank ordering used for the comparison runs viewer, operator, restore-operator, approver, access-admin, owner, and it is used only for resolution and the cap, never as the per-route authority check.
The owner cap appears twice. At write, setGroupRole throws when the target role is owner. At resolution, capGroupRole maps an owner value down to approver before it can win, so a planted owner mapping resolves to approver and never owner. A group mapping that names a custom role pins the built-in role to the viewer floor and confers the custom role’s capability set instead, resolved by name from the account’s own custom-role table, with the owner-reserved bar re-applied when that set is read.
Group extraction is extractGroups in the OIDC flow engine, and the overflow signal is the presence of the distributed-claim markers while a membership claim is configured, which returns an overflow result that the login orchestrator turns into a refusal rather than an empty group list.
Related
- Roles and capabilities is the authoritative role-by-capability table, including why no group reaches the two owner-reserved capabilities.
- Connect OIDC or OAuth2 covers wiring a native provider connection and naming the roles or groups claim so a token carries membership.
- Custom roles explains the named capability bundles a group mapping can also reference by name.
- Dual control for restores shows where a resolved role meets the second gate that a restore apply must also pass.
Last updated .