Skip to content
Torii docs

User metadata

Every user carries three free-form JSON metadata bags. They differ only in who can read and write them: pick the bag that matches the trust boundary of the data you are storing.

BagRead in SDKWrite in SDKRead on backendWrite on backendIn a JWT?Use for
publicMetadataoptionalPlan tier, role; the user may see it, but only your server may set it.
privateMetadataStripe customer id, internal flags; server-only secrets.
unsafeMetadataoptionalUI preferences, onboarding step; the user may change it from the browser.

Each write is size-checked and rejected with 400 if it exceeds the cap:

  • publicMetadata and unsafeMetadata: 512 bytes each (they are eligible to be projected into a JWT, which has a strict size budget).
  • privateMetadata: 4096 bytes (never enters a JWT).

Store anything larger in your own database and keep only a reference here.

publicMetadata and unsafeMetadata are part of the /me profile. Read them with useUserProfile:

import { useUserProfile } from '@torii-js/torii-react';
function PlanBadge() {
const { profile } = useUserProfile();
const plan = profile?.publicMetadata.plan ?? 'free';
return <span>{String(plan)}</span>;
}

privateMetadata is intentionally absent from the SDK: it is only returned by the backend API.

unsafeMetadata is the only bag the end-user can write. Updates deep-merge: send only the keys you want to change, and set a key to null to remove it.

const { updateProfile } = useUserProfile();
// Merges into the existing bag, other keys are preserved.
await updateProfile({ unsafeMetadata: { theme: 'dark' } });
// Remove a key.
await updateProfile({ unsafeMetadata: { theme: null } });

Writing public and private metadata from your backend

Section titled “Writing public and private metadata from your backend”

Use the secret-key backend API. Each bag is independent: omit a bag to leave it unchanged, or send an object to deep-merge into it (a key set to null is removed).

Terminal window
curl -X PATCH https://api.torii.so/api/server/v1/users/$USER_ID/metadata \
-H "Authorization: Bearer $TORII_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"publicMetadata": { "plan": "pro", "role": "admin" },
"privateMetadata": { "stripeId": "cus_123" }
}'

You can also seed any of the three bags when creating a user via POST /api/server/v1/users.

$USER_ID above is the same identifier the SDK exposes as user.id. One id spans all three surfaces, so it is the key your backend joins on:

  • useUser().user.id in the SDK,
  • the sub claim of every access token,
  • the {userId} path segment on /api/server/v1/users/{userId}.
import { useUser } from '@torii-js/torii-react';
function Profile() {
const { user } = useUser();
// user.id === the JWT `sub` === the BAPI user id your backend stores.
return <code>{user?.id}</code>;
}

Writing private metadata right after sign-up

Section titled “Writing private metadata right after sign-up”

A common pattern is to enrich a new user from your backend the moment they sign up (create a Stripe customer, write the id into privateMetadata, …). Wire it with the SDK’s onSignupSuccess callback, then let your backend do the privileged write with its secret key:

const { getToken } = useAuth();
<SignUp
onSignupSuccess={async () => {
const token = await getToken(); // the user's session JWT
await fetch('https://api.your-app.com/provision', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
}}
/>
end-user SDK (browser) your backend Torii
| sign up ------> onSignupSuccess()
| getToken() ---------> POST /provision (Bearer <session JWT>)
| | verify JWT ----------> GET /.well-known/jwks.json
| | userId = sub
| | PATCH /api/server/v1/users/{sub}/metadata
| | { "privateMetadata": { "stripeId": "cus_…" } } (sk_…)
| <----------------------- 200 ok

A JWT template projects metadata into the access token via shortcodes. Reference a whole bag or a single key:

{
"plan": "{{ user.public_metadata.plan }}",
"theme": "{{ user.unsafe_metadata.theme || \"light\" }}"
}

privateMetadata is deliberately not available as a shortcode. The template is size-checked when you save it; because the JWT-eligible bags are capped on write, they can’t overflow the ~1.2 KB custom-claims budget at mint time. A shortcode that resolves to nothing omits its claim unless you give it a || "default".