Skip to content

Product documentation

Claims in the token: permissions, org, and org_roles

Opt a client into product-api-resolved ID-token claims — RBAC permissions plus the active organization and its org-scoped roles — over the auto-wired claims-webhook path.

Claims in the token

A relying party can enrich its ID token with claims that Thoryn resolves for the authenticating subject at issuance time. The claims are computed in product-api (which owns the underlying data) and merged into the token by the hub over the auto-wired claims-webhook path — the hub itself never learns what the claims mean (ADR 2026-07-02-token-claims-enrichment-auto-wire.md).

Claims fire on the ID token only. Access tokens are never webhook-enriched — a PII guard. An app that needs these values in an access token reads them from the ID token, or calls the relevant management API.

Sources

A client opts into claims by listing sources on its claims-in-token configuration. Each source resolves to one or more claim keys:

SourceClaim(s) emittedMeaning
permissionspermissions: [string]The subject's effective RBAC permission names (distinct union of their roles' permissions).
orgorg: { id, slug, name }The active organization for this token (see below).
org_rolesorg_roles: [string]The org-scoped role names for the active org (not the union across the subject's orgs).
metadata:<key><key>: stringOne selected app_metadata key, projected as a flat scalar claim named after the key (see below).

Adding a source is a data change (the sources column is a JSON array) — no migration. Sources compose: a client may enable any combination, including any number of metadata:<key> entries.

The active organization (one active org per token)

A token names exactly one active organization (the Auth0 / WorkOS model). All of the selection logic lives in product-api, which owns organization membership; the hub only forwards the app's opaque selection.

Selecting it — the organization authorize parameter

Pass an organization (the org id) to /oauth2/authorize (or PAR). The hub confirms the authenticating subject is an active member of that org and threads the selection into the token. A non-member org is refused with access_denied at the authorize endpoint — never silently ignored.

Resolution rules

Situationorg / org_roles
organization names an org the subject is an active member ofresolved for that org
No organization; subject has exactly one active membershipresolved for that org (implicit)
No organization; subject has ≥ 2 active membershipsomitted — the app must select
organization names an org the subject is not a member ofomitted (and access_denied at authorize)

When an active org resolves, it also makes the permissions claim org-aware: the effective set becomes the union of the subject's global assignments and the active org's assignments. A different org's assignments never apply.

User metadata (metadata:<key>)

A client can also project selected user-metadata keys into its ID token. Each metadata:<key> source — e.g. metadata:department — emits exactly one flat claim named after the key (metadata:department produces a department claim), resolved from the subject's stored metadata at issuance time. A client may list as many metadata:<key> entries as it needs; each is an independent source.

The value is read from the user's admin-controlled app_metadata bag, which tenant admins manage through the user metadata and custom attributes API. Three invariants make a metadata claim safe to trust:

  • app_metadata only — never user_metadata. Only the admin-controlled bag is eligible. The user-writable user_metadata bag is never a claim source, so a user cannot forge their own token claims by editing their own metadata. That trust boundary is the whole reason the two bags are separate.
  • Scalars only. A key whose value is a JSON scalar (string, number, or boolean) is emitted as a string claim — the value is coerced to text. A key that is absent, null, or holds an object or array is silently omitted, never partially projected.
  • Fails open. If the metadata read fails — identity-service unreachable, an unknown subject, or a federated user with no Thoryn-managed metadata — the metadata claims are dropped and the token still issues, the same fail-open posture the rest of this surface holds.

The <key> must match [A-Za-z0-9_-] (1–64 characters); it is also the emitted claim name. Like every other source, enabling a metadata:<key> entry is a data change (it joins the same sources array) — no migration.

Schema-driven claims (token_claim / oidc_claim)

Beyond the per-client metadata:<key> sources above, the tenant's entity attribute schema can flag an attribute so it flows to the token automatically, for every enabled client, without any per-client configuration. The schema is per-environment, so a token resolves only its own environment's schema.

  • token_claim on an app-bag user attribute — auto-emits that key from app_metadata exactly like a metadata:<key> source (same flat-scalar, app_metadata-only, fail-open rules), but you don't have to list it per client. user-bag attributes are never eligible — the same "user_metadata is never a claim source" invariant, now enforced at the schema layer too.
  • token_claim on an organization attribute — emits the active org's Organization.metadata under an org_metadata claim (a nested object of the flagged keys, e.g. { "org_metadata": { "plan": "enterprise" } }). This resolves whenever an active org resolves (see above), independent of whether the org source is enabled — it closes the gap where org-entity metadata previously reached no token.
  • oidc_claim on an app-bag user attribute — surfaces the value under the standard OIDC claim name (e.g. an attribute mapped to birthdate emits a birthdate claim rather than a custom-named one), gated by the standard profile / email / address / phone scopes where the enrichment path can see them. Only the app bag is eligible, preserving the user_metadata-never-emits invariant.

Flagging an attribute is a schema-config change (no client re-configuration, no migration).

Endpoint reference

The claims-webhook registration and the toggle are managed per client:

MethodPathScopeDescription
PUT/api/v1/applications/{clientId}/token-claimstenant:clients.writeEnable claims-in-token; body { sources: ["permissions","org","org_roles"] }sources may also include metadata:<key> entries.
GET/api/v1/applications/{clientId}/token-claimstenant:clients.readRead the current configuration.
DELETE/api/v1/applications/{clientId}/token-claimstenant:clients.writeDisable claims-in-token.

The hub calls product-api's POST /internal/claims-enrichment at ID-token issuance; that endpoint is network-isolated (never gateway-routed) and authenticated by an HMAC signature over the request bytes.

Configuration

No client-side configuration beyond the toggle above. Org membership and org-scoped roles are managed through the B2B Organizations API; the app_metadata values a metadata:<key> source projects are managed through the user metadata API.

Troubleshooting

SymptomCause / fix
permissions present but org absentThe subject has ≥ 2 orgs and no organization was passed, or the requested org isn't an active membership — the claim is omitted, never guessed.
access_denied at /oauth2/authorizeThe subject is not an active member of the requested organization (or it is not a valid org id).
org_roles empty for a memberThe subject has no role assignments scoped to the active org (global roles are not org roles; their permissions still flow into permissions).
A metadata:<key> claim is missingThe key isn't set in the subject's app_metadata (metadata claims read from app_metadata only, never user_metadata), its value isn't a scalar, or the metadata read failed and fail-open dropped it — the claim is omitted, never guessed.
No enriched claims at allThe client hasn't enabled claims-in-token, or the config is disabled.

Security notes

  • Claims resolve from live data at issuance — revoking a role or removing a membership takes effect on the next token without re-minting anything.
  • The hub is product-agnostic: it forwards the opaque organization string and merges whatever product-api returns. It stores no org, role, or permission data.
  • Membership validation on the authorize path fails open on a product-api outage (login proceeds; the org claim is still omitted for a non-member), so a broker blip never blocks sign-in.
  • A metadata:<key> source reads the admin-controlled app_metadata bag only — never the user-writable user_metadata — so a user can never mint a trusted claim by editing their own metadata. The metadata read also fails open: a read error drops the metadata claims, never the login.