B2B org/workspace lifecycle
The end-to-end path from a fresh sign-in to a scoped, multi-tenant request — onboarding into organizations, selecting workspaces, and enforcing scope on the backend.
Wacht's business-to-business (B2B) model has two tenancy layers. An organization is the account a team shares; a workspace is a scope inside it. A user holds a membership in each, and the active pair rides in every session token's claims. This guide follows one user from sign-in to a scoped request, then covers admin-side invitations and the failure modes worth handling.
The contract across the seam: the frontend resolves which organization and workspace are active and switches between them; the backend reads the resulting claims and scopes every query. The frontend selection is UX. The backend claims are the authority.
The path
sign in
│
▼
resolve memberships ──── none ──────────► onboard: create org (+ first workspace)
│
has membership
│
▼
active org set? ──── no ──► OrganizationSwitcher / create
│
yes
▼
active workspace set? ── no ──► pick / create workspace
│
yes
▼
getToken() → claims carry org + workspace ids
│
▼
backend scopes the query to those claimsA new user signs in with no memberships and must onboard before any scoped request works. A returning user already has an active pair from their last session. Both end at the same place: a token whose claims name an organization and a workspace.
Resolve the active tenancy
useActiveOrganization() and useActiveWorkspace() read the active pair from the session. When either is null after loading, the user hasn't selected one yet — that's your onboarding branch, not an error.
import { useActiveOrganization, useActiveWorkspace } from "@wacht/react-router";
function TenantGate({ children }: { children: React.ReactNode }) {
const { activeOrganization, loading: orgLoading } = useActiveOrganization();
const { activeWorkspace, loading: wsLoading } = useActiveWorkspace();
if (orgLoading || wsLoading) return <Spinner />;
if (!activeOrganization) return <OnboardOrganization />;
if (!activeWorkspace) return <OnboardWorkspace />;
return <>{children}</>;
}RequireActiveTenancy from the SDK does this gate for you, rendering a selector dialog when no active pair is set. Hand-roll the gate when you want the onboarding screens to be your own.
Switch the active pair
Switching is a session operation, not an organization-hook one. useSession().switchOrganization(orgId) and switchWorkspace(workspaceId) change the active pair server-side, clear the cached token, and refetch the session. The next getToken() carries the new ids.
import { useSession } from "@wacht/react-router";
function useTenancyControls() {
const { switchOrganization, switchWorkspace } = useSession();
return { switchOrganization, switchWorkspace };
}OrganizationSwitcher is the prebuilt menu over this — it lists the user's organizations and calls switchOrganization on selection. There is no prebuilt workspace switcher; build one over useWorkspaceList() and switchWorkspace.
Switching org and workspace are independent calls. Switching organization does not auto-pick a workspace in the new org, so after an org switch the active workspace can be null — your gate routes back to workspace selection. Account for that rather than assuming a switch lands fully scoped.
Create on first run
A user with no memberships creates an organization, then a workspace inside it. Both return the created entity and the membership in one call, so the new owner is a member immediately — no second invite step for the creator.
import { useOrganizationList, useWorkspaceList, useSession } from "@wacht/react-router";
function OnboardButton() {
const { createOrganization } = useOrganizationList();
const { createWorkspace } = useWorkspaceList();
const { switchOrganization, switchWorkspace } = useSession();
async function onboard() {
const { data: org } = await createOrganization({ name: "Acme" });
await switchOrganization(org.organization.id);
const ws = await createWorkspace(org.organization.id, "Production");
await switchWorkspace(ws.workspace.id);
}
return <button onClick={onboard}>Create organization</button>;
}createOrganization takes an object ({ name, description?, image? }) and returns the wrapped result, so read data.organization. createWorkspace takes positional arguments (organizationId, name, image?, description?) and returns the unwrapped { workspace, membership }. Switch into each after creating it so the session — and the next token — reflects the new scope.
Onboarding teammates via invitations
The creator is in. Adding teammates by email is a first-party flow with backend SDK coverage on both Node and Rust. Use it when:
- An admin adds a teammate by email without provisioning them through the deployment-wide invitations surface.
- The new user should land in a specific workspace and role on first sign-in.
- You need to revoke a pending invite before it's accepted.
A row is soft-deleted both on user accept and on admin discard, so the stored state doesn't distinguish the two — pass include_deleted when listing if you need to see both.
import { organizations } from "@wacht/backend";
// Create — returns a slim summary that includes the invite token.
const summary = await organizations.createOrganizationInvitation(
"organization_id",
{
email: "newhire@example.com",
role_id: "organization_role_id",
workspace_id: "workspace_id",
workspace_role_id: "workspace_role_id",
expiry_days: 7,
},
);
// List pending invitations (optionally filter to one workspace).
const pending = await organizations.listOrganizationInvitations(
"organization_id",
{ workspace_id: "workspace_id" },
);
// Discard an invitation before it's accepted.
await organizations.discardOrganizationInvitation(
"organization_id",
"invitation_id",
);Only email is required. The rest are optional: omit role_id / workspace_id to invite into the org with no preset workspace.
use wacht::models::CreateOrganizationInvitationRequest;
let summary = client
.organizations()
.invitations()
.create(
"organization_id",
CreateOrganizationInvitationRequest {
email: "newhire@example.com".into(),
role_id: Some("organization_role_id".into()),
workspace_id: Some("workspace_id".into()),
workspace_role_id: Some("workspace_role_id".into()),
expiry_days: Some(7),
},
)
.send()
.await?;
let pending = client
.organizations()
.invitations()
.list("organization_id")
.workspace_id("workspace_id")
.send()
.await?;
client
.organizations()
.invitations()
.discard("organization_id", "invitation_id")
.send()
.await?;CreateOrganizationInvitationRequest derives Default, so set email and leave the rest as None for an org-only invite. Add .include_deleted(true) to list(...) to surface accepted and discarded rows; discard(...) is idempotent.
expiry_days defaults to 10 when omitted. The returned token builds the accept-invitation URL — surface it to admin tooling only when out-of-band sharing is needed, and treat it like a secret otherwise.
For the webhook events these operations fire (organization.invitation.created, .accepted, .revoked), see Webhooks → keep your backend in sync.
Enforce scope on the backend
The active pair is in the token claims. The backend reads it and scopes every query — it never trusts an org or workspace id from the request body. This is the same auth.organizationId / auth.workspaceId (Node) and auth.organization_id / auth.workspace_id (Rust) shown in the React + backend auth lifecycle.
Both ids are nullable. A token can be authenticated yet carry no active workspace — a user mid-onboarding, or one who just switched organizations. Treat a missing workspace as 403, not 500:
const auth = await getAuth(request);
await auth.protect();
if (!auth.workspaceId) {
return new Response("workspace required", { status: 403 });
}
// every query below filters on auth.workspaceIdlet workspace_id = auth
.workspace_id
.as_deref()
.ok_or(Error::Forbidden("workspace required"))?;
// every query below filters on workspace_idValidate membership and permissions per request, not once at sign-in. Roles change after a token is minted, and a workspace can stop being accessible mid-session.
Failure modes to handle
| Condition | Where it surfaces | Handling |
|---|---|---|
| User has no organization membership | activeOrganization is null after load | Route to org onboarding; don't error |
| Org membership but no active workspace | activeWorkspace is null; token has no workspace claim | Route to workspace selection; backend returns 403 for workspace-scoped routes |
| Active workspace no longer accessible | Backend 404/403 on a resource that previously resolved | Refetch tenancy, drop the stale selection, re-gate |
| Role or permission changed after token issue | Backend 403 on a route that worked before | Mint a fresh token (it carries updated permission claims) and retry once |
Production checklist
- The frontend gates the app on a resolved active organization and workspace before rendering scoped UI.
- Org switch and workspace switch are treated as separate steps; an org switch can leave the workspace unset.
- Every backend tenant query filters on the token's org/workspace claims, never on an id from the request body.
- Workspace-scoped routes return
403when the claim is absent, not a server error. - Invitation tokens are treated as secrets and surfaced only to admin tooling.
Reference
- React + backend auth lifecycle for the token verification seam this builds on.
- Rust: Organizations and Workspaces for the full backend method surface.
- Frontend API and Backend API for exact request/response shapes.
React + backend auth lifecycle
A React frontend signs a user in, mints a session token, and calls a backend that verifies it. Node and Rust both shown, end to end.
Unsupported stacks
Integrate Wacht when there's no SDK for your stack — a custom React router via @wacht/jsx, or any other frontend by calling the Frontend API directly.