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:
| Source | Claim(s) emitted | Meaning |
|---|---|---|
permissions | permissions: [string] | The subject's effective RBAC permission names (distinct union of their roles' permissions). |
org | org: { id, slug, name } | The active organization for this token (see below). |
org_roles | org_roles: [string] | The org-scoped role names for the active org (not the union across the subject's orgs). |
metadata:<key> | <key>: string | One 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
| Situation | org / org_roles |
|---|---|
organization names an org the subject is an active member of | resolved for that org |
No organization; subject has exactly one active membership | resolved for that org (implicit) |
No organization; subject has ≥ 2 active memberships | omitted — the app must select |
organization names an org the subject is not a member of | omitted (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_metadataonly — neveruser_metadata. Only the admin-controlled bag is eligible. The user-writableuser_metadatabag 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_claimon anapp-bag user attribute — auto-emits that key fromapp_metadataexactly like ametadata:<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_metadatais never a claim source" invariant, now enforced at the schema layer too.token_claimon anorganizationattribute — emits the active org'sOrganization.metadataunder anorg_metadataclaim (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 theorgsource is enabled — it closes the gap where org-entity metadata previously reached no token.oidc_claimon anapp-bag user attribute — surfaces the value under the standard OIDC claim name (e.g. an attribute mapped tobirthdateemits abirthdateclaim rather than a custom-named one), gated by the standardprofile/email/address/phonescopes where the enrichment path can see them. Only theappbag is eligible, preserving theuser_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:
| Method | Path | Scope | Description |
|---|---|---|---|
PUT | /api/v1/applications/{clientId}/token-claims | tenant:clients.write | Enable claims-in-token; body { sources: ["permissions","org","org_roles"] } — sources may also include metadata:<key> entries. |
GET | /api/v1/applications/{clientId}/token-claims | tenant:clients.read | Read the current configuration. |
DELETE | /api/v1/applications/{clientId}/token-claims | tenant:clients.write | Disable 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
| Symptom | Cause / fix |
|---|---|
permissions present but org absent | The 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/authorize | The subject is not an active member of the requested organization (or it is not a valid org id). |
org_roles empty for a member | The 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 missing | The 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 all | The 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
organizationstring 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-controlledapp_metadatabag only — never the user-writableuser_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.