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.
Concepts
Section titled “Concepts”- 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
oclaim; switching re-mints the token. With no active org, authorization checks returnfalse. - 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) andorg:member(the default role, assigned to invitees). Each role has a human-readablerole_nameto 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:viewororg:billing:manage) are ones you define. They are tokenized, so your app can check them client-side.
- System permissions (
- Feature — a named capability you define (e.g.
reports,billing) that groups its custom permissions. Its actions become the slug’s last segment: featurereports+ actionview→org: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.
Enabling organizations
Section titled “Enabling organizations”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:
- 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. - Features & custom permissions — define a feature (e.g.
reports) and the actions on it (view,export). Each action becomes an assignable custom permission slugorg:reports:exportthat you can grant to roles. - 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.
Managing organizations in your app
Section titled “Managing organizations in your app”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.
Switching the active org
Section titled “Switching the active org”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>Members & invitations
Section titled “Members & invitations”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 / roles —
GET .../members,GET .../roles. -
Invite a member —
POST .../invitationswith{ email, role }(requires the org’s creator/admin role,org:administratorin the built-in set). The response always includes the invitation token for a copyable share link, plus anemail_deliveryfield:dispatchedwhen an email was sent,not_configuredwhen 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 returns429(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 member —
PATCH/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.
Organization metadata
Section titled “Organization metadata”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.
Reading and writing from your app
Section titled “Reading and writing from your app”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
nullis removed from the bag. - Nested objects merge recursively, so
{ limits: { seats: 9 } }does not discard a siblinglimits.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.
Writing from your backend
Section titled “Writing from your backend”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.
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:
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:
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"]}'Authorizing with roles and permissions
Section titled “Authorizing with roles and permissions”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.
Other stacks (read the claim)
Section titled “Other stacks (read the claim)”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 idconst 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.
How it rides the token
Section titled “How it rides the token”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.
Requiring membership
Section titled “Requiring membership”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.
Self-service surfaces
Section titled “Self-service surfaces”The full component suite is shipped and exported:
<OrganizationList>— the active-org picker.<OrganizationSwitcher>— the compact nav dropdown.<CreateOrganization>— a create-org form.<OrganizationProfile>— tabbed rename / members / invitations.<OrganizationRequired>— the membership gate.
For custom UIs, the data layer is useOrganizations
and useOrganizationInvitations.
Not yet enforced
Section titled “Not yet enforced”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.