PowerShell Microsoft Graph: Export Teams Channels and Owners
─□✕

PowerShell Microsoft Graph: Export Teams Channels and Owners

PowerShell Tips Editor 5 min read
PowerShell Microsoft Graph: Export Teams Channels and Owners

Microsoft Teams sprawl is a real governance problem in most organizations. Teams get created during projects, the project ends, and the team lingers — with channels, owners who have since left, and sensitive files sitting in SharePoint. Before you can govern any of that, you need an accurate inventory. The Microsoft Graph PowerShell SDK gives you the API coverage to enumerate every team, every channel, and every owner across a tenant, but large tenants require careful pagination handling or you will get a partial dataset and never know it.

Required Graph Permissions: Team.ReadBasic.All and Channel.ReadBasic.All

The export script needs application permissions (not delegated) so it can run unattended. Register an app in Entra ID and grant the following application permissions: Team.ReadBasic.All to list teams, Channel.ReadBasic.All to enumerate channels, and TeamMember.Read.All to resolve channel owners. Admin consent is required for all three. Store the tenant ID, application ID, and a certificate thumbprint (preferred over a client secret for automation) in a secure location — Key Vault or a secrets manager, not a script file.

# Install the SDK scoped to the modules you need
Install-Module Microsoft.Graph.Teams -Scope CurrentUser -Force
Install-Module Microsoft.Graph.Groups -Scope CurrentUser -Force

# Connect with certificate-based auth (recommended for automation)
Connect-MgGraph -TenantId $tenantId `
                -ClientId $appId `
                -CertificateThumbprint $certThumbprint

Connecting and Handling Token Refresh for Long Runs

The default access token lifetime is one hour. For tenants with thousands of teams the enumeration will exceed that window. The Graph SDK handles token refresh automatically as long as you connected with a certificate or secret — the SDK refreshes using the stored credential before each request when the token is near expiry. However, if you disconnect or the session is interrupted, wrap the main loop in a try/catch that reconnects and resumes from the last processed team. Storing progress in a temporary CSV lets you resume rather than restart on failure.

Paginating Through All Teams with Get-MgGroup

Teams are backed by Microsoft 365 Groups. Get-MgGroup with the filter resourceProvisioningOptions/Any(x:x eq 'Team') returns only Groups that have Teams provisioned. Without -All, the SDK returns only the first page (up to 100 results). Always use -All to trigger automatic pagination — the SDK issues successive requests using the @odata.nextLink token internally.

$allTeams = Get-MgGroup -Filter "resourceProvisioningOptions/Any(x:x eq 'Team')" `
                        -Property Id,DisplayName,MailNickname,CreatedDateTime `
                        -All

Write-Host "Found $($allTeams.Count) teams in tenant"
Found 1847 teams in tenant

Enumerating Channels Per Team with Get-MgTeamChannel

With the team list in hand, loop through each team and call Get-MgTeamChannel. Archived teams return a 403 Forbidden when you attempt to read their channels — catch this explicitly and record the team as archived rather than failing the whole export. Use -ErrorAction Stop so the catch block fires on the 403 rather than the SDK writing a non-terminating error and continuing with a null result.

$inventory = [System.Collections.Generic.List[PSCustomObject]]::new()

foreach ($team in $allTeams) {
    try {
        $channels = Get-MgTeamChannel -TeamId $team.Id -All -ErrorAction Stop
    }
    catch [Microsoft.Graph.PowerShell.Authentication.Helpers.ErrorDetails] {
        if ($_.Exception.Message -match '403') {
            $inventory.Add([PSCustomObject]@{
                TeamId      = $team.Id
                TeamName    = $team.DisplayName
                ChannelId   = 'N/A'
                ChannelName = 'ARCHIVED — channels inaccessible'
                OwnerUpn    = 'N/A'
            })
            continue
        }
        throw
    }

    foreach ($channel in $channels) {
        $inventory.Add([PSCustomObject]@{
            TeamId      = $team.Id
            TeamName    = $team.DisplayName
            ChannelId   = $channel.Id
            ChannelName = $channel.DisplayName
            OwnerUpn    = $null   # resolved in next step
        })
    }
}

Resolving Channel Owners to Display Names

Channel owners are members with the owner role in the team’s membership, not a property of the channel object itself. Use Get-MgTeamMember filtered by roles/any(r:r eq 'owner') to retrieve only owners for each team, then join that data to the channel rows. Because the owner list is per-team rather than per-channel, fetch it once per team and apply it to all of that team’s channel rows.

$ownerMap = @{}
foreach ($team in $allTeams) {
    try {
        $owners = Get-MgTeamMember -TeamId $team.Id -All -ErrorAction Stop |
                  Where-Object { $_.Roles -contains 'owner' }
        $ownerMap[$team.Id] = ($owners.AdditionalProperties['userPrincipalName']) -join '; '
    }
    catch { $ownerMap[$team.Id] = 'ERROR' }
}

# Patch the inventory rows with resolved owner UPNs
foreach ($row in $inventory) {
    if ($ownerMap.ContainsKey($row.TeamId)) {
        $row.OwnerUpn = $ownerMap[$row.TeamId]
    }
}

Exporting the Full Inventory to CSV

With all rows populated, export to CSV with Export-Csv. Add a timestamp to the filename so successive runs do not overwrite each other. Include -NoTypeInformation to suppress the #TYPE header line that confuses Excel users.

$timestamp  = Get-Date -Format 'yyyyMMdd-HHmmss'
$outputPath = ".\Teams-Inventory-$timestamp.csv"

$inventory | Export-Csv -Path $outputPath -NoTypeInformation -Encoding UTF8
Write-Host "Exported $($inventory.Count) rows to $outputPath"

# Summary stats
$inventory | Group-Object TeamName | Sort-Object Count -Descending |
    Select-Object -First 10 Name, Count | Format-Table -AutoSize

Common Errors

  • Get-MgTeamChannel returns 403 for archived teams. Archived teams are read-only for most operations, and channel enumeration requires the team to be active. Catch the 403 explicitly per the loop example above, record the team as archived, and continue to the next team. Do not use a blanket catch that swallows all errors — you want real failures (like permission issues) to surface.
  • Pagination token expires during long enumeration of large tenants. The -All switch on Graph SDK cmdlets handles internal pagination, but if your loop takes longer than the token TTL between your own API calls (not the SDK’s), you may receive a 410 Gone on a nextLink. The safest mitigation is to batch the work into checkpointed runs of several hundred teams each, writing progress to disk between batches.

Related Cmdlets / See Also

Wrapping Up

A complete Teams channel and owner inventory is the foundation of any Teams governance program. Build the export script once, schedule it monthly, and you will always have a current baseline for access reviews, archival decisions, and license audits. Handle 403s from archived teams, always use -All for pagination, and store credentials in Key Vault rather than scripts.

Send-Item -To