Skip to content

Organizations & roles

Organizations let you group users into tenants — a company, a team, a workspace — and give each member a role that decides what they can do. A user can belong to several organizations and has one active organization at a time; the active org, the member’s role, and their custom permissions all ride the session token, so your app authorizes without an extra round-trip.

Organizations are configured in the Torii dashboard (Project → Organizations) and consumed in your app through the useOrganizations hook, the <OrganizationList> component, and the useAuth().require() authorization check.

  • Organization — a named group of users. Each user in an org is a member with exactly one role. A user can be a member of many orgs.
  • Active organization — the one org a session is currently acting in. It is carried in the token’s o claim; switching re-mints the token. With no active org, authorization checks return false.
  • Role — a member’s role in an org, identified by a prefixed key. The built-in set ships two roles: org:administrator (the creator role, granted to whoever creates the org) and org:member (the default role, assigned to invitees). Each role has a human-readable role_name to display.
  • Permissions come in two kinds:
    • System permissions (org:sys_*) are built-in capabilities — org:sys_profile:manage, org:sys_profile:delete, org:sys_domains:read, org:sys_domains:manage, org:sys_memberships:read, org:sys_memberships:manage. They are role-implied and never tokenized, so you check the member’s role, not the permission.
    • Custom permissions (org:<feature>:<action>, e.g. org:reports:view or org:billing:manage) are ones you define. They are tokenized, so your app can check them client-side.
  • Feature — a named capability you define (e.g. reports, billing) that groups its custom permissions. Its actions become the slug’s last segment: feature reports + action vieworg:reports:view.
  • Role set — the bundle of roles an organization draws from. A role set marks one role as the creator role (granted to the org’s creator) and one as the new-member default (assigned to invitees without an explicit role). Each environment has a default role set that new orgs bind to.

Organizations are off by default per environment. Turn them on under Project → Organizations → Settings with the environment switcher set to the environment you want.

The enable toggle is a hard kill switch: while it is off, every /_torii/organizations/** endpoint returns 403, and no organization context (active org, role, or permissions) is written into session tokens for that environment. Turning it on restores org endpoints and starts stamping org context into tokens on the next sign-in or session refresh.

Configuring roles & permissions in the dashboard

Section titled “Configuring roles & permissions in the dashboard”

Under Project → Organizations, per environment:

  1. Roles — create the roles your orgs use, each with a key (org:<name>), a display name, and the permissions it holds. One role is the creator role; one is the new-member default.
  2. Features & custom permissions — define a feature (e.g. reports) and the actions on it (view, export). Each action becomes an assignable custom permission slug org:reports:export that you can grant to roles.
  3. Role sets — bundle roles into a set and pick the creator + default roles. New organizations bind to the environment’s default role set at creation (falling back to the built-in set when none is configured).

System permissions (org:sys_*) are fixed and always available to the roles that hold them — you don’t define these.

Read and mutate organizations with the useOrganizations hook. It reads the org list from the cached session payload, so there’s no extra request on render.

import { useOrganizations } from '@torii-js/torii-react';
function OrgMenu() {
const { organizations, activeOrganization, createOrganization } = useOrganizations();
return (
<div>
<p>Active: {activeOrganization?.name ?? 'none'}</p>
<ul>
{organizations.map((org) => (
<li key={org.id}>{org.name} — {org.role_name ?? org.role}</li>
))}
</ul>
<button onClick={() => createOrganization('Acme Inc')}>New organization</button>
</div>
);
}

For a drop-in picker (list, create, and switch in one surface), render <OrganizationList> instead of building your own.

switchActiveOrganization(id) re-mints the access token with the new org in the o claim and persists the choice on the session, so a reload keeps the same active org:

const { organizations, activeOrganizationId, switchActiveOrganization } = useOrganizations();
<select value={activeOrganizationId ?? ''} onChange={(e) => switchActiveOrganization(e.target.value)}>
{organizations.map((org) => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>

Member and invitation management is available over the client API rather than a dedicated component. The endpoints live under /_torii/organizations/{id} — see the Client API reference for exact request and response shapes:

  • List members / rolesGET .../members, GET .../roles.

  • Invite a memberPOST .../invitations with { email, role } (requires the org’s creator/admin role, org:administrator in the built-in set). The response always includes the invitation token for a copyable share link, plus an email_delivery field: dispatched when an email was sent, not_configured when it was not. If you configure an invitation accept URL for the environment (dashboard → Organizations settings), Torii also emails the invitee a link to that URL with the ticket appended (?__torii_ticket=<token>). Email is best-effort and additive: a delivery failure never fails the invite, and the share link always works as a fallback. Invitation creation is rate-limited to 50 per hour per organization; past that the endpoint returns 429 (organization_invitation_rate_limited): contact [email protected] to raise the limit.

  • Redeem an invitation — a signed-in invitee accepts with POST /_torii/organizations/invitations/accept ({ token }). For a ready-made landing that handles both a brand-new invitee (sign up + auto-join) and an already signed-in user, point your accept route at <AcceptInvitation>; it reads the ticket from the URL for you. The lower-level <InvitationSignUp> card handles just the new-user signup step.

  • Change a member’s role / remove a memberPATCH / DELETE .../members/{userId}. Changing a role requires the admin role; a member can always remove themselves. An org keeps at least one holder of the creator role, so the last administrator can’t be demoted or removed.

Every organization, and every membership in it, carries a metadata bag you define: plan tier, an external CRM id, an onboarding step, a seat entitlement. Storing it on the organization means you don’t keep a parallel table keyed by our organization id just to answer “what plan is this tenant on”.

There are two bags on each, and the difference between them is who can read them:

Bag Client (/_torii/**) Backend (/api/server/v1/**)
Organization public_metadata read by any member, written by the admin role read + write
Organization private_metadata never returned read + write
Membership public_metadata not returned read + write
Membership private_metadata not returned read + write

private_metadata is for state your users must not see (an internal risk score, a billing customer id). It is not merely hidden by the UI: the client endpoints never read the column, so there is no response shape that can carry it, and an architecture test pins which files are allowed to.

Membership bags are backend-only by design, both ways. A seat entitlement or an internal flag on one member is your system’s state, not something an org admin sets from a browser, and the client’s members list is unpaginated, so putting a metadata bag on every row there would bloat it for a large tenant. Read them from GET /api/server/v1/organizations/{id}/members, which is cursor-paginated.

Each bag has its own 8 KB budget, measured on the stored result rather than on the patch you send. The organization’s bags and each membership’s bags are separate rows, so nothing you store on one competes with another.

An organization’s public_metadata is on the single-organization read, not on the organization summaries in /_torii/users/me: those ride the session payload the SDK loads on every cold start, so keeping metadata off them keeps that payload small.

const { getOrganization, updateOrganization } = useOrganizations();
// Read one organization's bag.
const org = await getOrganization(orgId);
console.log(org.public_metadata.plan);
// Write it (admin role only). The patch is DEEP-MERGED server-side.
await updateOrganization(orgId, { publicMetadata: { limits: { seats: 9 } } });

Merge semantics are the same as the user metadata endpoint, so you learn them once:

  • Keys in the patch override the stored value.
  • Keys absent from the patch are left alone.
  • A key sent as null is removed from the bag.
  • Nested objects merge recursively, so { limits: { seats: 9 } } does not discard a sibling limits.projects.

updateOrganization also renames, and the two compose in one request:

await updateOrganization(orgId, { name: 'Acme Inc', publicMetadata: { tier: 'gold' } });

Sending an empty patch is a 400, not a silent success, so a mistyped field name surfaces instead of looking like it saved.

Your server writes either bag with a secret key, for any organization in the key’s environment, with no membership required. The environment is resolved from the key, so there is no environment id in the path.

Terminal window
curl -X PATCH https://api.toriiauth.eu/api/server/v1/organizations/$ORG_ID/metadata \
-H "Authorization: Bearer $TORII_SECRET_KEY" \
-H 'Content-Type: application/json' \
-d '{"publicMetadata":{"plan":"enterprise"},"privateMetadata":{"crmAccountId":"acct_123"}}'

Membership metadata works the same way, per member:

Terminal window
curl -X PATCH \
https://api.toriiauth.eu/api/server/v1/organizations/$ORG_ID/members/$USER_ID/metadata \
-H "Authorization: Bearer $TORII_SECRET_KEY" \
-H 'Content-Type: application/json' \
-d '{"publicMetadata":{"seat":"billable"}}'

Each bag is tri-state on these endpoints too: omit it to leave it untouched, or send an object to deep-merge into it. The same 8 KB per-bag budget applies to the merged result, and a request naming neither bag is a 400 rather than a silent no-op. GET /api/server/v1/organizations and GET .../organizations/{id}/members are both cursor-paginated (?limit, ?cursor) and return both bags. See the Server API reference for the full shapes.

The membership list also reads from the user’s side: GET /api/server/v1/users/{userId}/organizations returns the organizations one user belongs to, with their role in each and that membership’s bags (an organization’s own bags come from reading the organization). It is the mirror image of the members list and is cursor-paginated the same way. To go the other direction and find users by organization, POST /api/server/v1/users/search takes an organizationId, which combines with the other filters rather than replacing them:

Terminal window
curl -X POST https://api.toriiauth.eu/api/server/v1/users/search \
-H "Authorization: Bearer $TORII_SECRET_KEY" \
-H 'Content-Type: application/json' \
-d '{"organizationId": "'"$ORG_ID"'", "statuses": ["active"]}'

Because the active org’s role and custom permissions ride the token, your app authorizes locally. Use useAuth().require() imperatively or <Show> declaratively.

import { useAuth } from '@torii-js/torii-react';
function BillingActions() {
const { require } = useAuth();
return (
<>
{/* Custom permission — tokenized, checked client-side. */}
{require({ permission: 'org:billing:manage' }) && <ChargeButton />}
{/* System capability — check the role that holds it. */}
{require({ role: 'org:administrator' }) && <DeleteOrgButton />}
</>
);
}
import { Show } from '@torii-js/torii-react';
<Show when={{ permission: 'org:reports:view' }} fallback={<NoAccess />}>
<ReportsPage />
</Show>

require() returns false when there is no active organization, when the token is missing, or when the check fails — so it’s safe to call unconditionally.

Rule of thumb: check custom permissions with { permission } and system capabilities with { role }. Passing a system slug (org:sys_*) to { permission } always returns false (with a dev warning) — system permissions aren’t in the token by design.

Outside React, decode the verified access token. The active org and role are plain fields on the nested o claim:

// After verifying the Torii access token:
const orgId = claims.o?.id ?? null; // active organization id
const role = claims.o?.rol ?? null; // active-org role key, e.g. "org:administrator"
if (role === 'org:administrator') {
// ...
}

Custom permissions are encoded compactly across fea (enabled features), o.per (verb dictionary), and o.fpm (per-feature bitmasks). Rather than re-implement that decoding, enforce permissions on your backend against the authoritative record — client-side checks are a UX convenience, not a security boundary. The server always re-checks.

At sign-in and on every session refresh, Torii resolves the active org, the member’s role, and their held custom permissions for that environment and writes them into the token’s o claim (plus the top-level fea feature list). A role or permission change therefore takes effect on the user’s next token refresh — up to the access-token lifetime, not instantly. Switching the active org re-mints the token immediately.

For a B2B app where every user must belong to an organization, enable membership required on the environment and wrap your app in <OrganizationRequired>. When the setting is on and the signed-in user has no active org, the gate renders the org picker (or your own fallback) instead of your app; picking an org (or creating one and then switching to it — creating alone does not activate it) clears the gate live. The gate also honours the allow user-created organizations setting to decide whether the picker offers a create affordance.

The full component suite is shipped and exported:

For custom UIs, the data layer is useOrganizations and useOrganizationInvitations.

Some dashboard organization settings are saved but not yet enforced at runtime: membership limits, verified domains, auto-creating a first organization, user-created-org limits, and naming templates. The enable toggle, the default role set (applied when an org is created), and membership-required (via <OrganizationRequired>) act today. Treat the rest as configuration staged for a later release, not active policy.