Product documentation
User metadata and custom attributes
Attach arbitrary per-user data to Thoryn users through two independent JSON bags — an admin-controlled app_metadata and an end-user-editable user_metadata — with the real endpoints, the JSON-object + byte-cap constraints, and an honest account of the one path (admin-controlled app_metadata only) by which a selected key can reach an ID token.
User metadata and custom attributes
A Thoryn user has fixed profile columns — email, given/family name, locale, and so on. User metadata is where you attach everything else: the tenant-defined, free-form attributes a real integration needs but the schema does not name. Plan tier, an external CRM id, a role hint, a UI theme preference, a marketing opt-in.
The model is the Auth0 split, and the distinction is a trust boundary, not a naming convention:
app_metadata— tenant/operator-controlled. The end user can never write it. This is where attributes your application trusts live: plan tier, external system ids, role hints.user_metadata— the user's own to edit (and an admin's too). This is where attributes the user owns live: display preferences, locale, opt-ins.
Both are stored as JSON objects on the user row and default to an empty object
({}). Each is written wholesale — a write replaces the entire bag with the
body you send.
Which bag? Ask "would it be a problem if the user could set this themselves?" If yes — a plan tier, an entitlement hint, anything your app grants on — it is
app_metadata. If no — a theme, a language preference — it isuser_metadata. The one place this boundary becomes load-bearing is token claims: onlyapp_metadatais ever eligible to become a claim, precisely because the user cannot forge it.
Metadata lives in identity-service, the federation member that owns Thoryn's own user rows. Tenant admins reach it through the customer plane (product-api behind the api-gateway), exactly like the rest of the users API; the hub stores no user data and has no metadata surface.
The two bags at a glance
| Bag | Who can write it | Who can read it | Typical use |
|---|---|---|---|
app_metadata | Tenant admin only (customer-plane / operator API) | Tenant admin | Plan tier, external ids, role/entitlement hints |
user_metadata | The user themselves and a tenant admin | The user (their own) and a tenant admin | Display preferences, locale, marketing opt-ins |
There is deliberately no self-service path to app_metadata: a user token can
neither read nor write it. That is a structural property of the surfaces, covered
under Security and privacy.
Manage a user's metadata (tenant admin)
Tenant admins manage both bags through the standard customer plane — the same console → BFF → api-gateway → product-api path used for the rest of the users API. product-api forwards each call to identity-service, which owns the storage and all validation.
Every call carries a bearer token minted for the tenant; the tenant comes from
the token's tnt claim and is forwarded as the upstream tenant, so a tenant only
ever reads and writes its own users' metadata. A {userId} that belongs to
another tenant is a 404 (never a 403) — the same no-existence-leak invariant
every tnt-scoped endpoint holds.
1. Read both bags
GET /api/v1/users/{userId}/metadata
Authorization: Bearer <token with tenant:users.read>The response returns both bags as real JSON objects (never double-encoded strings):
{
"appMetadata": { "planTier": "gold", "externalId": "crm-42" },
"userMetadata": { "theme": "dark", "locale": "nl-NL" }
}A user who has never had metadata set reads back two empty objects — the bags
default to {}, so a caller never has to null-check them.
2. Replace the app_metadata bag
Send the new bag as a JSON object. This is a whole-bag replace: the stored
app_metadata becomes exactly what you send, so read-modify-write if you only
mean to change one key.
PATCH /api/v1/users/{userId}/metadata/app
Authorization: Bearer <token with tenant:users.write>
Content-Type: application/json
{ "planTier": "gold", "externalId": "crm-42" }A successful write returns 200 with both bags (the same shape as the
GET).
3. Replace the user_metadata bag
Same shape, the other bag. An admin can write user_metadata too — useful for
back-office corrections — but the user can also maintain it themselves
(Self-service).
PATCH /api/v1/users/{userId}/metadata/user
Authorization: Bearer <token with tenant:users.write>
Content-Type: application/json
{ "theme": "dark", "locale": "nl-NL" }4. Seed metadata at user creation (optional)
POST /api/v1/users accepts optional appMetadata and userMetadata bags in
the create body, so you can provision a user with initial metadata in one call
rather than a create followed by a patch:
POST /api/v1/users
Authorization: Bearer <token with tenant:users.write>
Content-Type: application/json
{
"email": "ada@example.com",
"givenName": "Ada",
"appMetadata": { "planTier": "gold" },
"userMetadata": { "theme": "dark" }
}Both initial bags are validated the same way as a standalone metadata write — a non-object bag or an oversize bag fails the create with the matching error.
A user manages their own user_metadata
A signed-in user reads and writes only their own user_metadata through the
self-service "me" surface. app_metadata is not present here at all — it is
neither readable nor writable by the end user.
GET /api/v1/me/metadata → { "userMetadata": { ... } }
PATCH /api/v1/me/metadata Content-Type: application/json
body: { "theme": "dark", "locale": "nl-NL" }The caller is resolved from the access token's subject — there is no user id in
the path, because a user can only ever address their own bag. The PATCH is the
same whole-bag replace as the admin path and echoes the stored user_metadata
back.
The metadata model and its constraints
A few rules hold on every write, on every surface. They are enforced in one
place (UserMetadataService) so the customer plane, the self-service surface,
and the operator plane behave identically.
- The body must be a JSON object — a map of keys to values. An array, a
string, a number, a boolean, a bare
null, or an empty body are all rejected (metadata_not_object); malformed JSON is rejected separately (metadata_invalid_json). - Values inside the bag are unconstrained JSON. Only the top level must be
an object. Nested objects, arrays, strings, numbers, and booleans are all fine
as values —
{ "flags": ["beta"], "limits": { "seats": 5 } }is a valid bag. - A write replaces the whole bag. Despite the
PATCHverb (you are patching the user by swapping one of its bags), there is no key-level merge inside a bag. To change one key, read the bag, change it, and write it back. - Each bag is size-capped. The stored form is normalised to compact JSON and
measured; a bag over the cap is rejected with
metadata_too_large. The default is 32 KiB per bag (oauthy.identity.user-metadata.max-bytes-per-bag), and the cap is applied per bag, so the two bags together may hold up to twice that. See Configuration. - Stored as JSONB, normalised on write. The bags are real PostgreSQL
jsonbcolumns on the user row (migrationV46), re-serialised to canonical compact JSON before persistence — so what lands in the database is normalised, and the byte cap measures that stored form.
How metadata reaches a token
By default, metadata is not in any token. The metadata feature is storage
plus a management API; it does not put app_metadata or user_metadata into an
ID token or an access token on its own. If your application needs a user's
metadata, the direct answer is to read it from the
metadata API above.
There is exactly one path by which a metadata value can appear in a token, and it is opt-in, admin-controlled, and narrow. Thoryn's claims-in-token surface lets a client project selected metadata keys into its ID token:
- A client opts in by adding
metadata:<key>entries to its claims-in-tokensources— e.g.metadata:department— viaPUT /api/v1/applications/{clientId}/token-claims(see the token-claims reference). - Each
metadata:<key>entry emits one flat claim, named after the key (department), whose value is read from the user's bag at issuance time. - Only
app_metadatais eligible. The value is resolved from the admin-controlledapp_metadatabag;user_metadatais never a claim source — a user must not be able to forge their own claims by editing their own bag. This is the whole reason the two bags exist as separate trust domains. - Only scalar values project. A selected key whose value is a JSON scalar
(string, number, boolean) becomes a string claim; a key that is absent,
null, or holds an object/array is silently omitted. - The key must match
[A-Za-z0-9_-](1–64 chars) — it is also the emitted claim name. - Resolution fails open: if the metadata read fails, the metadata claims are omitted and the token still issues.
A second, config-free path exists via the entity attribute schema: an
app-bag attribute flagged token_claim auto-emits from app_metadata for every
enabled client without a per-client metadata:<key> entry, and one flagged with an
oidc_claim mapping surfaces under the standard OIDC claim name (scope-gated). Both
honour the very same app_metadata-only rule below — a user-bag attribute is never
eligible. See Claims in the token.
Claims fire on the ID token only — access tokens are never enriched this way, a deliberate PII guard. See Claims in the token for the full claims model, the active-organization rules, and the webhook path.
Practical consequence. To surface, say, a plan tier as a
planTierclaim: store it inapp_metadata(notuser_metadata), then addmetadata:planTierto the client's claims-in-token sources. Storing it inuser_metadatawill never make it a claim, by design.
Endpoint reference
Customer plane (tenant admin)
Through product-api, behind the api-gateway. Tenant scoped by the tnt claim; a
cross-tenant {userId} is a 404.
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /api/v1/users/{userId}/metadata | tenant:users.read | Read both bags for a user. |
PATCH | /api/v1/users/{userId}/metadata/app | tenant:users.write | Replace the app_metadata bag. Returns both bags. |
PATCH | /api/v1/users/{userId}/metadata/user | tenant:users.write | Replace the user_metadata bag. Returns both bags. |
POST | /api/v1/users | tenant:users.write | Create a user; optional appMetadata / userMetadata seed bags in the body. |
Self-service (the signed-in user)
identity-service's /api/v1/me surface; the caller is resolved from the token
subject.
| Method | Path | Description |
|---|---|---|
GET | /api/v1/me/metadata | Read the caller's own user_metadata bag (only). |
PATCH | /api/v1/me/metadata | Replace the caller's own user_metadata bag. app_metadata is not writable here. |
Operator plane / internal facade
These share the same UserMetadataService and the same JSONB columns as the
customer-plane path; they exist for the product-api facade and for Thoryn
operator tooling. Both require SCOPE_admin on a hub-validated JWT.
| Method | Path | Description |
|---|---|---|
GET | /internal/tenants/{tenantId}/users/{userId}/metadata | Tenant-parameterized facade product-api calls; {tenantId} is the sole tenant input. PATCH .../metadata/app and .../metadata/user write each bag. |
GET | /admin/users/{id}/metadata | Operator-plane surface; the tenant comes from the admin token's tenant_id claim. PATCH .../metadata/app and .../metadata/user write each bag. |
The generated users API reference documents the customer-plane request and response schemas in full.
Configuration
Metadata is used entirely through the API above — no deployment change is needed to store or read it, on either the managed SaaS or a self-managed install.
The one operator-tunable is the per-bag size cap, on identity-service. The
app_metadata / user_metadata columns are jsonb (no DB length constraint),
so the cap is enforced in the application layer and returns a structured error
rather than an opaque database failure.
oauthy:
identity:
user-metadata:
# Maximum size, in UTF-8 bytes of the compact JSON serialisation, of a
# single bag. A write whose bag exceeds this is rejected with a 413
# RFC 9457 `metadata_too_large`. Applied per bag, so the two bags together
# may hold up to twice this. Default 32 KiB.
max-bytes-per-bag: 32768See the identity-service configuration reference for the full property set.
Troubleshooting
All error responses are RFC 9457 problem-details (application/problem+json)
with a machine-readable errorCode extension and a sanitised detail — no bag
contents and no tenant ids are echoed back.
errorCode | Status | Cause / fix |
|---|---|---|
metadata_not_object | 400 | The body was valid JSON but not an object (an array, string, number, boolean, or null, or an empty body). Send a JSON object — a map of keys to values. |
metadata_invalid_json | 400 | The body was not well-formed JSON. Fix the JSON syntax. |
metadata_too_large | 413 | The bag exceeds max-bytes-per-bag (default 32 KiB). Trim the bag, or raise the operator cap. |
user_not_found | 404 | No user with that id exists in the caller's tenant. A cross-tenant id resolves here, too — the 404 is deliberate (no existence leak). |
Common situations:
- A
PATCHwiped keys I did not send. Working as designed — a write replaces the whole bag. Read the bag first, change the one key, and write the merged result back. - A user cannot see / cannot set
app_metadata. Correct.app_metadatahas no self-service route; only a tenant admin (or operator) writes it. An end-user token that reaches an admin metadata endpoint is rejected with403by the scope gate. - My metadata value is not appearing as a token claim. Confirm four things:
the client has the
metadata:<key>source enabled on its claims-in-token config; the value lives inapp_metadata(notuser_metadata); the value is a scalar (not an object or array); and you are reading the ID token, not the access token.
Security and privacy notes
app_metadatais admin-only by construction. There is no self-service write path for it: the admin endpoint is scope-gated (tenant:users.writeon the customer plane,SCOPE_adminon the operator plane), and the/api/v1/me/**surface exposes noapp_metadataroute at all. An end-user token has two independent layers of rejection between it andapp_metadata.- Only
app_metadatacan become a claim. The claims-in-token path resolvesmetadata:<key>fromapp_metadataexclusively, so a user can never promote their ownuser_metadatainto a trusted token claim. Keep anything your application authorizes on inapp_metadata. - Tenant-isolated. Every read and write is scoped to the caller's tenant — a
cross-tenant
{userId}is a404, never a leak. There is one row per user; a tenant can neither see nor overwrite another tenant's metadata. - Bounded by construction. Both bags are size-capped (default 32 KiB each, measured on the normalised stored JSON), so a single user row cannot be inflated without limit.
- No secrets in metadata — this is PII-and-config storage, not a vault. These bags are ordinary application data stored as plaintext JSONB. Do not put passwords, API keys, tokens, or any credential the platform must revoke en masse into them — those belong in a hashed, DB-stored table (the same shape as the client-secret and password-reset models), not a free-form bag. Treat the bags as personal data for GDPR purposes: they are included in a user's data export and erasure, and you should apply the same data-minimisation discipline you apply to any profile field.
Related
- Claims in the token — the claims-in-token surface
that can project a selected
app_metadatakey into an ID token. - Migrate your users to Thoryn — bulk import does
not carry metadata; seed it with
PATCH .../metadata/appand.../metadata/userafter the users exist. - Authorize with roles, permissions, and relationships — the RBAC surface, for entitlements you would otherwise be tempted to model as metadata role hints.
- Users API reference — the generated request/response schemas for the customer-plane metadata endpoints.