Skip to content

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 is user_metadata. The one place this boundary becomes load-bearing is token claims: only app_metadata is 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

BagWho can write itWho can read itTypical use
app_metadataTenant admin only (customer-plane / operator API)Tenant adminPlan tier, external ids, role/entitlement hints
user_metadataThe user themselves and a tenant adminThe user (their own) and a tenant adminDisplay 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 PATCH verb (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 jsonb columns on the user row (migration V46), 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-token sources — e.g. metadata:department — via PUT /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_metadata is eligible. The value is resolved from the admin-controlled app_metadata bag; user_metadata is 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 planTier claim: store it in app_metadata (not user_metadata), then add metadata:planTier to the client's claims-in-token sources. Storing it in user_metadata will 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.

MethodPathScopeDescription
GET/api/v1/users/{userId}/metadatatenant:users.readRead both bags for a user.
PATCH/api/v1/users/{userId}/metadata/apptenant:users.writeReplace the app_metadata bag. Returns both bags.
PATCH/api/v1/users/{userId}/metadata/usertenant:users.writeReplace the user_metadata bag. Returns both bags.
POST/api/v1/userstenant:users.writeCreate 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.

MethodPathDescription
GET/api/v1/me/metadataRead the caller's own user_metadata bag (only).
PATCH/api/v1/me/metadataReplace 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.

MethodPathDescription
GET/internal/tenants/{tenantId}/users/{userId}/metadataTenant-parameterized facade product-api calls; {tenantId} is the sole tenant input. PATCH .../metadata/app and .../metadata/user write each bag.
GET/admin/users/{id}/metadataOperator-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: 32768

See 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.

errorCodeStatusCause / fix
metadata_not_object400The 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_json400The body was not well-formed JSON. Fix the JSON syntax.
metadata_too_large413The bag exceeds max-bytes-per-bag (default 32 KiB). Trim the bag, or raise the operator cap.
user_not_found404No 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 PATCH wiped 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_metadata has no self-service route; only a tenant admin (or operator) writes it. An end-user token that reaches an admin metadata endpoint is rejected with 403 by 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 in app_metadata (not user_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_metadata is admin-only by construction. There is no self-service write path for it: the admin endpoint is scope-gated (tenant:users.write on the customer plane, SCOPE_admin on the operator plane), and the /api/v1/me/** surface exposes no app_metadata route at all. An end-user token has two independent layers of rejection between it and app_metadata.
  • Only app_metadata can become a claim. The claims-in-token path resolves metadata:<key> from app_metadata exclusively, so a user can never promote their own user_metadata into a trusted token claim. Keep anything your application authorizes on in app_metadata.
  • Tenant-isolated. Every read and write is scoped to the caller's tenant — a cross-tenant {userId} is a 404, 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