# JWT authorization claims (RFC 9068, SCIM)

Source: https://permdock.com/docs/standards/jwt-authorization-claims

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.

## What it is [#what-it-is]

[RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html), 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](https://www.rfc-editor.org/rfc/rfc7643.html) 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](/docs/standards/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 [#why-it-matters-for-permdock]

PermDock reads role and group material from tokens on every request through `permdock/jwt` and the provider mappers ([authentication](/docs/concepts/authentication)), and the [tenancy](/docs/concepts/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 [#how-permdock-uses-it]

### Default claim mapping in `permdock/jwt` [#default-claim-mapping-in-permdockjwt]

```ts
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 -> principal.plans
    tenant: "org_id", // no standard exists; vendor specific, see below
  },
});
```

| Claim | Encoding accepted | Becomes | Rule |
| --- | --- | --- | --- |
| `roles` | `string[]` or SCIM complex values | `principal.roles` (global); an issuer that scopes roles to a tenant is read through a `claims.memberships` path, which yields memberships `{ 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, roles: [] }` of the active tenant, 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 | `principal.plans`, selected by name | Same trust rule as `roles`; grant with `to: plans.pro`, not by stuffing the name into `principal.roles` |

Reading SCIM complex values: `{ "value": "9f2c", "display": "Design", "type": "direct" }` contributes `9f2c`; `display` and `type` are not read. An Entra `_claim_names` / `_claim_sources` overflow (above roughly 200 groups) is not resolved. For large or nested directories, use app roles or a directory lookup in `context` ([authentication](/docs/concepts/authentication) single sign-on).

### Tenant claims [#tenant-claims]

There is no standard tenant claim. The `claims.tenant` path is configuration, and the resolver applies the [threat model](/docs/security/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](/docs/adapters/clerk) 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](/docs/adapters/supabase) refuses `user_metadata` |

### PermDock as a claim source [#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.heldRoles({ tenant })` and `permdock.memberships()` for the authenticated subject. PermDock follows the draft only through this existing endpoint and adds nothing draft-specific until a working group adopts it; the draft is on the [watch list](/docs/standards/watch-list).

## Mapping table [#mapping-table]

| Specification concept | PermDock concept |
| --- | --- |
| RFC 9068 `roles` claim | `principal.roles`; tenant-scoped roles through a `claims.memberships` path |
| RFC 7643 complex value `value` | The identifier PermDock matches on |
| RFC 7643 complex value `display` | Never used for matching |
| RFC 7643 complex value `type` (`direct`, `indirect`) | Not read; direct and indirect memberships map the same way |
| RFC 9068 `groups` claim | `Membership.team` (identifier), `via: 'group:<id>'` |
| RFC 9068 `entitlements` claim | Roles under the entitlements-are-roles rule |
| 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 [#sources]

* [RFC 9068: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens](https://www.rfc-editor.org/rfc/rfc9068.html), section 2.2.3.1 and section 7.2.
* [RFC 7643: SCIM Core Schema](https://www.rfc-editor.org/rfc/rfc7643.html), sections 4.1.2, 4.2 and 8.2; [RFC 7644: SCIM Protocol](https://www.rfc-editor.org/rfc/rfc7644.html).
* [IANA JSON Web Token Claims registry](https://www.iana.org/assignments/jwt/jwt.xhtml) entries `roles`, `groups`, `entitlements`.
* [draft-gazitt-oauth-authzen-claims-00](https://www.ietf.org/archive/id/draft-gazitt-oauth-authzen-claims-00.txt).
* [Microsoft Entra: configure group claims](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims#configure-groups-optional-claims), [Okta: add a groups claim](https://developer.okta.com/docs/guides/customize-tokens-groups-claim/main/).
* [SaaS tenancy and roles](/docs/concepts/tenancy#why-this-model) for the vendor rows.
