Organizations
Give each of your business customers a first-class tenant: an organization with its own members and the roles each holds there, its own authentication policy, and org context minted into every token. Organizations are a container switched on beside your access model, so flat or hierarchical access control keeps running inside them.
Overview
An organization is a tenant object inside an Environment. It represents one of your business customers. Membership is admission, kept as a record of its own, and a member's roles are ordinary assignments at the organization's node or beneath it: none, one, or several. So the same identity can be an Admin at Acme and a Viewer at Globex, and belong to Initech holding nothing yet. That per-organization role set is the case environment-wide (flat) RBAC cannot express, and it is what organizations exist for. Organizations are not an access model. They are a container you switch on beside the model, and the Environment keeps running flat or hierarchical access control inside it. Roles, permissions, memberships, invitations, and each organization's authentication policy all live in Canopy; your application keeps no role tables of its own.
One organization per customer account
The intended mapping is direct: when a customer signs up for your product, your backend creates their account row in your database and provisions one Canopy organization alongside it. The organization is the anchor for that customer's roles; your account row stays the source of truth for everything else (billing, settings, domain data). Store the organization id on your account row and the two never drift.
Enabling the Container
Organizations are scoped to an Environment and switched on per Environment with one call, independent of the access model. Enabling reserves the tier directly under the Environment's root for organizations: every organization is a node there, and nothing else may be created at that level. The Environment stays flat or hierarchy; nothing about the model changes. In the Console the switch is the Organizations card on the Access Control Overview; until it is on, the Organizations page says so and links back to it.
curl -X PUT https://auth.canopy-io.com/portal/v1/accounts/{account}/applications/{app}/environments/{env}/organizations-feature \ -H "Authorization: Bearer <console token>" \ -H "Content-Type: application/json" \ -d '{ "enabled": true }'
Both models run inside the container. A flat Environment holds memberships and roles at the organization node only: a member's roles there apply across the organization. A hierarchy Environment hangs each organization's own tree beneath it, so roles can be scoped to a region or team inside the tenant (see the section on hierarchy below). Switching between the two later works the same way it does without organizations; the organizations and their memberships stay.
Organizations is a Pro plan feature. The gate applies to the management surface only: enabling the container, creating organizations, managing members and invitations, and setting policy. The runtime the feature mints keeps working regardless of billing state. Tokens keep carrying org claims, permission evaluation keeps answering, and your users keep signing in. A billing lapse never breaks your production logins.
Provisioning from Your Signup Flow
Provision from your own signup flow with your secret API key. Create the organization, then add the signing-up user as its first member with your owner-equivalent role. Pass your own account id as external_id: it is unique per Environment, so a repeated create answers 409 organization.external_id_conflict instead of a second organization, and GET /api/v1/organizations?external_id=... finds the tenant without your having stored the organization id first. metadata stays free-form for anything else.
curl -X POST https://auth.canopy-io.com/api/v1/organizations \ -H "X-API-Key: cnpy_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Corp", "external_id": "acct_8231" }'
{ "data": { "id": "7f3d2c1b-9a80-4e5f-b6a7-c8d9e0f1a2b3", "name": "Acme Corp", "slug": "acme-corp", "external_id": "acct_8231", "member_count": 0, "pending_invite_count": 0, "metadata": {} } }
The slug is display-only, derived from the name at create and frozen afterwards. Address organizations by id everywhere, and by external_id when all you have is your own account id. If the create succeeded but storing the id on your side failed, the same call again answers 409 and the lookup returns the organization: create-then-store is safe to retry.
First Tenant at Signup
The most common flow on a multi-tenant app: a person registers, and their first session should already act inside the organization created for them. The Environment can do this for you: in the Developer Console, under Tenant → Authentication → Self-signup, expand the Environment's row, turn on Create an organization on sign-up and choose the Role the creator receives (one that carries canopy:organization.manage, so the person can run their organization from the portal). From then on every POST /v1/identity/auth/register creates an organization named after the person (or the local part of their email), adds them as its first member with that role, and their first session after email verification already carries org_id and org_roles. The organization commits together with the identity, so a sign-up that cannot create one fails as a whole: a plan cap or a role deleted since it was chosen refuses the registration rather than leaving the person in no organization. Invited identities are untouched, and the switch cannot be turned on without a role. When you would rather name the tenant yourself, pass your own account id as external_id, or create it only after a step of your own, make the calls below from your backend instead, in this order. The examples use the backend-for-frontend shape, where your server holds the secret API key and receives refresh tokens in the body.
The switch is the step teams skip, which ships a session acting in no organization until the next login. A later login can name the organization to start in with the organization field instead. When every person in the Environment should belong to a tenant, turn on Require organization membership under Tenant → Authentication → Sign-in methods: a sign-in, refresh or SSO completion for someone in no organization then answers 403 auth.organization_membership_required until they are added to one, and removing someone's last membership ends their session at its next refresh. The Console shows how many current members that would refuse before you turn it on. The switch assumes the organization exists before a person's first sign-in, which is true of Create an organization on sign-up and of a backend that creates the organization straight after register with its API key. If your app creates the organization in an onboarding step that needs the person signed in, leave it off: their first sign-in would be refused before that step could run. GET /v1/identity/auth/config publishes both switches, so your sign-in and sign-up screens can say what will happen.
Members & Invitations
Two paths put a person inside an organization, and both end in the same place: an active membership there, with a role at the organization node when one was given.
Add an existing identity
For a person who already exists in the Environment, add them directly. role_id is optional: with it the member holds that role from the start, at the organization node or at the office or team node_id names, for the effective_from to effective_to window when you give one; without it they belong and hold nothing until a role is assigned, which is how a person who should act only at one office or team is added. Adding needs no consent from the person: any identity with an active membership in the Environment can be added by id, and the member row answers with their email and name. Adding someone who already belongs answers 409 organization.member_exists: give them the role through the assignment routes instead. That is why only you can do it, with your API key or from the Developer Console: an organization's own administrator, signed in, is refused with 403 organization.member_add_requires_invite and invites by email instead, so the person accepts before joining. A membership added for someone does not move where their next login starts: without an organization named, a login starts in the first organization they joined.
curl -X POST https://auth.canopy-io.com/api/v1/organizations/{org_id}/members \ -H "X-API-Key: cnpy_..." \ -H "Content-Type: application/json" \ -d '{ "identity_id": "7d3f9b2e-4c1a-4e8b-9f2d-1a6c5e0b8f34", "role_id": "4a9c1e7f-2b8d-4f3a-9c6e-5d1b8a2f7e09" }'
Invite by email
Invite by email when the person should agree to joining, and for anyone with no identity yet. An invitation names where its role is held, the organization node by default or an office or team beneath it with node_id, and optionally the effective_from to effective_to window; with no role_id it admits a member holding nothing, which only the organization node allows. Accepting creates whatever is missing in one step: the identity, its access to the Environment, the membership and the role. Someone who already has an identity in this Environment receives a request to join: accepting is their consent, proven by their existing password or by accepting while signed in, and an organization that requires single sign-on, or a person with a second factor enrolled, accepts only while signed in. Inviting someone who is already a member answers 409 organization.member_exists. Roles are changed, added and removed through the assignment routes (PATCH /api/v1/assignments/:id with the assignment id the member row lists; the organization's own routes under /api/v1/organizations/{org_id}/assignments take the organization node too), and removing a member (DELETE .../members/:identityId) deactivates the membership and every role held at the organization's nodes with it: the identity itself, and its other memberships, are untouched. The member row answers with status, source, joined_at and roles, every assignment held in the organization with its node and effective window; GET .../members?status=deactivated lists the people who left.
curl -X POST https://auth.canopy-io.com/api/v1/organizations/{org_id}/invites \ -H "X-API-Key: cnpy_..." \ -H "Content-Type: application/json" \ -d '{ "email": "dana@acme.com", "role_id": "4a9c1e7f-2b8d-4f3a-9c6e-5d1b8a2f7e09", "first_name": "Dana", "last_name": "Dual" }'
With hierarchy on inside the container, a role assignment at a node beneath an organization is a scoped grant on top of the membership, and it requires the membership first: assigning someone at Acme's EU team before they are a member of Acme, or after their membership was deactivated, is refused with 409 organization.membership_required. The same refusal stops a single sign-on arrival placed beneath it and a grant moved there, whenever the membership is not active. An invitation is the exception: accepting one admits the person first, so an invitation may name an office or team directly. A grant at the organization node itself needs no step before it: it admits its holder. Removing the membership removes every role held in the organization with it, and an organization keeps its last administrator: the last active holder of canopy:organization.manage at the organization node can neither be removed nor have that role taken away (409 organization.last_admin).
Hierarchy Inside an Organization
With the hierarchy model on inside the container, every organization is the top of its own tree. The Environment's hierarchy_schema is rooted at the reserved canopy:organization type, and its levels (say Region, then Team) hang beneath each organization rather than beneath the Environment root. Depth is counted from the organization down, and a node can never be moved from one organization into another.
Org Context in the Token
With the container on, identity access tokens carry three extra claims: org_id (the organization the session is acting in) org_roles (the names of the roles held at its node, sorted; [] for a member who holds none there) and org_role_ids (the same roles' ids, in the same order). A name is unique only among the Environment's roles or among one organization's own, so compare by id when a role must be told apart across organizations. They are minted together at login, resolved from the session's active organization, with the membership re-verified on every mint. A login starts in the organization the request names in its organization field, or, without one, in the identity's only organization or the first one it joined. An identity that belongs to no organization gets a token with neither claim: authentication succeeds, and every org-scoped question answers no.
{ "sub": "7d3f9b2e-4c1a-4e8b-9f2d-1a6c5e0b8f34", "type": "identity", "environment_id": "3c8e1f5a-6b2d-4a9e-b7c4-8d1f2e6a9b05", "org_id": "7f3d2c1b-9a80-4e5f-b6a7-c8d9e0f1a2b3", "org_roles": ["Admin"], "org_role_ids": ["5b2e8d1f-3c7a-4f9e-a6b4-0d8c2e9f1a73"], "exp": 1788460000 }
With @canopy-io/node, read the claims through orgContext(claims) after verifying the token. It returns { orgId, orgRoles, orgRoleIds } or null, and refuses a partial set, or names and ids that do not pair up, rather than letting one claim be read without the others. An empty orgRoles is a member holding no role at the organization node; authorize by evaluating, never by the list.
import { TokenVerifier, orgContext } from "@canopy-io/node";
const verifier = new TokenVerifier({
issuer: process.env.CANOPY_ISSUER,
// Hosted Login tokens name your client; omit for the Direct API.
audience: process.env.OAUTH_CLIENT_ID,
});
const claims = await verifier.verify(bearerToken);
const org = orgContext(claims);
if (org) {
// acting inside org.orgId, holding org.orgRoles at its node
}Building the Org Switcher
A person who belongs to several organizations acts in one at a time and switches between them. Two endpoints power the switcher your frontend renders. The list is paged, newest membership first, and takes q to search by name and sort_by=name.
List the caller's memberships
curl https://auth.canopy-io.com/v1/identity/auth/organizations \ -H "Authorization: Bearer <access token>"
{ "items": [ { "id": "...", "name": "Acme Corp", "slug": "acme-corp", "roles": [{ "id": "...", "name": "Admin" }], "administers": true, "portal_access": true }, { "id": "...", "name": "Globex", "slug": "globex", "roles": [], "administers": false, "portal_access": false } ], "pagination": { "page": 1, "take": 20, "item_count": 2, "page_count": 1, "has_previous_page": false, "has_next_page": false } }
Switch the active organization
Switching is a refresh-token operation: Canopy re-verifies the membership, applies the target organization's authentication policy, moves the session, and mints a new access token whose org_id and org_roles name the new organization. Browsers on cookie delivery send the refresh cookie automatically; a backend-for-frontend passes refresh_token in the body alongside its secret API key. Asking to act in an organization the identity does not belong to is refused with a 403. On hosted login the same move is one more authorization request: GET /oauth/authorize?organization=<id> while the browser holds Canopy's hosted session issues a code for that organization with no credential asked again, and refuses a non-member at your callback with access_denied.
curl -X POST https://auth.canopy-io.com/v1/identity/auth/switch-organization \ -H "X-API-Key: cnpy_..." \ -H "Content-Type: application/json" \ -d '{ "refresh_token": "<refresh token>", "org_id": "<target organization id>" }'
When the target organization requires MFA and the session has not yet presented a factor, the switch answers requires_mfa_challenge: true with an mfa_challenge instead of tokens, exactly as login does. The session stays in its current organization until the challenge is verified; the session that verify mints starts in the target. A session that already carries mfa in its amr claim moves without a challenge.
Nothing about the person changed after a switch. Only the context their next requests are decided in did, which is exactly why the same endpoint can answer differently before and after.
Per-Organization Authentication Policy
Each organization owns an authentication policy layered over the Environment's settings. The Environment is the floor and the organization can only tighten it: a value that would loosen the Environment is refused with organization.policy_loosens. Every field but require_sso is nullable, and null inherits the Environment; require_sso is on or off, off by default. This is the setting behind a control you offer your own customers: when Acme's owner turns on "Require MFA for our team" in your product, your backend writes it here and Canopy enforces it on Acme's sign-ins only, while Globex is untouched. Your staff can also set it in the Console when onboarding an enterprise customer whose contract calls for it.
Read and write the policy on the organization. The response carries the organization's own values, the Environment baseline it tightens from (environment), and the layered result its members sign in under (effective). The version field backs If-Match. Both routes admit the developer's hierarchy.view / hierarchy.manage and the reserved canopy:organization.manage held at the organization node, the permission you attach to a membership role so a tenant's own admin can set the tenant's policy and nothing else's.
curl -X PATCH https://auth.canopy-io.com/api/v1/organizations/{org_id}/policy \ -H "X-API-Key: cnpy_..." \ -H "Content-Type: application/json" \ -d '{ "mfa_required": true, "allowed_sign_in_methods": ["password", "sso"], "require_sso": false }'
{ "data": { "organization_id": "7f3d2c1b-...", "mfa_required": true, "mfa_factor_allowlist": null, "mfa_after_sso": null, "allowed_sign_in_methods": ["password", "sso"], "require_sso": false, "environment": { "mfa_required": false, "mfa_factor_allowlist": ["totp", "webauthn", "email_otp"], "mfa_after_sso": "require", "allowed_sign_in_methods": ["password", "email_otp", "sso"], "require_sso": false }, "effective": { "mfa_required": true, "mfa_factor_allowlist": ["totp", "webauthn", "email_otp"], "mfa_after_sso": "require", "allowed_sign_in_methods": ["password", "sso"], "require_sso": false }, "sso_recovery_codes_generation": 0, "sso_recovery_codes_remaining": 0, "version": 1 } }
The policy is decided at login for the organization the session starts in, and again at every switch for the organization it moves to, on the Direct API and on the hosted OAuth pages alike. A member with no factor who enters an organization that requires MFA lands in the Environment's grace window (mfa_enrollment_pending), the same way a newly required Environment treats them.
Binding an Identity Provider
An organization binds its own identity provider. The connection stays an Account-level object (create it, verify its email domain, and bind it to the Environment as the SSO page describes), and the organization binding says where sign-ins through it land: in this organization, joining as a member with the binding's role when the identity is provisioned just in time or is not yet a member. A connection binds to one organization per Environment.
curl -X POST https://auth.canopy-io.com/api/v1/organizations/{org_id}/sso-connections \ -H "X-API-Key: cnpy_..." \ -H "Content-Type: application/json" \ -d '{ "sso_connection_id": "<connection id>", "default_role_id": "<membership role id>" }'
In the Console the binding lives on the organization's Policy tab; the connection's own page lists the organizations it is bound to.
Directory Sync per Organization
An organization's identity provider pushes its people into your product, and you decide what that provider may hand out. The roles are yours, so a tenant cannot grant itself more than you offered. The connection itself can be set up three ways: by the organization's administrator in their portal, by you in the Console, or by your backend with an API key. Until you name at least one role, the organization's administrator sees a page saying directory sync is not available to them and has nothing to connect.
curl -X PUT https://auth.canopy-io.com/api/v1/organizations/{org_id}/directory/grantable-roles \ -H "X-API-Key: <api key>" \ -H "Content-Type: application/json" \ -d '{ "role_ids": ["<member role id>", "<lead role id>"], "default_role_id": "<member role id>" }'
Your own Environment-wide sync is unaffected and stays on the Console's Directory Sync page: it is a directory too, belonging to no organization, and its activity feed and a tenant's never show each other's events.
Letting Customers Administer Themselves
Two reserved permissions decide who may run an organization from the outside. canopy:organization.manage, held at the organization's node, administers the whole organization: its settings, its sign-in policy, its members. canopy:access.manage, held at any node inside it, administers access at that node and every node beneath it, and nothing beside it: an office administrator manages their office and its teams and is refused at a sibling office. Holding canopy:organization.manage at the organization node implies the access key everywhere in it. Grant either to one of your roles, and anyone holding that role administers what the key reaches, calling the organization endpoints with their own session token instead of your API key, which is what lets a customer's admin act as themselves rather than through your backend. Delegate Administration walks through both keys.
Name your admin role
Both keys are system permissions created in every Environment that has the organizations container on, so there is nothing to register. Add canopy:organization.manage to whichever role means "runs their own organization" in your product, and canopy:access.manage to the one that means "runs their own office", once per Environment. In the Developer Console they sit under Canopy capabilities on the Roles page, above your own permissions and tinted apart from them, because granting it hands authority to people who never sign in to the Console. The same grant over the API looks like this, and note the write replaces the role's whole set, so send its existing keys alongside it.
curl -X PUT https://auth.canopy-io.com/portal/v1/accounts/{account}/applications/{app}/environments/{env}/roles/{role_id}/permissions \ -H "Authorization: Bearer <admin token>" \ -H "Content-Type: application/json" \ -d '{ "permission_keys": ["canopy:organization.manage", "<its existing permission keys>"] }'
Give each organization its first admin
An organization's members cannot grant themselves this, so the first one comes from you: assign the admin role when you provision the organization, or invite that person with it. After that the organization is self-sufficient: its admins can promote a colleague or hand over entirely, and the last one cannot be removed or demoted.
Calling as the customer's admin
The organization endpoints accept an identity access token as well as an API key. With a token, every route checks the reserved permission at that organization, so an admin of Acme reads and writes Acme and is refused everywhere else. The one thing they read but cannot write is the list of roles their directory may grant, which stays yours. Only the reserved key opens these routes to a session: Canopy's own management keys, the ones an API key holds as scopes, are never satisfied by a permission you define, so a permission you define under one of those names, such as your own identity.manage, grants nothing: neither these routes, evaluate, the grants the SDK authorizes from, nor the permissions claim count it. Give your own permissions names of their own. One write stays yours: an organization's administrator can reset a member's second factor only while that member belongs to no other organization and signs in to no other Environment of your Account, because a factor is held once per identity and the reset would reach everywhere the person signs in; for a shared member the request answers 409 organization.member_mfa_shared and the reset comes from your own administrators. GET /v1/identity/auth/organizations reports administers (the whole organization) and portal_access (anything inside it) per membership, which is how a session knows what it may open; GET /api/v1/organizations/{org_id}/me then says what, down to the node. Delegate Administration lists the reads an admin screen is built from.
curl https://auth.canopy-io.com/api/v1/organizations/{org_id}/members \ -H "Authorization: Bearer <identity access token>" # Roles you may assign inside this organization, for a picker curl https://auth.canopy-io.com/api/v1/organizations/{org_id}/roles \ -H "Authorization: Bearer <identity access token>" # What the caller administers here, down to the node curl https://auth.canopy-io.com/api/v1/organizations/{org_id}/me \ -H "Authorization: Bearer <identity access token>"
What an administrator may hand out
An identity may grant a role only when it holds, at the target node or above, every permission the role carries, the reserved keys included. So an office administrator hands out the roles their office uses and never the one that runs the organization, and nobody promotes themselves: a write outside that line answers 403 assignment.grant_ceiling, on assignments, member role changes and invitations alike. The organization's own administrator, holding canopy:organization.manage at the organization node, is not bounded inside it, so a self-sufficient organization stays one. Console users and your API key are not bounded; the Workspace keeps its own allowlist for its administrators. Delegate Administration shows the escalation it prevents.
Everything above is the same surface the hosted admin portal calls. Build against it when you want these controls inside your own product; send customers to the portal when you would rather not build them at all.
The Organization Admin Portal
Each organization's own administrators run it from a hosted portal: who belongs, who holds a role at each place inside it, the authentication policy, single sign-on, and directory sync. The portal wears your Environment's branding and signs people in with their identity in your product. Admission is a reserved permission held through a role in that organization: canopy:organization.manage at the organization opens every page, and canopy:access.manage anywhere inside it opens the Access page alone. Link to it from your product, or open it from the Console's organization detail.
https://canopy-io.com/org/{publishable_key}/{organization_slug}The portal is a client of the same APIs. Every request carries the identity's token and is answered by /api/v1/organizations/:id/* under the tenant admin rule, so what it can do is exactly what the two reserved permissions allow.
Enforcing Per-Org Authorization
An organization is a hierarchy node underneath, and a member's roles are assignments at that node or beneath it. That means per-org authorization is the node question asked at the organization the token is acting in, and the SDK packages it as its own scope. An assignment inside an organization grants only while the membership is active, on the server and in the grants the SDK caches: a check against a deactivated membership answers ORGANIZATION_MEMBERSHIP_INACTIVE, and the grants read lists nothing held there.
NestJS: scope "org"
With @canopy-io/nestjs, declare the permission with scope: "org". The guard reads org_id off the verified claims CanopyTokenGuard attached and evaluates at that node through the local authorizer, so the check normally costs no network call. A caller acting in no organization is denied without one either.
@UseGuards(CanopyTokenGuard, CanopyGuard)
@RequirePermission("invoice.approve", { scope: "org" })
@Post("invoices/:id/approve")
approve() {}Plain HTTP: evaluate at the org node
Without the SDK, ask the evaluate endpoint the same question: node scope, with node_id set to the token's org_id. The two rules that keep tenancy honest: take the organization from the verified token, never from anything the caller sends, and treat a row belonging to a different organization as out of scope before any role is consulted.
curl -X POST https://auth.canopy-io.com/api/v1/permissions/evaluate \ -H "X-API-Key: cnpy_..." \ -H "Content-Type: application/json" \ -d '{ "identity_id": "7d3f9b2e-4c1a-4e8b-9f2d-1a6c5e0b8f34", "permission": "invoice.approve", "scope": "node", "node_id": "<org_id from the token>" }'
Two SDK details for a backend that is not the one verifying the token. resolveOrg, a CanopyModule option, tells the guard where to read the acting organization from when the claims are not on the request under attachTokenAs: a backend-for-frontend that parked them elsewhere, or an API-key surface. Feed it only a value read off a verified token, since it decides which organization's grants answer. orgContext(claims) from @canopy-io/node reads the pair safely, and the amr claim tells you how the session was proven (pwd, otp, or sso, plus mfa once a factor was verified), so a sensitive action can insist on mfa before proceeding.
Worked example. Dana is Admin at Acme and Viewer at Globex; the Viewer role carries read permissions only. The same person, the same endpoints, different answers, decided entirely by which membership the token is exercising:
Root assignments reach every organization
Evaluation walks a node's lineage with no special case for organizations, and organizations sit directly under the Environment root. So a role assigned at the root is held in every organization: a person with Support at the root can act in Acme and Globex alike, and an org-scoped check answers yes for them in both. That is the place for your own staff. It also means a customer's environment-wide role from before the container was on is a cross-tenant grant until you move that person into memberships: create the memberships first, then remove the root assignment. Removing it touches nothing beneath an organization. A membership, and any scoped grant beneath the organization, reaches only that tenant.
Lifecycle & Teardown
Each teardown removes exactly what it names, and identities always survive:
Tokens converge on their own: within the access token's lifetime, every removed or changed membership is reflected in the claims the next refresh mints, and a session acting nowhere lists its remaining memberships with GET /v1/identity/auth/organizations and switches into one.
Next Step
Tell us how we can improve this guide.