You added SSO, wired "map the IdP's groups to my app's roles," pointed it at the groups claim, and shipped. Then everyone signs in as the default role, admins included. The mapping silently does nothing, and the reason is the one thing almost no tutorial mentions: OIDC providers do not agree on where they put groups.
If you hardcode the claim name groups, you have quietly hardcoded "works with Okta and Keycloak, breaks with everyone else."
| Provider | Claim | The catch |
|---|---|---|
| Okta | groups | You must add a groups claim in the authorization server, and often request a groups scope. Not there by default. |
| Keycloak | groups | Only if you add a group-membership mapper to the client scope. |
| Microsoft Entra ID | groups | Off by default: enable the groups optional claim. Returns object IDs, not names. Past ~200 groups you get an overage pointer and must call Graph. |
| AWS Cognito | cognito:groups | Automatic, but namespaced. Plain groups is always empty. |
| Auth0 | a namespaced claim, e.g. https://app.example/roles | Auth0 strips non-namespaced custom claims, so it can never be plain groups. |
| not emitted | The ID token has no groups. Use Workspace + the Cloud Identity / Admin SDK out of band. | |
| GitHub (OAuth2) | none | No ID token. Org and team membership come from /user/orgs with the read:org scope. |
A config that works for Cognito needs cognito:groups; Auth0 needs your namespaced URL; Entra needs an object-ID map plus a Graph fallback; Google needs a different mechanism entirely. One hardcoded string cannot cover that.
1. The claim name is wrong. You read token["groups"], the provider put them in cognito:groups, you get None, mapping falls through to the default.
2. The groups were never emitted. Even with the right claim name, several providers do not include groups unless you request the right scope or flip a setting. If you hardcode the scope to openid email profile, you can never ask for more. Both fail the same silent way: no error, everyone lands on the default role.
Do not bake the claim name into your code. Store it per provider, default it to groups, and let it be overridden:
# config per provider, not a constant
groups_claim = provider.get("groups_claim", "groups") # cognito:groups, https://app/roles, ...
scope = provider.get("scope") or DEFAULT_SCOPE # so you can add "groups" for Okta
# read whatever claim this provider uses
groups = userinfo.get(groups_claim) or []
# first matching group wins, else default
role = next((role_map[g] for g in groups if g in role_map), default_role)
That is the whole difference between "SSO role mapping works" and "SSO role mapping silently doesn't": a groups_claim field and a scope override, per provider.
1. Decode the actual token. Log the decoded claims server-side (never the raw token) and look at what keys are really there. Usually the groups are under a name you did not expect, or missing.
2. If missing: the provider is not emitting them. Add the claim in its app config and request the scope it wants.
3. If present under another name: point groups_claim at it.
4. Entra past 200 groups: you get a _claim_names overage pointer, not groups. Call Graph memberOf, or use app-role assignments.
5. Google/GitHub: stop looking in the token. Google needs the directory API; GitHub needs the orgs API.
groups. If you are adding SSO to anything, make those two things configurable from day one.Aggrete is Apache-2.0 and runs on a laptop or a cluster.