Three Permission Mistakes That Let Multi-Tenant Apps Quietly Own Your Directory

Application permissions where delegated would do, overly broad scopes, and no way to list what's already consented. Here's the five-step audit to find which external apps are sitting in your tenant right now.

The scary part of a multi-tenant app isn’t the app. It’s the service principal it drops into your tenant the moment someone consents, a real security object, with real permissions, owned by a company whose change log you don’t read. Three things usually go wrong, and they go wrong quietly: someone grants application permissions where delegated would do, someone accepts Directory.ReadWrite.All because the consent dialog made it sound routine, and nobody can later name which external apps are already sitting in the tenant with a grant. Work the list in this order.

1. Know what “multi-tenant” actually means on the registration

In an app registration, the switch is signInAudience. AzureADMyOrg is single-tenant, only your directory. AzureADMultipleOrgs means any Entra tenant can provision a service principal for it. AzureADandPersonalMicrosoftAccount adds consumer accounts on top. If you build SaaS, multi-tenant is correct. The mistake is reaching for it when you have three known customer tenants you could just add as guests. Broaden the audience on purpose, not because the wizard defaulted there.

2. Separate delegated from application permissions, they fail differently

Delegated permissions act as the signed-in user and are capped by that user’s rights. Application permissions act as the app, with no user in the loop and no ceiling but the scope itself. A vendor that asks for Mail.Read delegated reads what the caller could already read. The same vendor asking for Mail.Read application reads every mailbox in the tenant, forever, including after the employee who clicked “Accept” has left. If an app wants application-level Directory.ReadWrite.All to “sync users,” that is not a scope, that’s a standing key to your directory. Make them justify it.

3. Audit what’s already consented before you touch anything

You can’t govern what you can’t list. In the admin center: Identity → Applications → Enterprise applications, then filter Application type = All and add the Homepage URL/owner columns. The tell for an external multi-tenant app is appOwnerOrganizationId pointing at a tenant that isn’t yours. The Graph endpoints that matter: /servicePrincipals for the objects, /oauth2PermissionGrants for delegated consent, and /servicePrincipals/{id}/appRoleAssignments for application permissions.

List the externally-owned ones first, those are the apps where another organisation controls the code:

audit.ps1PowerShell
Connect-MgGraph -Scopes 'Application.Read.All','Directory.Read.All'
​
$myTenantId = (Get-MgContext).TenantId
​
try {
    # -All handles pagination; never loop a live query by hand
    $sps = Get-MgServicePrincipal -All -Property `
        Id, DisplayName, AppId, AppOwnerOrganizationId, SignInAudience `
        -ErrorAction Stop
}
catch {
    Write-Error "Failed to enumerate service principals: $($_.Exception.Message)"
    return
}
​
$external = $sps | Where-Object {
    $_.AppOwnerOrganizationId -and $_.AppOwnerOrganizationId -ne $myTenantId
}
​
# Review this file by hand before any revocation step touches it
$external |
    Select-Object DisplayName, AppId, AppOwnerOrganizationId, SignInAudience |
    Sort-Object DisplayName |
    Export-Csv -Path .\external-multitenant-apps.csv -NoTypeInformation
​
Write-Host "Found $($external.Count) externally-owned service principals. Review the CSV."

4. Check publisher verification and consent necessity before you approve

A verified publisher is a blue badge backed by a Microsoft Partner Network account Microsoft has actually checked, it won’t tell you the app is safe, but an unverified publisher asking for tenant-wide application permissions is a hard stop until you know who they are. Three questions per app: who owns it (publisher + appOwnerOrganizationId), what does it truly need (delegated beats application; read beats write; scoped beats .All), and does anyone still use it. An unused app with a live grant is pure liability.

5. Revoke safely, dry-run first, and know which consent you’re killing

Admin consent covers the whole tenant; user consent covers one user. Pulling an admin grant can break every user at once, so model the blast radius before you commit. Start from your reviewed CSV, not a live pipe, and keep the state-changing call behind -WhatIf until you’ve read the output:

audit.ps1PowerShell
# Operate on the reviewed file only, never straight off Get-MgServicePrincipal
$targets = Import-Csv .\external-multitenant-apps.csv |
    Where-Object { $_.AppId -eq '<app-id-you-decided-to-remove>' }
​
foreach ($t in $targets) {
    $sp = Get-MgServicePrincipal -Filter "appId eq '$($t.AppId)'" -ErrorAction Stop
    $grants = Get-MgOauth2PermissionGrant -All `
        -Filter "clientId eq '$($sp.Id)'" -ErrorAction Stop
​
    foreach ($g in $grants) {
        # Remove -WhatIf only after you've read every line it prints
        Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $g.Id -WhatIf
    }
}

Revoking the grant leaves the service principal in place, disable or delete it separately if the app is genuinely gone, and tell the owning team first, because a broken integration at 9am reads exactly like a breach.

If you do one thing this week: run step 3, sort the CSV by owner, and find the externally-owned apps holding application permissions ending in .All. That short list is where your next incident is hiding.