backend / authentication / sso / 07_group_role_claim_mapping.md

Group, Role, and Claim Mapping

6 interview angles 7 min read source

Group, Role, and Claim Mapping

The IdP says “this user is in the engineers group” and “their department is R&D.” Your app needs to translate that into permissions. The mapping is where SSO meets RBAC.

What the IdP gives you

After successful SSO, you have claims (OIDC) or attributes (SAML):

// OIDC ID token claims
{
  "sub": "abc-123",
  "email": "alice@example.com",
  "name": "Alice Smith",
  "groups": ["engineers", "admins", "everyone"],
  "department": "R&D",
  "manager": "bob@example.com",
  "country": "US",
  "employee_id": "E12345"
}
<!-- SAML AttributeStatement -->
<saml:AttributeStatement>
  <saml:Attribute Name="email">
    <saml:AttributeValue>alice@example.com</saml:AttributeValue>
  </saml:Attribute>
  <saml:Attribute Name="groups">
    <saml:AttributeValue>engineers</saml:AttributeValue>
    <saml:AttributeValue>admins</saml:AttributeValue>
  </saml:Attribute>
</saml:AttributeStatement>

Your app translates these into:

  • User attributes stored locally (email, name, department).
  • Roles / permissions for authorization decisions.

Where to map — three layers

┌─────────────────────────────────┐
│ IdP                             │  "alice has groups [engineers, admins]"
└─────────────────────────────────┘
              ↓ claims
┌─────────────────────────────────┐
│ Mapping layer                   │  "groups [engineers, admins] → role 'editor'"
└─────────────────────────────────┘
              ↓ assigned role
┌─────────────────────────────────┐
│ Authorization                   │  "role 'editor' can edit posts"
└─────────────────────────────────┘

The mapping layer is the configurable boundary between “what the IdP says” and “what your app understands.”

Strategy 1: Direct group sync

Treat IdP groups as roles. Sync membership on every login (or via SCIM).

def on_sso_login(claims: dict) -> User:
    user = upsert_user(claims)
    idp_groups = set(claims.get("groups", []))
    user.roles = {g for g in idp_groups if g in APP_KNOWN_ROLES}
    session.commit()
    return user

Pros: simple, no configuration. Cons: IdP group naming is rigid (must match app role names). Different customers use different group names.

Strategy 2: Configurable mapping

Each customer (tenant) configures their own IdP-group-to-app-role mapping in your admin UI.

class GroupRoleMapping(Base):
    tenant_id: Mapped[int]
    idp_group: Mapped[str]      # e.g. "okta-engineering-admins"
    app_role: Mapped[str]       # e.g. "admin"

def on_sso_login(claims: dict, tenant: Tenant) -> User:
    user = upsert_user(claims)
    idp_groups = set(claims.get("groups", []))
    mappings = GroupRoleMapping.query.filter(
        tenant_id=tenant.id,
        idp_group__in=idp_groups,
    ).all()
    user.roles = {m.app_role for m in mappings}
    session.commit()
    return user

Pros: flexible per customer. Cons: more configuration UX.

This is what mature B2B SaaS does (Slack, Notion, Linear, etc.). Customer admins map their existing groups to your app’s roles.

Strategy 3: Attribute-based access control (ABAC)

Don’t reduce to “roles” — use raw attributes in authorization decisions:

def can_edit_doc(user: User, doc: Document) -> bool:
    return (
        user.department == doc.owning_department
        or "admins" in user.idp_groups
        or user.user_id == doc.owner_id
    )

Pros: expressive (per-document rules, location-based, time-of-day). Cons: harder to audit (“who can edit this?” requires running the predicate).

For sensitive multi-attribute decisions, ABAC is the right tool. For typical CRUD: roles suffice.

Where mappings should live

Location Pros Cons
Hardcoded in app simplest inflexible per customer
Per-tenant DB table flexible, auditable needs admin UI
IdP claim transformation (Okta, Auth0 rules) mapping happens at IdP distributed (each customer’s IdP must be configured)
Policy engine (OPA, Cedar) decoupled, declarative infrastructure overhead

For most B2B SaaS: per-tenant DB table + admin UI.

Claims that should map to user attributes

Some claims aren’t role-related; they’re user data:

Claim Maps to
sub user.external_id (immutable identifier)
email user.email
name, given_name, family_name user.full_name, user.first_name, user.last_name
preferred_username user.username
picture user.avatar_url
locale user.locale
zoneinfo user.timezone
phone_number user.phone

Update on every login (the IdP is the source of truth):

user.email = claims["email"]
user.full_name = claims.get("name", user.full_name)
user.locale = claims.get("locale", user.locale)
session.commit()

Exception: don’t overwrite attributes the user has explicitly set in your app (e.g. display name preference) unless that’s the intended UX.

Stable user identification

The sub claim (OIDC) or NameID (SAML) should be your stable identifier — opaque, never changes, even if the user changes email or name.

class User(Base):
    id: Mapped[int] = mapped_column(primary_key=True)
    external_id: Mapped[str] = mapped_column(unique=True, index=True)  # the sub
    email: Mapped[str]
    name: Mapped[str]

Pitfall: matching on email instead of sub. Users change emails (marriage, departure), and you’d end up with split accounts or wrong-account-merge.

Some IdPs reuse sub across apps; others scope it per app. Read the IdP docs.

Group claim formats — they vary

Different IdPs serialize group claims differently:

IdP Claim
Okta groups array of strings
Auth0 https://yourapp.com/groups (namespaced)
Azure AD groups (UUIDs by default! not names) or use “group claims” config
Keycloak realm_access.roles, resource_access.<client>.roles
AWS Cognito cognito:groups

Azure AD’s UUIDs are the classic gotcha — to get human-readable group names, you must configure the app to include them via “optional claims” or look them up via Microsoft Graph.

Plan your mapping config to accept whatever the IdP sends.

Updating roles — on every login vs cached

Two strategies:

On every login: re-read groups from claims, replace user.roles. Simple, always fresh.

Cached + SCIM updates: store roles, update on SCIM group change events. Faster (no per-login sync), works for users with long sessions.

For session-based apps with short sessions, “every login” is fine. For long-session apps, SCIM ensures changes propagate without waiting for re-login.

Forcing re-authentication for role changes

If a user’s role is downgraded mid-session, they keep the old role until next login (their cached session has it). For sensitive role changes:

  1. Invalidate the user’s sessions at the time of role change.
  2. Next request 401s, user re-authenticates, gets new role.

Or use very short access tokens and re-fetch user state from the DB on each token refresh.

Tenant routing via claims

For multi-tenant B2B apps where each customer has their own IdP:

def on_sso_login(claims: dict) -> User:
    tenant = lookup_tenant_by_issuer(claims["iss"])
    if not tenant:
        raise NotAuthorized("unknown tenant")

    user = upsert_user(claims, tenant=tenant)
    user.roles = map_groups_to_roles(claims.get("groups", []), tenant)
    return user

The IdP’s issuer identifies the tenant; you don’t trust client-side tenant claims. See 10_multi_tenant_sso.md.

Claim transformation at the IdP

Modern IdPs (Okta, Auth0, Azure AD) let you transform claims before they go to your app:

// Auth0 "Action" example
exports.onExecutePostLogin = async (event, api) => {
    const groups = event.user.app_metadata.groups || [];
    api.idToken.setCustomClaim("https://yourapp.com/groups", groups);
    api.accessToken.setCustomClaim("https://yourapp.com/role",
        groups.includes("admins") ? "admin" : "user");
};

Pros: mapping lives at the IdP; your app sees clean claims. Cons: distributed config (every customer’s IdP needs the same logic); harder to debug.

For “your customer’s IdP,” prefer mapping in your app (consistent across customers). For “your own IdP,” IdP-side transforms are convenient.

Common pitfalls

  • Matching users by email instead of sub — email changes cause split or merged accounts.
  • Trusting all claims without verifying the IdP issued them — see 08_attack_vectors.md.
  • Hardcoding group names — different customers use different IdP group names.
  • Not updating roles on subsequent logins — admin demotes user at IdP, but old role persists in your app.
  • Missing the groups claim because it wasn’t in the scope — must request the right scope to receive the claim.
  • Azure AD returning group UUIDs — your app maps “no readable name.” Configure the app registration to include group names.

Common interview confusions

  • sub is the email.” — usually a UUID or opaque ID. The email is the email claim. Match users on sub.
  • “Groups always come through automatically.” — depends on the IdP’s claim config. You may need to request a specific scope, configure the IdP to include groups, or fetch them from the UserInfo endpoint.
  • “Group sync happens with every API request.” — usually on login (refreshed every session). For mid-session sync, you need SCIM or token introspection.

Interview angle

  • “How do you map IdP groups to your app’s roles?” — per-tenant config: a (tenant_id, idp_group, app_role) mapping table. Customer admins configure in your UI. On SSO login, read claim groups and apply mapping.
  • “Why match users by sub instead of email?”sub is stable; emails change. Matching by email causes split accounts (email changed) or wrong-account merges (email reassigned).
  • “How do you keep group membership in sync between IdP and app?” — read groups from claims on each login (simple, lag = session duration). Or SCIM for proactive sync. Or both.
  • “How do you handle a customer’s IdP that sends groups as UUIDs?” — Azure AD does this by default. Configure their app registration to include group names (“optional claims” or “groups claim type”), or have your app look them up via Microsoft Graph.
  • “User’s role was changed at the IdP — when does your app see it?” — depends on strategy: with per-login sync, on next login. With SCIM, when IdP pushes the PATCH (usually within minutes).
  • “How do you prevent a user from keeping admin access after demotion?” — invalidate active sessions when SCIM tells you the role changed. Short access tokens help — even without active session invalidation, the next refresh re-fetches state.