Stop Paying for Shared Mailboxes: Auto-Reclaim Licenses Across Every Tenant

TL;DR
- Converting a departed user's mailbox to shared retains the data without a license, but a technician who forgets to strip the license keeps you paying for an unused seat.
- An Azure Function on a timer can loop every customer tenant, find licensed shared mailboxes, and remove the licenses automatically on a schedule.
- The function uses the secure application model for headless auth and the AzureADPreview and PartnerCenter modules declared in Requirements.psd1.
- Because Connect-AzureAD does not accept the delegated admin token, the script reconnects per customer inside the loop using Partner Center access tokens.
- A standalone local version using MSOnline cmdlets is provided for MSPs who prefer to run the cleanup on demand instead of as a scheduled job.
Across a book of customers, offboarding mistakes are inevitable. The right move when a user leaves is to convert their mailbox to shared, retaining the data without paying for a subscription. The problem is the step that gets skipped: a technician converts the mailbox but forgets to strip the license. Multiply that across every tenant you manage and you are quietly paying for seats nobody uses. This post shows how to use an Azure Function and PowerShell to find and remove licenses from shared mailboxes across all your customers on a schedule, with a local version for anyone who would rather run it on demand.
Before you build this
In a previous post on running PowerShell in Azure Functions, we covered the fundamentals of Function Apps and setting them up with the secure application model (opens in new tab) for headless authentication. Read those first to understand the foundation and to gather the secrets you need to authenticate.
If you would rather run this locally instead of through Azure Functions, you still need to follow Kelvin's guide for the secure application model GUIDs and tokens linked above. The benefit of the Function approach is scheduling it as a timed job that runs periodically, instead of remembering to run it yourself.
Setting up the Function App
- Follow the steps in the Azure Functions post to add a Function App and modify its configuration. General Settings should have the platform set to 64 Bit, and you should have the following Application Settings as environment variables:
- ApplicationId
- ApplicationSecret
- tenantID
- refreshToken
- upn
- ExchangeRefreshToken

- Go to App Files, find the Requirements.psd1 file in the dropdown, and add the dependencies:

- Click the Functions tab and click + Add.

- Select Timer Trigger and add a CRON value. See Microsoft's timer trigger reference (opens in new tab) for the schedule format. This example runs every day at 9:30.

- After you click add, click Code + Test. A boilerplate script appears in the run.ps1 file. Leave the
param($Timer)line and add the import lines for the modules you declared. Click Save.

- With the file saved, click Test/Run. A new window opens on the right. Do not modify anything there. Click Run. The script connects and you see a message telling you the managed dependency is downloading.

Error handling: Some setups error out with a timeout message here. If that happens, run one Import-Module line at a time. Remove one, Save, Test Run, let it install and import, then add the second line, Save, Test Run, and confirm the second import.
The scheduled cleanup script
- With the modules loaded, do a test run. This script loops through all your customers, finds users with licensed shared mailboxes, and removes the licenses. It logs who was a licensed shared mailbox user at each customer. It helps to have a known licensed shared mailbox lined up as your test case. Paste this into the function:
using namespace System.Net
param($Timer)
Import-Module AzureADPreview -UseWindowsPowershell
Import-Module PartnerCenter -UseWindowsPowershell
$ApplicationId = $ENV:ApplicationId
$ApplicationSecret = $ENV:ApplicationSecret
$secPas = $ApplicationSecret| ConvertTo-SecureString -AsPlainText -Force
$tenantID = $ENV:tenantID
$refreshToken = $ENV:refeshToken
$ExchangeRefreshToken = $ENV:ExchangeRefreshToken
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $secPas)
###Connect to your Own Partner Center to get a list of customers/tenantIDs #########
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID
Connect-AzureAD -AadAccessToken $aadGraphToken.AccessToken -AccountId $upn -MsAccessToken $graphToken.AccessToken
$customers = Get-AzureADContract
Write-Host "Found $($customers.Count) customers." -ForegroundColor DarkGreen
foreach ($customer in $customers) {
Write-Host "Checking Shared Mailboxes for $($Customer.DisplayName)" -ForegroundColor Green
$token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $customer.CustomerContextId
$tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
$credential1 = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
$customerId = $customer.DefaultDomainName
$session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($customerId)&BasicAuthToOAuthConversion=true" -Credential $credential1 -Authentication Basic -AllowRedirection
Import-PSSession $session -AllowClobber
$sharedMailboxes = Get-Mailbox -ResultSize Unlimited -Filter {recipienttypedetails -eq "SharedMailbox"}
Remove-PSSession $session
try{
$CustAadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $customer.CustomerContextId
$CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $customer.CustomerContextId
Connect-AzureAD -AadAccessToken $CustAadGraphToken.AccessToken -AccountId $upn -MsAccessToken $CustGraphToken.AccessToken -TenantId $customer.CustomerContextId
$licensedUsers = Get-AzureADUser -All $true | where {$_.AssignedLicenses}
}catch{"There was an error"}
foreach ($mailbox in $sharedMailboxes) {
Add-Type -Path "C:\home\data\ManagedDependencies\210414162202318.r\AzureADPreview\2.0.2.134\Microsoft.Open.AzureADBeta.Graph.PowerShell.dll"
Add-Type -Path "C:\home\data\ManagedDependencies\210414162202318.r\AzureADPreview\2.0.2.134\Microsoft.Open.AzureAD16.Graph.Client.dll"
if ($licensedUsers.ObjectId -contains $mailbox.ExternalDirectoryObjectID) {
Write-Host "$($mailbox.displayname) is a licensed shared mailbox" -ForegroundColor Yellow
$userUPN="$($mailbox.userPrincipalName)"
$userList = Get-AzureADUserLicenseDetail -ObjectID $userUPN
$Skus = $userList.SkuId
Write-Host $Skus
if($Skus -is [array])
{
$licenses = New-Object -TypeName Microsoft.Open.AzureAD.Model.AssignedLicenses
foreach ($lic in $($Skus -split ' ')) {
$Licenses.RemoveLicenses = $lic
Set-AzureADUserLicense -ObjectId $userUPN -AssignedLicenses $licenses
}
} else {
$licenses = New-Object -TypeName Microsoft.Open.AzureAD.Model.AssignedLicenses
$Licenses.RemoveLicenses = $Skus
Set-AzureADUserLicense -ObjectId $userUPN -AssignedLicenses $licenses
}
Disconnect-AzureAD
}
}
}The full version is on GitHub (AzureFunctionsShared.ps1) (opens in new tab).
- When you run it, the log output looks like this:

Handling the most common error
The most common error you are likely to see is this one:

It means there is no Exchange license within that tenant. It is unlikely across a real customer base, but it can come up in a test environment.
Running the cleanup locally instead
If you would rather run this on demand, the local version is on GitHub (Unlicense Shared Mailbox.ps1) (opens in new tab). Fill in the first block of variables with your secure application model values:
$ApplicationId = ""
$ApplicationSecret = ""
$secPas = $ApplicationSecret| ConvertTo-SecureString -AsPlainText -Force
$tenantID = ""
$refreshToken = ''
$ExchangeRefreshToken = ''
$upn = ''
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $secPas)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
Write-Host "Found $($customers.Count) customers for $((Get-MsolCompanyInformation).displayname)." -ForegroundColor DarkGreen
foreach ($customer in $customers) {
#Get ALL Licensed Users and Find Shared Mailboxes#
try{
$licensedUsers = Get-MsolUser -TenantId $customer.TenantId | Where-Object {$_.islicensed}
Write-Host "Checking Shared Mailboxes for $($Customer.Name)" -ForegroundColor Green
$token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $customer.TenantId -ErrorAction SilentlyContinue
$tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
$customerId = $customer.DefaultDomainName
$InitialDomain = Get-MsolDomain -TenantId $customer.TenantId | Where-Object {$_.IsInitial -eq $true}
$session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($InitialDomain)&BasicAuthToOAuthConversion=true" -Credential $credential -Authentication Basic -AllowRedirection -ErrorAction SilentlyContinue
Import-PSSession $session
$sharedMailboxes = Get-Mailbox -ResultSize Unlimited -Filter {recipienttypedetails -eq "SharedMailbox"}
Remove-PSSession $session
foreach ($mailbox in $sharedMailboxes) {
if ($licensedUsers.ObjectId -contains $mailbox.ExternalDirectoryObjectID) {
Write-Host "$($mailbox.displayname) is a licensed shared mailbox" -ForegroundColor Yellow
$licenses = ($licensedUsers | Where-Object {$_.objectid -contains $mailbox.ExternalDirectoryObjectId}).Licenses
$licenseArray = $licenses | foreach-Object {$_.AccountSkuId}
Write-Host "Removing License" -ForegroundColor Yellow
$mailbox | ForEach-Object {
Set-MsolUserLicense -UserPrincipalName "$($mailbox.UserPrincipalName)" -TenantId $($customer.TenantId) -removelicenses $licenseArray -ErrorAction SilentlyContinue
}
}
}
}catch { "An error occurred."}
}A note on the build
Azure Functions are where scheduled PowerShell tasks are heading, though they are still maturing. We had to write extra lines to get the function to do what the local script does in fewer, which is worth knowing before you commit a job to it. The payoff is a cleanup that runs on its own and keeps you from paying for seats nobody uses.
Frequently asked questions
Why do licensed shared mailboxes happen?
When you offboard a user, converting their mailbox to shared keeps the data without a paid subscription. But the conversion does not remove the license automatically. If a technician forgets that step, the mailbox is shared yet still consuming a paid license, which is pure margin leakage across the customers you manage.
Do I have to use Azure Functions to run this?
No. The scheduled Azure Function is the hands-off option, but a standalone local script is included that you run on demand. Both use the secure application model for authentication; the difference is whether the cleanup runs automatically on a timer or when you launch it.
What does the 'no exchange license within the tenant' error mean?
It simply means the tenant has no Exchange license to query. It is uncommon across a customer base but can appear in a test environment or a tenant with no Exchange Online presence. The error is safe to expect and does not indicate a script problem.
Margin leakage is just one kind of drift
Forgotten licenses cost money. Forgotten security controls cost clients. CloudCapsule scans every tenant against 250+ controls in 60 seconds and surfaces the misconfigurations that crept back in since last quarter.
Try a free scan
Written by
Nick Ross
CEO · Microsoft MVP · Founder, T-Minus 365
Nick is not just a CEO, he's a respected thought leader and influencer in the MSP space. Tens of thousands of MSPs learn through his YouTube channel, T-Minus365. Nick has been honored as a three-time Microsoft MVP for his educational content; his expertise and influence are the backbone of our mission, ensuring that you are in the best hands when it comes to security.
Nick joined Pax8 in 2017, where he would ultimately oversee product management for PSA and Microsoft integrations. Following his tenure at Pax8, Nick has continued to demonstrate his leadership prowess as an executive at various MSPs, culminating in his most recent role at Sourcepass.
Nick holds a Bachelor's Degree in Business Management from Florida State University, as well as a Minor Degree in Entrepreneurship. In his free time, Nick is an avid hiker, reader, and fitness-junkie.


