Enable PKCE for OAuth token exchange
How to add PKCE to your custom identity provider's OAuth token exchange with Chainguard, either alongside a client …
For the complete documentation index, see llms.txt.
Chainguard can grant roles based on a user’s groups in your identity provider (IdP). You map an IdP group to a Chainguard role once, and from then on any user who logs in with that group in their token receives the role for that session. Access follows group membership, so you manage who gets what in your IdP instead of assigning roles to each user in Chainguard.
This guide covers Okta and Microsoft Entra ID. The Chainguard-side steps (2 through 4) are the same for both providers; only how you emit group membership in Step 1 differs.
To complete this guide, you need the following:
owner role.chainctl installed on your local machine. You must also authenticate with chainctl auth login.The rest of this guide refers to your organization and identity provider by their UIDPs, stored in the ORGANIZATION and IDENTITY_PROVIDER environment variables. Retrieve and set them with the following commands.
Store your organization’s UIDP in the ORGANIZATION variable:
export ORGANIZATION=$(chainctl iam organizations list -o json | jq -r '.items[0].id')Store your identity provider’s UIDP in the IDENTITY_PROVIDER variable:
export IDENTITY_PROVIDER=$(chainctl iam identity-providers list -o json | jq -r '.items[0].id')These commands select the first result each list returns. If your account can access more than one organization or identity provider, replace .items[0] with a filter that matches the one you want, or set each variable to the UIDP directly.
Before you configure any mappings, it helps to understand how group-derived roles behave:
Mappings are additive. A mapping grants a role on top of whatever access a user already has. It never removes existing access.
Group-derived roles are session-scoped, not standing grants. They apply to the logged-in session and are re-evaluated at each login. They do not appear in chainctl iam role-bindings list or the Console’s role-bindings view. Instead, each login that resolves group-derived roles emits a Chainguard CloudEvent recording the roles granted, along with the identity provider and groups they came from. To observe effective group-derived access, subscribe to Chainguard’s CloudEvents stream.
As a result, no single command or view answers “who holds a group-derived role right now?” The CloudEvents stream is a log of each login as it happens, not a current-state table. During an access review or an incident, reconstruct effective access by combining that event history with current group membership in your IdP and the mappings from chainctl iam external-group-role-mappings list.
Changes take effect within an hour. A session that includes group-derived roles is short-lived by design. Its access token lasts at most one hour, and no refresh token is issued, so when the token expires the user logs in again and that login re-reads their current group membership. As a result:
Configure your identity provider to include the user’s group memberships in the OIDC token it issues to Chainguard. The exact Console steps differ by provider and change over time, so follow your provider’s own documentation. This guide relies only on the result:
--external-group-id in Step 3. This guide uses groups.| Provider | Configuring the groups claim | Values you map on |
|---|---|---|
| Okta | Customize tokens with a groups claim | Group names (for example, app-admins) |
| Microsoft Entra ID (Group Claim) | Configure group claims | Group Object IDs (GUIDs), by default |
| Microsoft Entra ID (App Roles) | Configure app roles | App Role Value, by default |
To use group display names in Entra ID instead of GUIDs, configure the claim to emit cloud-group display names. This requires restricting the claim to groups assigned to the application, which is also the recommended way to stay under the group limit.
Update the identity provider you use to log in so Chainguard requests the groups claim and knows which claim carries group membership:
chainctl iam identity-providers update $IDENTITY_PROVIDER \
--oidc-additional-scopes=groups \
--oidc-groups-claim=groups
--oidc-additional-scopes=groupsshould not be set when using Entra ID.
--oidc-additional-scopes=groups tells Chainguard to request the groups claim.--oidc-groups-claim=groups tells Chainguard which claim carries group membership, using the name from Step 1. An empty value turns group mapping off for this provider.Create a mapping from an IdP group to a Chainguard role:
chainctl iam external-group-role-mappings create \
--external-group-id "GROUP" \
--role editor \
--scope $ORGANIZATION \
--idp $IDENTITY_PROVIDER--external-group-id is the value the IdP emits: the group name for Okta (for example, app-admins), or the group Object ID (GUID) for Entra ID.--role is the role to grant, by name or UIDP, such as viewer or editor.--scope is the UIDP of the organization where the role applies.--idp is the identity provider that owns the mapping.To review the mappings you’ve configured, run the list subcommand:
chainctl iam external-group-role-mappings list --parent $ORGANIZATIONEach command creates one mapping. To map many groups at once, see Automate mappings with the API.
chainctl iam role-bindings list, because it’s session-scoped.Once mappings are in place, you can adjust access by changing group membership within your identity provider, or by changing the mappings themselves:
Grant more than one role to a group. Create one mapping per role for the same group. The capabilities combine.
Remove one user’s group-derived access. Remove that user from the group in your IdP, or unassign them from the application. Only that user is affected; everyone else in the group keeps the mapped role.
Remove a role from everyone in a group. Delete the mapping. This revokes the mapped role for all users in that group and leaves their other access intact:
chainctl iam external-group-role-mappings delete $MAPPING_IDBe sure to replace $MAPPING_ID with the mapping’s UIDP, which you can find by running the chainctl iam external-group-role-mappings list command shown in Step 3.
Remove every mapping for an identity provider. When offboarding a provider, delete all of its mappings in one command. chainctl asks you to confirm first; include the --yes flag to skip the prompt:
chainctl iam external-group-role-mappings delete --all --idp $IDENTITY_PROVIDERThis revokes the mapped roles for all users across that IdP’s groups and leaves their other access intact. Note that this cleanup step is optional; deleting the identity provider also deletes any mappings that still exist. Running it first is still worth doing — the confirmation shows exactly how many mappings you’re revoking before the provider itself is removed.
Each of these changes takes effect at the affected user’s next login, within one hour.
When you need to revoke a user’s access immediately, as with a compromised account or a departing employee, deleting the mapping alone is not enough. A session that already resolved the group-derived role keeps it until the access token expires, up to an hour, and that token can’t be revoked mid-session. The fix is to stop the user from re-deriving the role at their next login:
The existing session still expires on its own within the hour, and the IdP change blocks re-authorization after that.
Each chainctl command in this guide handles one mapping. When your IdP has dozens or hundreds of groups to map, or when you manage Chainguard access from a pipeline, use the Chainguard API instead. Its ExternalGroupRoleMappings endpoints create, list, and delete the same mappings chainctl does, and the results behave identically: additive, session-scoped, and re-evaluated at each login.
These examples reuse the ORGANIZATION and IDENTITY_PROVIDER variables from the prerequisites. Add a token and the API host:
export TOKEN=$(chainctl auth token)
export API=https://console-api.enforce.devThat token is your own and expires within an hour. To run these calls from a pipeline, authenticate as an assumable identity that holds the access described in Permissions for the calling identity.
chainctl accepts a role name, but the API takes the role’s UIDP. Retrieve it by name:
export ROLE=$(curl -s -H "Authorization: Bearer $TOKEN" \
"$API/iam/v2/roles?name=editor" | jq -r '.roles[0].uid')The identity provider owns the mapping, so its UIDP goes in the request path and the body carries the rest. The fields correspond to the flags in Step 3:
chainctl flag | API equivalent |
|---|---|
--idp | The identity provider UIDP in the request path |
--external-group-id | externalGroupId |
--role | roleUid, which takes the role’s UIDP rather than its name |
--scope | scope |
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
"$API/iam/v2/externalGroupRoleMappings/$IDENTITY_PROVIDER" \
-d "{\"externalGroupId\": \"app-admins\", \"roleUid\": \"$ROLE\", \"scope\": \"$ORGANIZATION\"}" | jq .{
"uid": "d9e2f1a0.../4b0a7c19c3e2f8d1/e54a7ea6f02e5dff",
"identityProviderUid": "d9e2f1a0.../4b0a7c19c3e2f8d1",
"externalGroupId": "app-admins",
"roleUid": "0e4b93c2...",
"scope": "d9e2f1a0...",
"createTime": "2026-09-10T18:04:21.968Z"
}Keep the uid from the response. It’s the mapping’s own UIDP, rooted under the identity provider, and deleting the mapping later requires it.
The API has no batch create, so loop over your groups. This example maps every group listed in groups.txt to one role and reports the result of each call:
while read -r group; do
status=$(curl -s -o response.json -w '%{http_code}' \
-X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
"$API/iam/v2/externalGroupRoleMappings/$IDENTITY_PROVIDER" \
-d "{\"externalGroupId\": \"$group\", \"roleUid\": \"$ROLE\", \"scope\": \"$ORGANIZATION\"}")
case "$status" in
200) echo "created: $group" ;;
409) echo "exists: $group" ;;
*) echo "failed: $group (HTTP $status)"; jq -c . response.json ;;
esac
done < groups.txtA mapping that already exists returns HTTP 409 rather than a second record, so you can re-run the loop after fixing a failure without creating anything twice. To grant more than one role, run the loop once per role with its own group list.
Read back every mapping under an identity provider:
curl -s -H "Authorization: Bearer $TOKEN" \
"$API/iam/v2/externalGroupRoleMappings?identity_provider_uid=$IDENTITY_PROVIDER" \
| jq '{totalCount, mappings: [.externalGroupRoleMappings[] | {uid, externalGroupId, roleUid}]}'totalCount reports how many mappings match. Results are paginated, so a nextPageToken in the response means more pages remain, as described in Pagination.
Delete a single mapping by its UIDP:
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \
"$API/iam/v2/externalGroupRoleMappings/$MAPPING_UID"A successful delete returns an empty JSON object, and a mapping that’s already gone returns HTTP 404.
To remove several mappings in one call, pass their UIDPs to the :batchDelete endpoint along with the identity provider they belong to:
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
"$API/iam/v2/externalGroupRoleMappings:batchDelete" \
-d "{\"parent\": \"$IDENTITY_PROVIDER\", \"names\": [\"$MAPPING_UID_1\", \"$MAPPING_UID_2\"]}" | jq .The response lists the mappings it deleted. Names that no longer exist are skipped without error, so repeating a teardown is safe. Every name must belong to the identity provider named in parent; one that doesn’t fails the whole call with INVALID_ARGUMENT and deletes nothing.
The API has no equivalent of the --all flag. To clear every mapping for a provider you’re offboarding, list them first and feed their UIDPs to :batchDelete:
MAPPINGS=$(curl -s -H "Authorization: Bearer $TOKEN" \
"$API/iam/v2/externalGroupRoleMappings?identity_provider_uid=$IDENTITY_PROVIDER" \
| jq -c '[.externalGroupRoleMappings[].uid]')
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
"$API/iam/v2/externalGroupRoleMappings:batchDelete" \
-d "{\"parent\": \"$IDENTITY_PROVIDER\", \"names\": $MAPPINGS}" | jq .This revokes the mapped roles for every group under that provider, so review the list output before you run the delete.
Creating a mapping grants a role, so the API enforces an anti-escalation rule. The identity making the call needs permission to create identity providers and role bindings, and it must already hold every capability the mapped role grants. An identity can’t create a mapping that grants access it doesn’t have itself. The owner role satisfies all three requirements.
Identity providers cap how many groups a token can carry. Past that limit, the IdP stops sending the inline groups claim. This means Chainguard no longer receives the user’s groups, and their mappings don’t resolve. Keep the emitted set small by sending only the groups you map:
groups claim once a user belongs to more than 200 groups (the JWT and OIDC limit; the SAML limit is 150). Past the limit, Entra ID emits an overage claim (_claim_names and _claim_sources) that points to Microsoft Graph instead of the inline list, and Chainguard doesn’t follow it. Avoid the overage by emitting only groups assigned to the application, as described in Step 1, or by using fewer, coarser groups for access.Last updated: 2026-09-10 18:12