Most SaaS applications model users as members of a single organization. But real workflows break this assumption: a contractor who works for multiple clients, a partner whose company integrates tightly with yours, a shared service account that belongs to no single org. When a user needs to access multiple organizations, you face a design decision that affects everything from your JWT structure to your UI. There is no single right answer, but the tradeoffs are well-defined.
Model 1: multiple memberships on a single identity
The user has one identity (one user row, one login) but is a member of multiple organizations. When they log in, they choose which organization context to operate in. The session is scoped to one org at a time; switching orgs issues a new token scoped to the new org.
-- A single user can be a member of multiple orgs CREATE TABLE org_members ( org_id UUID NOT NULL REFERENCES organizations(id), user_id UUID NOT NULL REFERENCES users(id), role TEXT NOT NULL DEFAULT 'member', joined_at TIMESTAMPTZ DEFAULT NOW(), PRIMARY KEY (org_id, user_id) ); -- The session tracks which org context is currently active CREATE TABLE sessions ( id UUID PRIMARY KEY, user_id UUID NOT NULL, active_org_id UUID REFERENCES organizations(id), -- ... other session fields );
// JWT claims for multi-org user: scoped to current org context
{
"sub": "user_01HXYZ", // stable user identity
"org": "org_ACME", // currently active org
"org_role": "admin", // role in that specific org
"orgs": ["org_ACME", "org_BETA"], // all orgs user belongs to (optional)
"exp": 1648000000
}
// Switching org context: issue a new token
async function switchOrgContext(userId, targetOrgId, currentToken) {
// Verify user is actually a member of the target org
const membership = await db.query(
'SELECT role FROM org_members WHERE user_id = $1 AND org_id = $2',
[userId, targetOrgId]
);
if (!membership.rows[0]) {
throw new Error('User is not a member of this organization');
}
// Issue new token scoped to the new org
return issueAccessToken(userId, targetOrgId, membership.rows[0].role);
}
Model 2: separate per-org identities with account linking
The user has separate identity records per organization (often when organizations use different SSO providers), with an account linking table that connects them. This is common when integrating external identity providers: alice@acme.com in Acme's Okta tenant and alice@beta.com in Beta Corp's Google Workspace are the same person but different identity records from the platform's perspective.
-- Linked accounts: multiple identities resolve to the same person CREATE TABLE linked_accounts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), primary_user_id UUID NOT NULL REFERENCES users(id), linked_user_id UUID NOT NULL REFERENCES users(id), link_type TEXT NOT NULL, -- 'same_person', 'delegated_access' linked_at TIMESTAMPTZ DEFAULT NOW(), linked_by UUID REFERENCES users(id), UNIQUE(primary_user_id, linked_user_id) ); -- External identity connections (SSO) CREATE TABLE identity_connections ( user_id UUID NOT NULL REFERENCES users(id), provider TEXT NOT NULL, -- 'okta', 'google', 'azure-ad' org_id UUID REFERENCES organizations(id), external_id TEXT NOT NULL, -- provider's user ID email TEXT NOT NULL, UNIQUE(provider, org_id, external_id) );
Cross-org delegation
Delegation is distinct from membership: a delegated user can act within an org they do not formally belong to, on behalf of another user or on behalf of their own org. This is the pattern for integration partnerships where you want to grant a partner's service account read access to specific data without making them a full org member.
-- Delegation grants CREATE TABLE access_delegations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), grantor_org_id UUID NOT NULL, grantee_user_id UUID NOT NULL, -- the user receiving access grantee_org_id UUID, -- optional: the org they represent resource_type TEXT NOT NULL, -- 'org_reports', 'audit_logs', etc. resource_id TEXT, -- specific resource, or NULL for all permissions TEXT[] NOT NULL, -- ['read'], ['read', 'comment'], etc. expires_at TIMESTAMPTZ, granted_by UUID REFERENCES users(id), revoked_at TIMESTAMPTZ );
// JWT for a delegated cross-org session
{
"sub": "user_PARTNER",
"org": "org_GRANTOR", // acting within the grantor's org
"acting_as": "delegated", // not a member, delegated access
"delegation_id": "del_ABC123",
"permitted_resources": ["org_reports"],
"exp": 1648003600 // short expiry for delegated tokens
}
// Authorize a delegated action
async function authorizeDelegated(userId, targetOrgId, action, resourceType) {
const delegation = await db.query(`
SELECT * FROM access_delegations
WHERE grantee_user_id = $1
AND grantor_org_id = $2
AND $3 = ANY(permissions)
AND (resource_type = $4 OR resource_type = '*')
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
`, [userId, targetOrgId, action, resourceType]);
return delegation.rowCount > 0;
}
Audit requirements for cross-tenant access
Every cross-tenant access event — a user accessing org B while authenticated to org A, or a delegated partner accessing your data — must be logged with both the actor's home organization and the accessed organization. This is critical for security incident investigation: if a customer asks "who from outside our org accessed our data last month?", you need to be able to answer.
// Audit log entry for cross-org access
await recordAuditEvent({
action: 'cross_org_access',
actor_user_id: userId,
actor_org_id: actorHomeOrgId, // user's own org
target_org_id: accessedOrgId, // org being accessed
resource_type: resourceType,
delegation_id: delegationId, // links to the grant that authorized this
ip_address: req.ip,
occurred_at: new Date(),
});
Ensure cross-org audit events are visible to both organizations in their respective audit log views. The grantor should see who accessed their data. The grantee's organization should see what their user accessed on behalf of partners. Security teams at enterprise customers will specifically request this visibility during procurement evaluations.