Mapping OIDC Claims to Application Roles

Every identity provider speaks a slightly different dialect: Okta sends groups, Azure AD sends roles and opaque wids, Auth0 sends namespaced custom claims, Keycloak nests them under realm_access.roles. If your application reaches into the ID token and trusts whatever shape it finds, you have coupled your authorization model to one vendor and opened a path to privilege escalation. This page — part of the Configuring Identity Providers for OIDC guide — shows how to build a deliberate mapping layer that normalizes claims across providers, translates them into your own internal roles, and provisions accounts just-in-time without ever trusting an unverified claim. This mapping layer is the bridge from federated identity to your own role-based access control system.

The Problem: Claims Are Not Roles

An OIDC claim is an assertion about the user made by the IdP. An application role is your internal authorization construct. Conflating them creates three failure modes:

  • Vendor lock-in. Code that reads token.groups breaks the day you add a second IdP that uses token.roles, or migrate from Okta to Entra ID.
  • Authorization by raw string. Granting access based on a group named Admins means anyone who can influence the IdP’s directory — or forge an unverified token — controls your permission model.
  • Semantic drift. The IdP group Engineering-FTE means something to HR; it should not silently equal your application’s billing:write permission unless you explicitly decided so.

The solution is an explicit, auditable mapping: verified claim → normalized claim → internal role → permissions.

The Claim-to-Role Mapping Pipeline

flowchart LR
    A["ID token / userinfo\nraw claims"]:::client --> B["1. Verify\nsig + iss + aud"]:::idp
    B --> C["2. Extract\nprovider-specific paths"]:::idp
    C --> D["3. Normalize\ncanonical claim set"]:::store
    D --> E["4. Map\nclaim → internal role"]:::store
    E --> F["5. JIT provision\nupsert user + roles"]:::store
    F --> G["6. RBAC\npermissions enforced"]:::rs
    classDef client fill:#fff0ee,stroke:#c0392b,stroke-width:2px,color:#1a1614
    classDef idp    fill:#eef0ff,stroke:#2c3e8c,stroke-width:2px,color:#1a1614
    classDef store  fill:#fffbec,stroke:#d4840a,stroke-width:2px,color:#1a1614
    classDef rs     fill:#ebf5fb,stroke:#2980b9,stroke-width:2px,color:#1a1614

Each stage has one job, and the trust boundary is at stage 1: nothing downstream runs until the token’s signature and claims are cryptographically verified.

Implementation

1. Verify before you read anything

Authorization decisions begin only after the token is validated against the IdP’s JWKS with an explicit algorithm allowlist. A common, devastating mistake is mapping roles from an unverified token — or from the access_token when your roles live in the id_token.

import { jwtVerify, createRemoteJWKSet } from "jose";

const JWKS = createRemoteJWKSet(new URL(idp.jwksUri));

export async function verifyIdToken(idToken: string) {
  const { payload } = await jwtVerify(idToken, JWKS, {
    issuer: idp.issuer,
    audience: idp.clientId,
    algorithms: ["RS256"], // explicit allowlist — never "none", never HS256 for OIDC
  });
  return payload; // safe to read claims only past this point (RFC 7519)
}

alg: none and accepting a symmetric HS256 where the IdP signs asymmetrically are classic algorithm-confusion attacks (RFC 8725, JWT BCP) — the verifier must pin the expected algorithm, not read it from the token header.

2. Extract provider-specific claim paths

Define, per IdP, where the role-bearing claims live. Treat absence as “no roles,” never as “all roles.”

type ProviderProfile = {
  issuer: string;
  groupClaimPath: string[];   // e.g. ["groups"] or ["realm_access", "roles"]
  rolePrefix?: string;        // strip vendor namespacing, e.g. "https://app/"
};

const providers: Record<string, ProviderProfile> = {
  okta:     { issuer: "https://acme.okta.com",        groupClaimPath: ["groups"] },
  entra:    { issuer: "https://login.microsoftonline.com/<tenant>/v2.0",
              groupClaimPath: ["roles"] },
  keycloak: { issuer: "https://kc.acme.com/realms/app",
              groupClaimPath: ["realm_access", "roles"] },
};

function extractRawGroups(claims: Record<string, unknown>, p: ProviderProfile): string[] {
  let node: unknown = claims;
  for (const key of p.groupClaimPath) {
    node = (node as Record<string, unknown> | undefined)?.[key];
  }
  if (!Array.isArray(node)) return [];
  return node
    .filter((v): v is string => typeof v === "string")
    .map((v) => (p.rolePrefix ? v.replace(p.rolePrefix, "") : v));
}

3. Normalize to a canonical claim set

Collapse vendor differences into one shape the rest of your app understands. Lowercase, trim, dedupe — small inconsistencies (Admins vs admins) otherwise become silent authorization bugs.

type NormalizedIdentity = {
  subject: string;       // stable: iss + sub, never email alone
  email: string;
  emailVerified: boolean;
  groups: string[];      // canonical, lowercased
};

function normalize(claims: Record<string, unknown>, p: ProviderProfile): NormalizedIdentity {
  return {
    subject: `${claims.iss}|${claims.sub}`,
    email: String(claims.email ?? "").toLowerCase(),
    emailVerified: claims.email_verified === true,
    groups: [...new Set(extractRawGroups(claims, p).map((g) => g.trim().toLowerCase()))],
  };
}

Key the user on iss|sub, not email — emails get reassigned and are not unique across IdPs, so trusting them for identity invites account takeover.

4. Map normalized claims to internal roles

The mapping is your data, not the IdP’s. Keep it explicit and version-controlled (or in an admin-editable table) so a security review can read exactly which IdP group grants which internal role. Default deny: an unmapped group grants nothing.

// Internal roles are your own vocabulary, decoupled from any IdP.
const groupToRoles: Record<string, string[]> = {
  "acme-platform-admins": ["admin"],
  "acme-billing":         ["billing_manager"],
  "acme-engineering":     ["developer"],
};

function mapToInternalRoles(id: NormalizedIdentity): string[] {
  const roles = new Set<string>(["member"]); // baseline role for any authenticated user
  for (const g of id.groups) {
    for (const r of groupToRoles[g] ?? []) roles.add(r);
  }
  return [...roles];
}

For richer conditions than a static lookup — for example, granting a role only when a department claim equals a value and the request is in-region — graduate this map into policy and consult choosing between RBAC and ABAC to decide where attribute-based rules belong.

5. Just-in-time provisioning

On first login, create the user and assign roles; on subsequent logins, reconcile roles so a directory change at the IdP propagates. Do this in an idempotent upsert keyed on subject.

export async function jitProvision(claims: Record<string, unknown>, p: ProviderProfile) {
  if (claims.email_verified !== true) {
    throw new Error("refusing to provision: email not verified by IdP");
  }
  const id = normalize(claims, p);
  const roles = mapToInternalRoles(id);

  await db.transaction(async (tx) => {
    const user = await tx.upsertUser({ subject: id.subject, email: id.email });
    // Reconcile: the IdP is the source of truth for *which* roles, on every login.
    await tx.replaceUserRoles(user.id, roles);
    await tx.auditRoleChange(user.id, roles, id.subject); // append-only audit trail
  });
}

Reconciling on every login is what makes deprovisioning work: remove a user from the IdP group, and their next login drops the role. If you only assigned roles at first login, revocations would never propagate.

Security Implications

  • Never trust an unverified claim. Map roles only from a token whose signature, iss, and aud you verified, with a pinned algorithm allowlist (RFC 8725). The access_token is often opaque and not meant for the client to read — roles for your app belong in the verified id_token or a verified userinfo response.
  • Default deny on unmapped groups. An unrecognized IdP group must grant nothing. Mapping “everything not explicitly denied” lets a new IdP group silently escalate privileges.
  • Require email_verified before provisioning. Auto-provisioning on an unverified email lets an attacker who controls an unverified address claim someone else’s account, especially if you (wrongly) key on email.
  • Treat the mapping table as privileged config. Editing the claim→role map is itself a privilege-granting action — gate it behind admin auth and log it to an append-only audit log.
  • Beware the groups overage claim. Entra ID and others omit groups and send a Graph API reference once a user exceeds a group-count threshold. If you don’t handle the overage indicator, a heavily-grouped admin can silently lose all roles — fail closed and fetch the full set, never assume “no groups.”

Prevention & Monitoring Hooks

  • Log every role reconciliation with subject, old roles, new roles, and the source token’s iss. Sudden role grants or a mapping change that elevates many users at once should alert.
  • Snapshot the mapping in CI. Treat groupToRoles as code with a test asserting that known IdP groups still resolve to expected roles, so a refactor can’t silently drop a mapping.
  • Alert on provisioning of unmapped groups appearing in tokens — it signals a new IdP group your security team hasn’t reviewed yet.
  • Re-evaluate on token refresh, not just login, if your sessions are long-lived, so directory changes don’t lag a multi-day session.

Frequently Asked Questions

Should I read roles from the ID token or the access token?

Read them from the verified id_token (or a verified userinfo response) — that’s the token OIDC defines for conveying identity claims to the client (RFC 7519). The access_token is frequently opaque and intended for the resource server, not the client, so parsing it for roles is fragile and sometimes impossible. Whichever token you use, verify its signature and iss/aud with a pinned algorithm allowlist before reading a single claim.

How do I keep roles in sync when someone is removed from an IdP group?

Reconcile roles on every login (and on token refresh for long sessions) by replacing the user’s role set from the current token, not merely adding on first login. When the IdP drops the user from a group, their next token omits it and your reconciliation removes the corresponding internal role. Pair this with short access-token lifetimes so revocations propagate within minutes rather than days.

What happens when a user belongs to too many groups for the token to carry?

Some IdPs (notably Entra ID) replace the groups claim with an overage indicator pointing at a Graph API endpoint once the user exceeds a group-count threshold. If your mapping treats the missing claim as “no groups,” a heavily-grouped admin can lose all roles. Detect the overage indicator and fetch the full group set from the IdP’s API, failing closed rather than silently downgrading.

Why not just use the IdP group names directly as my application roles?

Because it couples your authorization model to one provider’s directory and naming, makes a second IdP painful, and lets anyone who manages the directory reshape your permissions. An explicit mapping layer keeps internal roles as your own vocabulary, lets you support multiple IdPs behind one canonical claim set, and gives security a single auditable place that defines exactly which IdP group grants which permission.

Where should attribute-based conditions (region, department) live?

Simple static “group X grants role Y” belongs in the mapping table. Once decisions depend on request context or claim values — grant billing_manager only when department = finance and the request originates in-region — that’s attribute-based logic better expressed as policy. See the RBAC-vs-ABAC guide to decide which conditions stay in the role map and which move into a policy engine.