PermDock
Standards

JWT authorization claims (RFC 9068, SCIM)

How PermDock reads the registered roles, groups and entitlements JWT claims (RFC 9068 section 2.2.3.1, SCIM RFC 7643 encoding) into global roles, team memberships and entitlement roles, how vendor tenant claims map to the active tenant, and the AuthZEN claims draft that makes a PDP a claim source.

Status: planned Phase: 1 Adapter phases: permdock/jwt 1, provider subjectFrom* mappers 1 and 2, permdock/authzen search endpoints 2.

What it is

RFC 9068, the JWT profile for OAuth 2.0 access tokens, says in section 2.2.3.1 that an authorization server which embeds authorization attributes beyond delegated scopes (memberships in roles and groups, entitlements for the targeted resource) SHOULD use the roles, groups and entitlements attributes of the SCIM User schema (RFC 7643 section 4.1.2) as claim names, and registers all three in the IANA JWT claims registry. RFC 7643 defines the three as multi-valued complex attributes: each value is an object whose value sub-attribute carries the identifier, optionally with display (a human label), type, primary and $ref. RFC 7643 section 8.2 shows the groups form. No vocabulary is given for roles or entitlements.

In practice many issuers emit plain string arrays ("roles": ["admin"], "groups": ["9f2c..."]), so a reader must accept both encodings.

Two neighbours:

  • SCIM 2.0 Group (RFC 7643 section 4.2): a Group resource has a displayName and members[].value. There is no tenant attribute anywhere in SCIM; tenancy is per endpoint or per bearer token. Nested groups are provider-specific (Microsoft Entra does not expand them in members.value filters). The IPSIE AL1 profile constrains SCIM for enterprise interoperability (watch list).
  • draft-gazitt-oauth-authzen-claims (individual draft, 2026): binds the three RFC 9068 claims to AuthZEN Resource Search so an authorization server can obtain them from a policy decision point rather than a directory. A claim binding associates a claim with an AuthZEN resource type and action; a search over that type and action enumerates the claim's values.

Why it matters for PermDock

PermDock reads role and group material from tokens on every request through permdock/jwt and the provider mappers (authentication), and the tenancy model needs a place for team memberships and entitlement roles on the wire. Inventing claim names would make PermDock one more vendor mapping. Following RFC 9068 means:

  • Issuers that already follow the RFC (Entra app roles under roles, Okta and Entra groups) work with the default mapping and no claims configuration.
  • Group ids arrive as identifiers, and the SCIM value versus display distinction gives the rule for which one to trust.
  • The AuthZEN claims draft makes permdock/authzen's /search/resource endpoint a legitimate source of roles and groups for an authorization server, which closes the loop: PermDock decides which roles a subject holds, and the token an agent later presents can carry them.

How PermDock uses it

Default claim mapping in permdock/jwt

import { subjectFromJwt } from 'permdock/jwt'

const subject = await subjectFromJwt(token, {
  jwks: 'https://issuer.example/.well-known/jwks.json',
  issuer: 'https://issuer.example',
  audience: 'api://permdock-example',
  claims: {
    id: 'sub',
    roles: 'roles',            // default; RFC 9068
    groups: 'groups',          // default; RFC 9068 -> team memberships
    entitlements: 'entitlements', // default; RFC 9068 -> roles (entitlements are roles)
    tenant: 'org_id',          // no standard exists; vendor specific, see below
  },
})
ClaimEncoding acceptedBecomesRule
rolesstring[] or SCIM complex valuesprincipal.roles (global) or, when the issuer scopes roles to the tenant it named, a membership { tenant, roles }Names the policy never declared are dropped and reported once by on('auth'); a custom role name resolves through the RoleSource
groupsstring[] or SCIM complex valuesprincipal.memberships[] entries { tenant, team: value, roles: [] }, with via: 'group:<value>'; roles come from the resolver's groupRoles option ({ '9f2c': ['lead'] }, ids never names) or a MembershipSourceThe value sub-attribute is the identifier; display is never used for matching
entitlementsstring[] or SCIM complex valuesRoles under the entitlements-are-roles rule, selected by nameSame trust rule as roles

Reading SCIM complex values: { "value": "9f2c", "display": "Design", "type": "direct" } contributes 9f2c; type: "indirect" (nested membership expanded by the issuer) is kept and recorded in via as group:9f2c:indirect. Entra's _claim_names and _claim_sources overflow (above roughly 200 groups) is not resolved by PermDock; the resolver reports groups-overflow through on('auth') and the recipe is app roles or a directory lookup in context (authentication single sign-on).

Tenant claims

There is no standard tenant claim. The claims.tenant path is configuration, and the resolver applies the threat model rule: the value is compared against onboarded tenants (or the subject's memberships) and an absent or unknown value yields a subject with no active tenant, never a default.

IssuerTenant claimRoles per tenantNotes
Microsoft Entra IDtidroles are app roles assigned in the tenantRestrict the issuer to one tenant or compare tid against onboarded tenants; permdock doctor PD011
Auth0org_id, org_nameOrganization Roles through a post-login Action claimorg_name is a label, org_id is the identifier
WorkOSorg_idrole (slug) and permissionspermissions under the entitlements-are-roles rule
Clerkorg_id (session token)org_role, org_permissions; compact o claim in v2 tokensThe Clerk provider reads these directly
Google WorkspacehdNone in the tokenAbsent for consumer accounts; permdock doctor PD010
Kindeorg_codepermissions, roles
Descopetenants object keyed by tenant idtenants.<id>.rolesA per-tenant claim path: claims: { memberships: 'tenants' } maps each key to a membership
ZitadelOrganisation id nested under each role in urn:zitadel:iam:org:project:rolesSame claimThe resolver flattens role -> { orgId } into { tenant: orgId, roles: [role] } memberships
LogtoAudience urn:logto:organization:<id> on an organization tokenroles, scopeOne token per organisation
Supabase (convention)tenant_id injected by a custom access token hookA hook-injected role claimThe Supabase provider refuses user_metadata

PermDock as a claim source

permdock/authzen serves /access/v1/search/resource. Under the AuthZEN claims draft an authorization server binds roles to a resource type and action (for example type role, action hold) and asks the PDP which resources the subject may act on; the answer is the claim value list. PermDock answers from permdock.roles({ tenant }) and permdock.memberships() for the authenticated subject. This is Phase 2 and depends on the draft's adoption; recorded on the watch list.

Mapping table

Specification conceptPermDock concept
RFC 9068 roles claimprincipal.roles, or Membership.roles when tenant-scoped by the issuer
RFC 9068 groups claimMembership.team (identifier), via: 'group:<id>'
RFC 9068 entitlements claimRoles under the entitlements-are-roles rule
RFC 7643 complex value valueThe identifier PermDock matches on
RFC 7643 complex value displayIgnored for matching; may be copied to meta for UI
RFC 7643 complex value type (direct, indirect)Recorded in via
SCIM Group.idMembership.team
SCIM Group.displayNameNever an identifier
No standard tenant claimclaims.tenant configuration; principal.tenant after resolution
AuthZEN claims draft: claim binding/search/resource over the subject's roles and memberships

Sources

Open questions

  • Whether an issuer's tenant-scoped roles (Auth0 Organization Roles in a token that also carries org_id) should default to a membership or to global roles; the proposal is membership whenever claims.tenant resolves, global otherwise.
  • Whether to surface SCIM display values to the UI through meta or drop them entirely.
  • How far to follow the AuthZEN claims draft before it is adopted by a working group.

On this page