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
Groupresource has adisplayNameandmembers[].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 inmembers.valuefilters). 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 Entragroups) work with the default mapping and noclaimsconfiguration. - Group ids arrive as identifiers, and the SCIM
valueversusdisplaydistinction gives the rule for which one to trust. - The AuthZEN claims draft makes
permdock/authzen's/search/resourceendpoint a legitimate source ofrolesandgroupsfor 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
},
})| Claim | Encoding accepted | Becomes | Rule |
|---|---|---|---|
roles | string[] or SCIM complex values | principal.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 |
groups | string[] or SCIM complex values | principal.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 MembershipSource | The value sub-attribute is the identifier; display is never used for matching |
entitlements | string[] or SCIM complex values | Roles under the entitlements-are-roles rule, selected by name | Same 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.
| Issuer | Tenant claim | Roles per tenant | Notes |
|---|---|---|---|
| Microsoft Entra ID | tid | roles are app roles assigned in the tenant | Restrict the issuer to one tenant or compare tid against onboarded tenants; permdock doctor PD011 |
| Auth0 | org_id, org_name | Organization Roles through a post-login Action claim | org_name is a label, org_id is the identifier |
| WorkOS | org_id | role (slug) and permissions | permissions under the entitlements-are-roles rule |
| Clerk | org_id (session token) | org_role, org_permissions; compact o claim in v2 tokens | The Clerk provider reads these directly |
| Google Workspace | hd | None in the token | Absent for consumer accounts; permdock doctor PD010 |
| Kinde | org_code | permissions, roles | |
| Descope | tenants object keyed by tenant id | tenants.<id>.roles | A per-tenant claim path: claims: { memberships: 'tenants' } maps each key to a membership |
| Zitadel | Organisation id nested under each role in urn:zitadel:iam:org:project:roles | Same claim | The resolver flattens role -> { orgId } into { tenant: orgId, roles: [role] } memberships |
| Logto | Audience urn:logto:organization:<id> on an organization token | roles, scope | One token per organisation |
| Supabase (convention) | tenant_id injected by a custom access token hook | A hook-injected role claim | The 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 concept | PermDock concept |
|---|---|
RFC 9068 roles claim | principal.roles, or Membership.roles when tenant-scoped by the issuer |
RFC 9068 groups claim | Membership.team (identifier), via: 'group:<id>' |
RFC 9068 entitlements claim | Roles under the entitlements-are-roles rule |
RFC 7643 complex value value | The identifier PermDock matches on |
RFC 7643 complex value display | Ignored for matching; may be copied to meta for UI |
RFC 7643 complex value type (direct, indirect) | Recorded in via |
SCIM Group.id | Membership.team |
SCIM Group.displayName | Never an identifier |
| No standard tenant claim | claims.tenant configuration; principal.tenant after resolution |
| AuthZEN claims draft: claim binding | /search/resource over the subject's roles and memberships |
Sources
- RFC 9068: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens, section 2.2.3.1 and section 7.2.
- RFC 7643: SCIM Core Schema, sections 4.1.2, 4.2 and 8.2; RFC 7644: SCIM Protocol.
- IANA JSON Web Token Claims registry entries
roles,groups,entitlements. - draft-gazitt-oauth-authzen-claims-00.
- Microsoft Entra: configure group claims, Okta: add a groups claim.
- SaaS tenancy and roles for the vendor rows.
Open questions
- Whether an issuer's tenant-scoped
roles(Auth0 Organization Roles in a token that also carriesorg_id) should default to a membership or to global roles; the proposal is membership wheneverclaims.tenantresolves, global otherwise. - Whether to surface SCIM
displayvalues to the UI throughmetaor drop them entirely. - How far to follow the AuthZEN claims draft before it is adopted by a working group.
Postgres row-level security
Postgres RLS as a compile target and import source for PermDock policies, covering CREATE POLICY semantics, Supabase helpers, GUC patterns, pg_policies introspection, and the Drizzle and Prisma 8 authoring surfaces.
FAPI 2.0 Security Profile
What the FAPI 2.0 Security Profile requires of a resource server and how permdock/jwt with profile: 'fapi2' and the OpenAPI emitter enforce those requirements before a permission check runs.