Back to Blog
5 min read

Azure AD B2B Collaboration: Secure Partner Access

Azure AD B2B (Business-to-Business) enables secure collaboration with external partners while maintaining control over your corporate resources. Let’s explore how to implement B2B collaboration effectively.

What is Azure AD B2B?

B2B allows you to invite external users who authenticate with their own identity provider:

  • Another Azure AD tenant
  • Microsoft accounts
  • Google accounts
  • SAML/WS-Fed identity providers
  • Email one-time passcode

Guests appear in your directory but authenticate elsewhere.

Inviting Guest Users

Via Azure Portal or Graph API

import requests

graph_url = "https://graph.microsoft.com/v1.0"
headers = {
    "Authorization": f"Bearer {access_token}",
    "Content-Type": "application/json"
}

def invite_guest_user(email, display_name, redirect_url=None):
    """Invite external user as guest"""
    invitation = {
        "invitedUserEmailAddress": email,
        "invitedUserDisplayName": display_name,
        "inviteRedirectUrl": redirect_url or "https://myapps.microsoft.com",
        "sendInvitationMessage": True,
        "invitedUserMessageInfo": {
            "customizedMessageBody": "You've been invited to collaborate with Contoso. Click accept to get started."
        }
    }

    response = requests.post(
        f"{graph_url}/invitations",
        headers=headers,
        json=invitation
    )

    return response.json()

# Invite a partner
result = invite_guest_user(
    email="partner@fabrikam.com",
    display_name="Partner User",
    redirect_url="https://portal.contoso.com"
)

print(f"Invitation sent. Status: {result['status']}")
print(f"Redeem URL: {result['inviteRedeemUrl']}")

Bulk Invitations

import csv
from concurrent.futures import ThreadPoolExecutor

def bulk_invite_from_csv(csv_file):
    """Invite multiple users from CSV file"""
    results = []

    with open(csv_file, 'r') as f:
        reader = csv.DictReader(f)
        users = list(reader)

    with ThreadPoolExecutor(max_workers=5) as executor:
        futures = [
            executor.submit(
                invite_guest_user,
                user['email'],
                user['name'],
                user.get('redirect_url')
            )
            for user in users
        ]

        for future, user in zip(futures, users):
            try:
                result = future.result()
                results.append({
                    'email': user['email'],
                    'status': 'success',
                    'id': result.get('invitedUser', {}).get('id')
                })
            except Exception as e:
                results.append({
                    'email': user['email'],
                    'status': 'failed',
                    'error': str(e)
                })

    return results

# CSV format: email,name,redirect_url
# partner1@external.com,Partner One,https://app.contoso.com
# partner2@external.com,Partner Two,https://app.contoso.com

Cross-Tenant Access Settings

Configure how your organization interacts with others:

def configure_cross_tenant_access(tenant_id, settings):
    """Configure access settings for specific tenant"""

    policy = {
        "tenantId": tenant_id,
        "b2bCollaborationInbound": {
            "usersAndGroups": {
                "accessType": "allowed",
                "targets": [
                    {
                        "target": "AllUsers",
                        "targetType": "user"
                    }
                ]
            },
            "applications": {
                "accessType": "allowed",
                "targets": [
                    {
                        "target": settings.get("allowed_apps", ["AllApplications"])[0],
                        "targetType": "application"
                    }
                ]
            }
        },
        "b2bCollaborationOutbound": {
            "usersAndGroups": {
                "accessType": "allowed",
                "targets": [
                    {
                        "target": settings.get("outbound_group", "AllUsers"),
                        "targetType": "user"
                    }
                ]
            }
        },
        "inboundTrust": {
            "isMfaAccepted": settings.get("trust_mfa", True),
            "isCompliantDeviceAccepted": settings.get("trust_compliant", False),
            "isHybridAzureADJoinedDeviceAccepted": settings.get("trust_hybrid_join", False)
        }
    }

    response = requests.post(
        f"{graph_url}/policies/crossTenantAccessPolicy/partners",
        headers=headers,
        json=policy
    )

    return response.json()

# Trust MFA from partner tenant
configure_cross_tenant_access(
    tenant_id="partner-tenant-guid",
    settings={
        "trust_mfa": True,
        "trust_compliant": True,
        "allowed_apps": ["sharepoint-app-id"]
    }
)

Self-Service Sign-Up

Allow guests to sign up for specific apps:

def create_self_service_signup_flow():
    """Create user flow for self-service guest signup"""

    # Create user flow
    user_flow = {
        "id": "B2X_1_partner_signup",
        "userFlowType": "signUpOrSignIn",
        "userFlowTypeVersion": 1,
        "identityProviders": [
            {"id": "Email-Password-Authentication"}
        ]
    }

    response = requests.post(
        f"{graph_url}/identity/b2xUserFlows",
        headers=headers,
        json=user_flow
    )

    flow_id = response.json()['id']

    # Add attributes to collect
    attributes = [
        {"id": "city"},
        {"id": "companyName"},
        {"id": "jobTitle"}
    ]

    for attr in attributes:
        requests.post(
            f"{graph_url}/identity/b2xUserFlows/{flow_id}/userAttributeAssignments",
            headers=headers,
            json={"userAttribute": attr}
        )

    return flow_id

Managing Guest Lifecycle

Access Reviews

def create_guest_access_review():
    """Create access review for guest users"""

    review = {
        "displayName": "Quarterly Guest Review",
        "descriptionForAdmins": "Review guest user access quarterly",
        "descriptionForReviewers": "Please review if these guests still need access",
        "scope": {
            "@odata.type": "#microsoft.graph.accessReviewQueryScope",
            "query": "/users?$filter=userType eq 'Guest'",
            "queryType": "MicrosoftGraph"
        },
        "reviewers": [
            {
                "query": "/users/{manager-id}",
                "queryType": "MicrosoftGraph"
            }
        ],
        "settings": {
            "mailNotificationsEnabled": True,
            "reminderNotificationsEnabled": True,
            "justificationRequiredOnApproval": True,
            "defaultDecisionEnabled": True,
            "defaultDecision": "Deny",
            "instanceDurationInDays": 14,
            "autoApplyDecisionsEnabled": True,
            "recommendationsEnabled": True,
            "recurrence": {
                "pattern": {
                    "type": "absoluteMonthly",
                    "interval": 3,
                    "dayOfMonth": 1
                },
                "range": {
                    "type": "noEnd",
                    "startDate": "2021-06-01"
                }
            }
        }
    }

    response = requests.post(
        f"{graph_url}/identityGovernance/accessReviews/definitions",
        headers=headers,
        json=review
    )

    return response.json()

Guest Cleanup

from datetime import datetime, timedelta

def find_stale_guests(inactive_days=90):
    """Find guest users who haven't signed in recently"""

    cutoff_date = (datetime.utcnow() - timedelta(days=inactive_days)).isoformat() + "Z"

    query = f"userType eq 'Guest' and signInActivity/lastSignInDateTime lt {cutoff_date}"

    response = requests.get(
        f"{graph_url}/users",
        headers=headers,
        params={
            "$filter": query,
            "$select": "id,displayName,mail,signInActivity,createdDateTime"
        }
    )

    return response.json().get('value', [])

def remove_stale_guests(dry_run=True):
    """Remove or disable stale guest accounts"""

    stale_guests = find_stale_guests(inactive_days=180)

    for guest in stale_guests:
        print(f"Stale guest: {guest['displayName']} ({guest['mail']})")
        print(f"  Last sign-in: {guest.get('signInActivity', {}).get('lastSignInDateTime', 'Never')}")

        if not dry_run:
            # Option 1: Delete
            # requests.delete(f"{graph_url}/users/{guest['id']}", headers=headers)

            # Option 2: Block sign-in
            requests.patch(
                f"{graph_url}/users/{guest['id']}",
                headers=headers,
                json={"accountEnabled": False}
            )
            print(f"  Disabled account")

    return len(stale_guests)

Conditional Access for Guests

# Require MFA for all guest access
guest_mfa_policy = {
    "displayName": "Require MFA for Guests",
    "state": "enabled",
    "conditions": {
        "users": {
            "includeGuestsOrExternalUsers": {
                "guestOrExternalUserTypes": "b2bCollaborationGuest,b2bCollaborationMember",
                "externalTenants": {
                    "membershipKind": "all"
                }
            }
        },
        "applications": {
            "includeApplications": ["All"]
        }
    },
    "grantControls": {
        "operator": "OR",
        "builtInControls": ["mfa"]
    }
}

# Restrict guests to specific apps
guest_app_restriction = {
    "displayName": "Restrict Guest App Access",
    "state": "enabled",
    "conditions": {
        "users": {
            "includeGuestsOrExternalUsers": {
                "guestOrExternalUserTypes": "b2bCollaborationGuest"
            }
        },
        "applications": {
            "includeApplications": ["All"],
            "excludeApplications": [
                "sharepoint-app-id",
                "teams-app-id"
            ]
        }
    },
    "grantControls": {
        "operator": "OR",
        "builtInControls": ["block"]
    }
}

Monitoring Guest Activity

// Guest sign-in activity
SigninLogs
| where TimeGenerated > ago(30d)
| where UserType == "Guest"
| summarize
    SignInCount = count(),
    UniqueApps = dcount(AppDisplayName),
    LastSignIn = max(TimeGenerated)
    by UserPrincipalName, UserDisplayName
| order by SignInCount desc

// Failed guest sign-ins
SigninLogs
| where TimeGenerated > ago(7d)
| where UserType == "Guest"
| where ResultType != 0
| summarize FailedCount = count() by UserPrincipalName, ResultDescription
| order by FailedCount desc

// Guest access by application
SigninLogs
| where TimeGenerated > ago(30d)
| where UserType == "Guest"
| summarize
    GuestCount = dcount(UserPrincipalName),
    SignInCount = count()
    by AppDisplayName
| order by GuestCount desc

Best Practices

  1. Limit guest permissions: Use Conditional Access to restrict access
  2. Regular access reviews: Quarterly reviews of guest necessity
  3. Trust MFA from partners: Accept MFA from trusted tenants
  4. Monitor activity: Track guest sign-ins and resource access
  5. Automate lifecycle: Remove inactive guests automatically

Resources

Michael John Peña

Michael John Peña

Senior Data Engineer based in Sydney. Writing about data, cloud, and technology.