Export Microsoft 365 Licenses to CSV (Graph App-Only)
Export every Microsoft 365 license (subscribed SKU) to a timestamped CSV with consumed-versus-available units, using app-only Microsoft Graph certificate authentication so it runs unattended.
by Emanuel De Almeida
TL;DR
- Exports every Microsoft 365 license (subscribed SKU) to a timestamped CSV under C:\Temp\Export.
- Authenticates to Microsoft Graph app-only with a certificate, so it runs unattended with no interactive sign-in.
- Reports the SKU id, friendly name, purchased units and consumed units, sorted by usage.
- Needs an Entra ID app registration with the Organization.Read.All application permission.
- Ideal for scheduled license reporting and true-up before renewals.
What does this script do?
It connects to Microsoft Graph using application (app-only) authentication, enumerates every subscribed SKU that applies to users, and writes a clean CSV row per license with how many units you own and how many are in use. Each run is timestamped and logged, so you can drop it into a scheduled task and keep a history of license consumption over time.
Why export Microsoft 365 license data?
License sprawl is expensive. Exporting consumed-versus-available units on a schedule shows exactly where you are over-provisioned, flags SKUs about to run out before a renewal, and gives finance an auditable record. Pulling it through Graph means no manual clicking around the admin center.
What do you need before running it?
- PowerShell 5.1 or later.
- The Microsoft Graph PowerShell SDK installed.
- An Entra ID app registration with the Organization.Read.All application permission, granted admin consent.
- A client certificate uploaded to that app, with its thumbprint available on the machine that runs the script.
Install the Graph SDK if you do not already have it:
Install-Module Microsoft.Graph -Scope CurrentUserHow do you set up app-only authentication?
- In the Entra admin center, register a new application (or reuse one).
- Under API permissions, add the Microsoft Graph application permission Organization.Read.All and grant admin consent.
- Under Certificates & secrets, upload a public certificate whose private key is installed on the machine that will run the script.
- Copy the application (client) id, your tenant id and the certificate thumbprint into the three variables at the top of the script.
How do you run the script?
Set the three connection variables, then run it from a session that can read the certificate's private key:
.\Export-M365Licenses.ps1The CSV lands in C:\Temp\Export\Licenses-<date>.csv and a run log is written to C:\Temp\Log.
What is in the CSV output?
- TimeStamp: when the row was written.
- SkuId: the GUID of the subscribed SKU.
- License: the SkuPartNumber, for example ENTERPRISEPACK or SPE_E3.
- Available: purchased units that are enabled.
- ConsumedUnits: how many of those are currently assigned.
FAQ
Does this script change any licenses?
No. It only reads subscribed SKUs and writes a CSV. It uses read-only Organization.Read.All and never assigns or removes licenses.
Can I run it without a certificate?
App-only auth with a certificate is recommended for unattended runs. You can swap in a client secret or interactive sign-in, but a certificate avoids storing a secret and survives scheduled execution.
How do I turn SkuPartNumber into a friendly product name?
Microsoft publishes a product names and service plan identifiers reference that maps values like SPE_E3 to 'Microsoft 365 E3'. Join your export against that list for human-readable reports.
The script
#Requires -Version 5.1
#Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Identity.DirectoryManagement
<#
.SYNOPSIS
Exports every Microsoft 365 subscribed SKU (license) to a CSV file.
.DESCRIPTION
Connects to Microsoft Graph with app-only (certificate) authentication,
reads all user-facing subscribed SKUs, and writes the SKU id, friendly
name, purchased (enabled) units and consumed units to a timestamped CSV.
Logs and the export are written under C:\Temp.
Fill in your tenant id, application (client) id and certificate thumbprint
below. The app registration needs the Organization.Read.All application
permission with admin consent.
.NOTES
Author : Emanuel De Almeida - https://www.navanem.com
Version: 1.0
#>
If ([Net.SecurityProtocolType]::Tls12 -bor $False) {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Write-Host "`t Forced TLS 1.2 since it is not the server default"
}
$Global:ErrorActionPreference = 'Stop'
# ─── Connection variables (replace with your own) ───
$Tenant_ID = '<your-tenant-id>'
$Application_ID = '<your-application-id>'
$Certificate_Thumb_Print = '<your-certificate-thumbprint>'
# Log helper
Function Write-Log {
Param(
[Parameter(Mandatory = $true)][String]$Message,
[Parameter(Mandatory = $true)][String]$Type
)
$Date = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
"$Date - $Type - $Message" |
Out-File -FilePath "C:\Temp\Log\$(Get-Date -Format 'yyyy-MM-dd').log" -Append -Encoding UTF8
}
# Make sure the output folders exist
Function CheckFilePath {
If (-not (Test-Path -Path 'C:\Temp\Log')) { New-Item 'C:\Temp\Log' -ItemType Directory | Out-Null }
If (-not (Test-Path -Path 'C:\Temp\Export')) { New-Item 'C:\Temp\Export' -ItemType Directory | Out-Null }
}
CheckFilePath
Try {
Write-Log -Message 'Connecting to Graph' -Type 'Information'
Connect-MgGraph -ClientId $Application_ID -CertificateThumbprint $Certificate_Thumb_Print -TenantId $Tenant_ID
Write-Log -Message 'Connected to Graph' -Type 'Success'
} Catch {
Write-Host "`n`t$($_.InvocationInfo.InvocationName) [Line:$($_.InvocationInfo.ScriptLineNumber)]: $($_.Exception.Message)" -ForegroundColor Yellow
Write-Log -Message "$($_.InvocationInfo.InvocationName) [Line:$($_.InvocationInfo.ScriptLineNumber)]: $($_.Exception.Message)" -Type 'Error'
Write-Log -Message 'Unable to connect' -Type 'Error'
Break
}
Try {
Write-Log -Message 'Gathering licenses' -Type 'Information'
$Licenses = Get-MgSubscribedSku -All -Property * |
Where-Object { $_.AppliesTo -eq 'User' } |
Sort-Object -Property ConsumedUnits -Descending
Write-Log -Message "Found $($Licenses.Count) types of licenses" -Type 'Success'
} Catch {
Write-Host "`n`t$($_.InvocationInfo.InvocationName) [Line:$($_.InvocationInfo.ScriptLineNumber)]: $($_.Exception.Message)" -ForegroundColor Yellow
Write-Log -Message "$($_.InvocationInfo.InvocationName) [Line:$($_.InvocationInfo.ScriptLineNumber)]: $($_.Exception.Message)" -Type 'Error'
Write-Log -Message 'Unable to get licenses' -Type 'Error'
}
Try {
Write-Log -Message 'Exporting information' -Type 'Information'
Foreach ($License in $Licenses) {
[PSCustomObject]@{
TimeStamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
SkuId = $License.SkuId
License = $License.SkuPartNumber
Available = $License.PrepaidUnits.Enabled
ConsumedUnits = $License.ConsumedUnits
} | Export-Csv -Path "C:\Temp\Export\Licenses-$(Get-Date -Format 'yyyy-MM-dd').csv" -Delimiter ',' -Encoding UTF8 -NoTypeInformation -Append -Force
}
Write-Log -Message 'Information exported' -Type 'Success'
} Catch {
Write-Host "`n`t$($_.InvocationInfo.InvocationName) [Line:$($_.InvocationInfo.ScriptLineNumber)]: $($_.Exception.Message)" -ForegroundColor Yellow
Write-Log -Message "$($_.InvocationInfo.InvocationName) [Line:$($_.InvocationInfo.ScriptLineNumber)]: $($_.Exception.Message)" -Type 'Error'
Write-Log -Message 'Unable to export information' -Type 'Error'
}
Review before running. Test in a non-production environment first.
