Find idle Power Platform licences
Get-IdlePowerPlatformLicenses.ps1
Lists every Power Platform and Dynamics 365 licence whose owner has not signed in recently.
This is the fastest money in a Power Platform tenant, and the first thing to run before anyone asks for more budget. Disabling an account does not release its licences, so almost every tenant is paying for seats attached to people who left months ago. The script takes the later of the interactive and non-interactive sign-in dates, which matters more than it sounds: an integration account that only ever authenticates non-interactively is not idle, and switching it off to save $20 a month is how you cause an outage.
What it finds
- Licences still assigned to disabled accounts
- Licences on accounts that have never signed in at all
- Licences on accounts quiet for longer than -DaysInactive
- A per-SKU breakdown you can price against your own agreement
Module
Microsoft.Graph (Authentication, Users, Identity.DirectoryManagement)
Install-Module Microsoft.Graph -Scope CurrentUser
Permissions
- User.Read.All
- AuditLog.Read.All
- Organization.Read.All
- Microsoft Entra ID P1 or P2 in the tenant, or signInActivity is not available at all
Run time
1-3 minutes for 10,000 users
379 lines, PowerShell 5.1+
Get-IdlePowerPlatformLicenses.ps1
#Requires -Version 5.1
#Requires -Modules Microsoft.Graph.Authentication
<#
.SYNOPSIS
Finds Power Platform and Dynamics 365 licences assigned to users who have not signed
in recently. Read-only: it reports, it never releases a licence.
.DESCRIPTION
The fastest money in a Power Platform tenant is a licence nobody is using. This script
reads every licensed user from Microsoft Graph together with their sign-in activity,
keeps the Power Platform and Dynamics 365 SKUs, and reports the ones whose owner has
been quiet for longer than -DaysInactive.
It flags three things, in the order they are worth chasing:
1. Licences still assigned to disabled accounts. Disabling an account does not
release its licences. Most tenants have some. This is the cheapest win there is.
2. Licences on accounts that have never signed in at all. Usually a leaver who was
provisioned and never started, or a test account somebody forgot.
3. Licences on accounts whose last sign-in is older than -DaysInactive.
Sign-in activity comes from the signInActivity property on the user object, which
carries both interactive and non-interactive sign-ins. The script takes the later of
the two, so a service-style account that only ever authenticates non-interactively is
not wrongly reported as idle. That distinction matters: reporting an integration
account as unused is how you cause an outage while trying to save money.
Nothing here writes. Removing a licence needs business context this script does not
have, so the output is a review list and a CSV, not an action.
.PARAMETER DaysInactive
How many days of silence make a licence a candidate. Default 90.
.PARAMETER OutputPath
Optional path to a CSV file. The full result set is written there.
.PARAMETER SkuPattern
Regular expression matched against the SKU part number to decide what counts as a
Power Platform or Dynamics 365 licence. The default covers the Power Apps, Power
Automate, Power Pages, Copilot Studio and Dynamics 365 families. Pass '.' to report
every SKU in the tenant.
.PARAMETER TenantId
Optional tenant id or domain passed through to Connect-MgGraph.
.PARAMETER SkipConnect
Reuse the Microsoft Graph session that is already open instead of calling
Connect-MgGraph. Useful when you are running several of these scripts in one session.
.EXAMPLE
.\Get-IdlePowerPlatformLicenses.ps1
Connects, then lists every Power Platform and Dynamics 365 licence whose owner has
not signed in for 90 days.
.EXAMPLE
.\Get-IdlePowerPlatformLicenses.ps1 -DaysInactive 30 -OutputPath .\idle-licences.csv
Tightens the window to 30 days and writes the full result set to CSV for a licence
review meeting.
.EXAMPLE
.\Get-IdlePowerPlatformLicenses.ps1 -SkuPattern '^DYN365' -Verbose |
Format-Table Reason, DisplayName, LicenceName, DaysSinceSignIn -AutoSize
Dynamics 365 SKUs only, with progress, formatted for a report.
.NOTES
Modules Microsoft.Graph.Authentication, Microsoft.Graph.Users and
Microsoft.Graph.Identity.DirectoryManagement (v2.0 or later).
Install-Module Microsoft.Graph -Scope CurrentUser
Scopes User.Read.All, AuditLog.Read.All, Organization.Read.All
AuditLog.Read.All is the one people forget. Reading signInActivity
needs it on top of User.Read.All, and Graph normally rejects the whole
query rather than answering it without the sign-in data. The script
checks the session for the scope up front and says so, because the
failure mode people invent for themselves - dropping signInActivity
from the query to make the error go away - produces a report in which
every account looks dormant.
Licensing signInActivity requires a Microsoft Entra ID P1 or P2 licence in the
tenant. Without one the property is not available at all. That is a
Microsoft restriction, not a bug in this script, and it is the usual
reason a run comes back with sign-in dates missing everywhere.
Read-only GET requests only. It does not assign, remove or modify a licence and
it does not touch an account. The only thing it writes anywhere is the
CSV you ask for with -OutputPath, on your own disk.
Run time Around one to three minutes for 10,000 users. Reading signInActivity is
slower than a plain user list, so budget more time than you expect on a
large tenant.
Author VerseBlocks - https://www.verseblocks.com
#>
[CmdletBinding()]
param(
[ValidateRange(1, 3650)]
[int]$DaysInactive = 90,
[string]$OutputPath,
# Part numbers are inconsistent about underscores between families, so the optional
# ones here are not decoration: POWER_VIRTUAL_AGENTS_VIRAL_TRIAL and POWERAPPS_PER_USER
# both have to match, and an anchored pattern without them silently drops a family.
[string]$SkuPattern = '^(POWER_?APPS|POWERFLOW|POWER_?AUTOMATE|FLOW_|POWER_?PAGES|POWER_?VIRTUAL_?AGENT|VIRTUAL_AGENT|CCIBOTS|CDSAICAPACITY|DYN365|D365_|Dynamics_365)',
[string]$TenantId,
[switch]$SkipConnect
)
# ---------------------------------------------------------------------------
# Friendly names for the SKU part numbers you actually meet in the wild.
# Anything not listed here is still reported, by its raw part number, so the
# script never hides a licence just because the map is incomplete. Microsoft
# publishes the full list at:
# https://learn.microsoft.com/en-us/entra/identity/users/licensing-service-plan-reference
# Add rows as you meet them.
# ---------------------------------------------------------------------------
$SkuFriendlyName = @{
'POWERAPPS_PER_USER' = 'Power Apps Premium (per user)'
'POWERAPPS_PER_APP' = 'Power Apps per app plan'
'POWERAPPS_PER_APP_IWTRIAL' = 'Power Apps per app baseline access'
'POWERAPPS_DEV' = 'Power Apps Developer Plan (free)'
'POWERAPPS_VIRAL' = 'Power Apps Plan 2 Trial'
'POWERFLOW_P2' = 'Power Apps Plan 2'
'FLOW_PER_USER' = 'Power Automate per user plan'
'FLOW_PER_USER_DEPT' = 'Power Automate per user plan (department)'
'FLOW_PER_FLOW' = 'Power Automate per flow plan'
'FLOW_BUSINESS_PROCESS' = 'Power Automate per flow plan'
'FLOW_FREE' = 'Power Automate Free'
'POWERAUTOMATE_ATTENDED_RPA' = 'Power Automate Premium'
'POWERAUTOMATE_UNATTENDED_RPA' = 'Power Automate unattended RPA add-on'
'CDSAICAPACITY' = 'AI Builder capacity add-on'
'DYN365_ENTERPRISE_PLAN1' = 'Dynamics 365 Customer Engagement Plan'
'DYN365_ENTERPRISE_SALES' = 'Dynamics 365 Sales Enterprise'
'DYN365_SALES_PREMIUM' = 'Dynamics 365 Sales Premium'
'D365_SALES_PRO' = 'Dynamics 365 Sales Professional'
'DYN365_ENTERPRISE_CUSTOMER_SERVICE' = 'Dynamics 365 Customer Service Enterprise'
'DYN365_CUSTOMER_SERVICE_PRO' = 'Dynamics 365 Customer Service Professional'
'DYN365_ENTERPRISE_FIELD_SERVICE' = 'Dynamics 365 Field Service Enterprise'
'DYN365_ENTERPRISE_TEAM_MEMBERS' = 'Dynamics 365 Enterprise Team Members'
'DYN365_TEAM_MEMBERS' = 'Dynamics 365 Team Members'
'DYN365_FINANCE' = 'Dynamics 365 Finance'
'DYN365_SCM' = 'Dynamics 365 Supply Chain Management'
'DYN365_BUSCENTRAL_ESSENTIAL' = 'Dynamics 365 Business Central Essentials'
'DYN365_BUSCENTRAL_PREMIUM' = 'Dynamics 365 Business Central Premium'
'DYN365_BUSCENTRAL_TEAM_MEMBER' = 'Dynamics 365 Business Central Team Members'
'DYN365_MARKETING_APP' = 'Dynamics 365 Customer Insights - Journeys'
}
# --- Module check ----------------------------------------------------------
$requiredModules = @(
'Microsoft.Graph.Authentication'
'Microsoft.Graph.Users'
'Microsoft.Graph.Identity.DirectoryManagement'
)
$missingModules = @($requiredModules | Where-Object { -not (Get-Module -ListAvailable -Name $_) })
if ($missingModules.Count -gt 0) {
Write-Error ("Missing PowerShell module(s): {0}. Install the Microsoft Graph SDK, then run this script again: Install-Module Microsoft.Graph -Scope CurrentUser -Repository PSGallery" -f ($missingModules -join ', '))
return
}
# --- Connect ---------------------------------------------------------------
$requiredScopes = @('User.Read.All', 'AuditLog.Read.All', 'Organization.Read.All')
if (-not $SkipConnect) {
Write-Verbose 'Connecting to Microsoft Graph.'
$connectArgs = @{ Scopes = $requiredScopes; ErrorAction = 'Stop' }
if ($TenantId) { $connectArgs['TenantId'] = $TenantId }
# -NoWelcome only exists on Microsoft.Graph 2.x and later. Ask before you pass it.
$connectCommand = Get-Command Connect-MgGraph -ErrorAction SilentlyContinue
if ($connectCommand -and $connectCommand.Parameters.ContainsKey('NoWelcome')) {
$connectArgs['NoWelcome'] = $true
}
try {
Connect-MgGraph @connectArgs
}
catch {
Write-Error ("Could not connect to Microsoft Graph: {0}. Sign in with an account that already holds, or can consent to, User.Read.All, AuditLog.Read.All and Organization.Read.All." -f $_.Exception.Message)
return
}
}
$context = Get-MgContext
if (-not $context) {
Write-Error 'No Microsoft Graph session. Run Connect-MgGraph first, or drop -SkipConnect and let this script connect for you.'
return
}
Write-Verbose ("Connected to tenant {0} as {1}." -f $context.TenantId, $context.Account)
if ($context.Scopes -notcontains 'AuditLog.Read.All') {
Write-Warning 'This Graph session does not hold AuditLog.Read.All. Reading signInActivity needs it on top of User.Read.All, so the user query will most likely be refused outright, and anywhere it is not, sign-in dates come back empty and every account looks dormant. Reconnect with: Connect-MgGraph -Scopes "User.Read.All","AuditLog.Read.All","Organization.Read.All"'
}
# --- SKU lookup ------------------------------------------------------------
Write-Verbose 'Reading subscribed SKUs.'
try {
$subscribedSkus = Get-MgSubscribedSku -ErrorAction Stop
}
catch {
Write-Error ("Could not read subscribed SKUs: {0}. This needs the Organization.Read.All or Directory.Read.All scope." -f $_.Exception.Message)
return
}
$skuPartNumberById = @{}
foreach ($sku in $subscribedSkus) {
$skuPartNumberById[[string]$sku.SkuId] = [string]$sku.SkuPartNumber
}
Write-Verbose ("Found {0} subscribed SKUs in the tenant." -f $skuPartNumberById.Count)
# --- Users -----------------------------------------------------------------
$userProperties = @(
'id'
'displayName'
'userPrincipalName'
'accountEnabled'
'createdDateTime'
'department'
'usageLocation'
'signInActivity'
'assignedLicenses'
)
Write-Verbose 'Reading licensed users and sign-in activity. On a large tenant this is the slow part.'
$users = $null
try {
$users = Get-MgUser -All -Property $userProperties -Filter 'assignedLicenses/$count ne 0' -ConsistencyLevel eventual -CountVariable licensedUserCount -ErrorAction Stop
Write-Verbose ("Graph reports {0} licensed users." -f $licensedUserCount)
}
catch {
Write-Verbose ("Advanced query filter was rejected ({0}). Falling back to reading every user and filtering locally." -f $_.Exception.Message)
try {
$users = Get-MgUser -All -Property $userProperties -ErrorAction Stop
}
catch {
Write-Error ("Could not read users from Microsoft Graph: {0}. Check the signed-in account holds User.Read.All and AuditLog.Read.All." -f $_.Exception.Message)
return
}
}
if (-not $users) {
Write-Warning 'Microsoft Graph returned no users. Nothing to report.'
return
}
# --- Evaluate --------------------------------------------------------------
$now = Get-Date
$cutoff = $now.AddDays(-$DaysInactive)
$results = New-Object System.Collections.Generic.List[object]
$usersWithoutSignInData = 0
$examined = 0
foreach ($user in $users) {
$examined++
if ($examined % 500 -eq 0) { Write-Verbose ("Examined {0} users." -f $examined) }
if (-not $user.AssignedLicenses -or @($user.AssignedLicenses).Count -eq 0) { continue }
$lastInteractive = $null
$lastNonInteractive = $null
if ($user.SignInActivity) {
$lastInteractive = $user.SignInActivity.LastSignInDateTime
$lastNonInteractive = $user.SignInActivity.LastNonInteractiveSignInDateTime
}
# Take the later of the two. A non-interactive sign-in still means the account is
# doing work, and switching off an integration account is not a saving.
$lastSignIn = $null
foreach ($candidate in @($lastInteractive, $lastNonInteractive)) {
if ($candidate -and (-not $lastSignIn -or $candidate -gt $lastSignIn)) { $lastSignIn = $candidate }
}
if (-not $lastSignIn) { $usersWithoutSignInData++ }
$daysSinceSignIn = $null
if ($lastSignIn) { $daysSinceSignIn = [int][math]::Floor(($now - $lastSignIn).TotalDays) }
$accountEnabled = [bool]$user.AccountEnabled
$isIdle = (-not $lastSignIn) -or ($lastSignIn -lt $cutoff)
# An enabled account that signed in inside the window is fine. A disabled account is
# reported whatever its sign-in history says, because the licence is still being paid
# for and cannot possibly be in use.
if (-not $isIdle -and $accountEnabled) { continue }
$priority = 3
$reason = "No sign-in for $daysSinceSignIn days"
if (-not $lastSignIn) {
$priority = 2
$reason = 'Never signed in'
}
if (-not $accountEnabled) {
$priority = 1
$reason = 'Account disabled, licence still assigned'
}
foreach ($assigned in $user.AssignedLicenses) {
$skuId = [string]$assigned.SkuId
if (-not $skuPartNumberById.ContainsKey($skuId)) { continue }
$partNumber = $skuPartNumberById[$skuId]
if ($partNumber -notmatch $SkuPattern) { continue }
$friendlyName = $partNumber
if ($SkuFriendlyName.ContainsKey($partNumber)) { $friendlyName = $SkuFriendlyName[$partNumber] }
$results.Add([pscustomobject]@{
Priority = $priority
Reason = $reason
DisplayName = $user.DisplayName
UserPrincipalName = $user.UserPrincipalName
AccountEnabled = $accountEnabled
LicenceName = $friendlyName
SkuPartNumber = $partNumber
LastSignIn = $lastSignIn
DaysSinceSignIn = $daysSinceSignIn
LastInteractiveSignIn = $lastInteractive
LastNonInteractiveSignIn = $lastNonInteractive
Department = $user.Department
UsageLocation = $user.UsageLocation
AccountCreated = $user.CreatedDateTime
UserId = $user.Id
})
}
}
$sorted = @($results | Sort-Object Priority, @{ Expression = 'DaysSinceSignIn'; Descending = $true })
# --- Output ----------------------------------------------------------------
if ($OutputPath) {
try {
$sorted | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8 -ErrorAction Stop
Write-Verbose ("Wrote {0} rows to {1}." -f $sorted.Count, $OutputPath)
}
catch {
Write-Error ("Could not write the CSV to '{0}': {1}. Check the folder exists and is writable, then run again." -f $OutputPath, $_.Exception.Message)
}
}
$sorted
Write-Host ''
Write-Host 'Idle Power Platform and Dynamics 365 licences' -ForegroundColor Cyan
Write-Host (' Users examined : {0}' -f $examined)
Write-Host (' Inactivity window : {0} days (nothing since {1:yyyy-MM-dd})' -f $DaysInactive, $cutoff)
Write-Host (' Candidate licences found : {0}' -f $sorted.Count)
if ($sorted.Count -gt 0) {
$onDisabled = @($sorted | Where-Object { $_.Priority -eq 1 }).Count
$neverUsed = @($sorted | Where-Object { $_.Priority -eq 2 }).Count
$staleUsed = @($sorted | Where-Object { $_.Priority -eq 3 }).Count
Write-Host (' On disabled accounts : {0}' -f $onDisabled)
Write-Host (' Never signed in : {0}' -f $neverUsed)
Write-Host (' Idle beyond the window : {0}' -f $staleUsed)
Write-Host ''
Write-Host ' By licence:'
foreach ($group in ($sorted | Group-Object LicenceName | Sort-Object Count -Descending)) {
Write-Host (' {0,-52} {1}' -f $group.Name, $group.Count)
}
}
if ($usersWithoutSignInData -gt 0) {
Write-Host ''
Write-Host (' {0} accounts came back with no sign-in data at all.' -f $usersWithoutSignInData) -ForegroundColor Yellow
Write-Host ' A handful is normal - those accounts really have never signed in. If it is most' -ForegroundColor Yellow
Write-Host ' of the tenant, the cause is almost always no Entra ID P1/P2 licence, which means' -ForegroundColor Yellow
Write-Host ' signInActivity is not available at all. Fix that before you act on this list.' -ForegroundColor Yellow
}
Write-Host ''
Write-Host ' Nothing was changed. Confirm each account with its owner before you reclaim.'
Write-Host ''