Table of Contents
Who Performed an Azure AD License Assignment?
After writing about how to detect underused (and expensive) licenses assigned to Azure AD accounts, I was asked if it was possible to report who assigned a license to accounts. It’s a good question that stumped me for a moment. There’s no obvious off-the-shelf indication of who assigned licenses to accounts in any Microsoft 365 administrative interface.
Azure AD Audit Data
License assignment is an Azure AD activity. It’s therefore possible to find information about these actions in the Azure AD audit log by searching for “Change user license” events. Unfortunately, these events only note that some sort of license assignment occurred. It doesn’t tell you what happened to licenses in terms of additions, removals, or disabling service plans in licenses. For that information, you need to find a matching “Update user” event where the license assignment detail is captured in the Modified Properties tab (Figure 1).

Unfortunately, the Get-MgAuditLogDirectoryAudit cmdlet doesn’t report the same level of detail about license assignments, so the Azure AD audit log isn’t a good source for reporting.
License Assignment Records in the Unified Audit Log
Azure AD is a source for the Office 365 (unified) audit log and the information ingested into the Office 365 audit log is more comprehensive albeit formatted in such a way that the data isn’t easy to fetch. However, we can find enough data to write a PowerShell script to create a basic report that contains enough information to at least give administrators some insight into who assigns licenses.
To create the report, the script:
- Ran the Search-UnifiedAuditLog cmdlet to retrieve audit records for the Change user license and Update User actions.
- Create separate arrays for both types of event.
- For each Change user license event, see if there’s a matching Update user record. If one is found, extract the license assignment information from the record.
- Report what’s been found.
Here’s the script to prove that the concept works:
# Azure AD license assignment script $StartDate = (Get-Date).AddDays(-90) $EndDate = (Get-Date).AddDays(1) Write-Host "Searching for license assignment audit records" [array]$Records = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Formatted -ResultSize 5000 -Operations "Change user license", "Update User" -SessionCommand ReturnLargeSet If (!($Records)) { Write-Host "No audit records found... exiting... " ; break} Write-Host ("Processing {0} records" -f $Records.count) $Records = $Records | Sort-Object {$_.CreationDate -as [datetime]} -Descending [array]$LicenseUpdates = $Records | Where-Object {$_.Operations -eq "Change user license."} [array]$UserUpdates = $Records | Where-Object {$_.Operations -eq "Update user."} $Report = [System.Collections.Generic.List[Object]]::new() ForEach ($L in $LicenseUpdates) { $NewLicenses = $Null; $OldLicenses = $Null; $OldSkuNames = $Null; $NewSkuNames = $Null $AuditData = $L.AuditData | ConvertFrom-Json $CreationDate = Get-Date($L.CreationDate) -format s [array]$Detail = $UserUpdates | Where-Object {$_.CreationDate -eq $CreationDate -and $_.UserIds -eq $L.UserIds} If ($Detail) { # Found a user update record [int]$i = 0 $LicenseData = $Detail[0].AuditData | ConvertFrom-Json [array]$OldLicenses = $LicenseData.ModifiedProperties | Where {$_.Name -eq 'AssignedLicense'} | Select-Object -ExpandProperty OldValue | Convertfrom-Json If ($OldLicenses) { [array]$OldSkuNames = $Null ForEach ($OSku in $OldLicenses) { $OldSkuName = $OldLicenses[$i].Substring(($OldLicenses[$i].IndexOf("=")+1), ($OldLicenses[$i].IndexOf(",")-$OldLicenses[$i].IndexOf("="))-1) $OldSkuNames += $OldSkuName $i++ } $OldSkuNames = $OldSkuNames -join ", " } [array]$NewLicenses = $LicenseData.ModifiedProperties | Where {$_.Name -eq 'AssignedLicense'} | Select-Object -ExpandProperty NewValue | Convertfrom-Json If ($NewLicenses) { $i = 0 [array]$NewSkuNames = $Null ForEach ($N in $NewLicenses) { $NewSkuName = $NewLicenses[$i].Substring(($NewLicenses[$i].IndexOf("=")+1), ($NewLicenses[$i].IndexOf(",")-$NewLicenses[$i].IndexOf("="))-1) $NewSkuNames += $NewSkuName $i++ } $NewSkuNames = $NewSkuNames -join ", " } } # end if $ReportLine = [PSCustomObject] @{ Operation = $AuditData.Operation Timestamp = Get-Date($AuditData.CreationTime) -format g 'Assigned by' = $AuditData.UserId 'Assigned to' = $AuditData.ObjectId 'Old SKU' = $OldSkuNames 'New SKU' = $NewSkuNames 'New licenses' = $NewLicenses 'Old licenses' = $OldLicenses } $Report.Add($ReportLine) } $Report = $Report | Sort-Object {$_.TimeStamp -as [datetime]} $Report | Out-GridView
The output is sparse (Figure 2) but I reckon it is sufficient to understand what happens when a license assignment occurred. Events without any license detail appear to be when an administrator removes a license from an account or a service plan from a license.

I didn’t bother attempting to parse out the license detail. The information returned by Azure AD includes all the licenses assigned to an account, so you’d end up with something like this for an account with three licenses. Splitting the individual licenses and disabled service plans out from this information is an exercise for the reader.
$NewLicenses.Split(',') [SkuName=POWER_BI_STANDARD AccountId=a662313f-14fc-43a2-9a7a-d2e27f4f3478 SkuId=a403ebcc-fae0-4ca2-8c8c-7a907fd6c235 DisabledPlans=[]] [SkuName=ENTERPRISEPACK AccountId=a662313f-14fc-43a2-9a7a-d2e27f4f3478 SkuId=6fd2c87f-b296-42f0-b197-1e91e994b900 DisabledPlans=[]] [SkuName=TOPIC_EXPERIENCES AccountId=a662313f-14fc-43a2-9a7a-d2e27f4f3478 SkuId=4016f256-b063-4864-816e-d818aad600c9 DisabledPlans=[]]
Principal Proved
In any case, the answer to the question is that it’s possible to track and report Azure AD license assignments by using the audit log to extract events relating to these actions and parsing the information in the events. The resulting output might not be pretty (but could be cleaned up), but it’s enough to prove the principal.
Learn how to exploit the data available to Microsoft 365 tenant administrators through the Office 365 for IT Pros eBook. We love figuring out how things work.
Search-UnifiedAuditLog cmdlet only available for me after connecting to exchange online. However, that doesn’t pull any information for license assigment only the changes made on exchange. Any suggestions?
Search-UnifiedAuditLog is a cmdlet in the Exchange Online management module, hence why you must connect to EXO before you can run it.
Azure AD sends information about license assignments (all assignments) to the log. It tracks more than changes made on Exchange.
Hi! I am testing this script but when I execute it is returning time out after some time executing. 🙁
And because it’s PowerShell, you can run the script line by line to find out where the time-out occurs. It might just be a transient service issue.
My timeou is here
[array]$Records = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Formatted -ResultSize 5000 -Operations “Change user license”, “Update User”
Write-ErrorMessage : The operation has timed out
Add -SessionCommand ReturnLargeSet to the command and see if it works. There have been some problems with the cmdlet recently and this parameter can help. Remember to sort the returned items afterwards. I’ve updated the code to reflect these chamges.
Great Tony, now it works, but that variables new and old are returned empty. 🙁
The code ran and found license update events for me. If you mean the old and new license data, Microsoft seems to have changed the format of the captured data since I wrote the script. It’s just a matter of extracting the right data from the $AuditData variable. I don’t have time to look at it now…
The issue appears to be where you made the license changes. If you remove the license through the O365 portal, then the script returns the results. I have some users that I used a PowerShell script to remove all licenses from via the Azure AD module. The results in the log output are completely different for these users even though the RecordType and EventType are the same.
In the AAD PS result, all data is in ExtendedProperties and ModifiedProperties is blank. Also all of the results appear to be bracketed by multiple backslashes and other characters. I am trying to work out what the format is and how to get it out to modify the script. An example of the start of an ExtendedProperties output is below.
{@{Name=additionalDetails; Value={“id”:”9ebc85e1-0138-45c2-ac02-a428ff1be8e4″,”seq”:”1″,”b”:”{\”targetUpdatedProperties\”:\”[{\\\”Name\\\”:\\\”AssignedLicense\\\”,\\\”OldValue\\\”:[\\\”[SkuName=SPE_E3,
The sad truth is that the Entra ID team needs to fix the problem with the audit records. The PS inconsistency doesn’t surprise me because the Azure AD module is on its way to retirement and no one is paying much attention to it.
Thanks Tony. What module would you recommend because I was not using the MSOL module because that had supposedly been discontinued?
Also do you have any idea what format that data is in? Is it JSON with characters that indicate the levels or need to be stripped out to make it appear normal?
Thank you for your time.
What module would I recommend to do what? As a replacement for MSOL? If so, that’s the Microsoft Graph PowerShell SDK.
As to ‘what format,’ if you mean the format of audit records, the AuditData property is in JSON. That’s why I use the ConvertFrom-JSON cmdlet to make the data more accessible to PowerShell.