Cross-tenant authentication: linking users across organizations

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);
}
Including the full list of org memberships in the JWT is tempting but creates problems: the token grows large, and any change to memberships does not take effect until token refresh. Keep the JWT scoped to the current active org only. Fetch the full membership list separately when rendering the org switcher UI.

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.

← Back to blog Try Bastionary free →