Short answer: run .\Get-EntraInactiveUsers.ps1 -Days 90 -CsvPath .\inactive.csv. It reads every user’s signInActivity from Microsoft Graph and lists accounts whose last successful sign-in (lastSuccessfulSignInDateTime) is older than -Days, plus accounts that never signed in. Add -LicensedOnly to find paid licences you can reclaim, or -UserType Guest for stale guests. signInActivity needs a Microsoft Entra ID P1 or P2 licence in the tenant and the AuditLog.Read.All permission.
Commands checked against the official documentation (linked below) on 6 October 2026; not yet run on our lab servers. The script was syntax-checked with PowerShell 7.4.6 and PSScriptAnalyzer 1.25 (no errors or warnings) but has not yet been run against a live Microsoft 365 tenant. Every cmdlet, endpoint, property and permission it uses was checked against Microsoft Learn. If something behaves differently for you, tell us and we will fix the script.
Table of Contents
What it does
This is the cloud version of our inactive AD users guide. Entra ID does not have lastLogonTimestamp; it has the signInActivity property on each user, with three dates:
| Property | What Microsoft records |
|---|---|
lastSignInDateTime | Last interactive sign-in attempt, successful or not |
lastNonInteractiveSignInDateTime | Last non-interactive sign-in attempt (a client using a token on the user’s behalf), successful or not |
lastSuccessfulSignInDateTime | Last successful interactive or non-interactive sign-in. Microsoft recommends it for inactive-user reports |
Because the first two include failures, an account that is being password-sprayed looks “active” if you go by them. The script therefore uses lastSuccessfulSignInDateTime by default (-Basis Successful). Microsoft did not backfill that property when it was introduced, so if it is empty the script falls back to the newest of the other two dates and says so in the BasisUsed column. -Basis AnyAttempt uses the newest of all three instead.
- Members and guests:
-UserType Member,GuestorAll(default). Guests also get their invitation state (externalUserState). - Licensed only:
-LicensedOnlykeeps users with at least one entry inassignedLicenses. - New accounts created inside the window are skipped unless you add
-IncludeRecentlyCreated, so last week’s starters do not show up as “never signed in”. - Disabled accounts are skipped unless
-IncludeDisabled. - Synced users are flagged (OnPremisesSynced) because they must be disabled in on-premises AD, not in Entra ID.
- Read-only: the script only lists accounts. Disabling or deleting them is a separate, deliberate step.
Requirements
- Windows PowerShell 5.1 or PowerShell 7 with the
Microsoft.Graph.Authenticationmodule. - Licence: Microsoft Entra ID P1 or P2 in the tenant. Without it Graph refuses
signInActivityand the script stops with an explanation. - Permissions:
User.Read.AllandAuditLog.Read.All(delegated or application). - Role: Microsoft names Reports Reader as the least privileged role for reading sign-in activity; Global Reader also works.
Download and first run
- Copy the script from the box on this page (or use the download button) and save it as
C:\Scripts\Get-EntraInactiveUsers.ps1. - If you downloaded the file, clear the “downloaded from the internet” mark so the execution policy lets it run:
Unblock-File C:\Scripts\Get-EntraInactiveUsers.ps1. - Install the module once, for your user:
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser. - Read the built-in help, then run it once interactively and look at the result on screen before you export or schedule anything.
cd C:\Scripts
Get-Help .\Get-EntraInactiveUsers.ps1 -Full
.\Get-EntraInactiveUsers.ps1 -Days 90
.\Get-EntraInactiveUsers.ps1 -Days 90 -LicensedOnly -UserType Member
Options
| Parameter | What it does | Default |
|---|---|---|
-Days | Inactive for at least this many days (1 to 3650) | 90 |
-UserType | All, Member or Guest | All |
-LicensedOnly | Only users with an assigned licence | Off |
-IncludeDisabled | Also report blocked accounts | Off |
-IncludeRecentlyCreated | Also report accounts created less than -Days ago | Off |
-Basis | Successful (last successful sign-in) or AnyAttempt (newest of all three dates) | Successful |
-CsvPath / -HtmlPath | Write CSV and/or HTML | Screen only |
-PassThru | Send objects down the pipeline | Off |
-TenantId, -ClientId, -CertificateThumbprint | App-only sign-in for scheduled runs | Interactive |
Usage examples
# Everyone inactive for 90 days, to CSV
.\Get-EntraInactiveUsers.ps1 -Days 90 -CsvPath C:\Reports\inactive-90.csv
# Licences to reclaim: licensed members idle for 60 days
.\Get-EntraInactiveUsers.ps1 -Days 60 -LicensedOnly -UserType Member -HtmlPath C:\Reports\licensed-idle.html
# Stale guests, including ones already blocked
.\Get-EntraInactiveUsers.ps1 -Days 180 -UserType Guest -IncludeDisabled -CsvPath .\stale-guests.csv
# Accounts that never signed in at all
.\Get-EntraInactiveUsers.ps1 -Days 30 -PassThru | Where-Object { -not $_.LastActivity }
CSV columns
No sample output is shown because the script has not yet run against a live tenant. Columns:
| Column | Meaning |
|---|---|
| DisplayName, UserPrincipalName, UserType, AccountEnabled | From GET /users |
| IsLicensed, LicenseCount | From assignedLicenses |
| CreatedDateTime | When the account was created (UTC) |
| LastSuccessfulSignIn, LastInteractiveSignIn, LastNonInteractiveSignIn | The three signInActivity dates (UTC) |
| LastActivity, DaysInactive | The date the script judged by, and whole days since then |
| Status | “Inactive N days” or “NeverSignedIn (or last sign-in before April 2020)” |
| BasisUsed | LastSuccessful, AnyAttempt, or “Fallback: last attempt (no successful sign-in recorded)” |
| OnPremisesSynced | True for users synced from AD (disable them there) |
| ExternalUserState | Guest invitation state (for example PendingAcceptance) |
| Id | Object ID, handy for follow-up scripts |
Schedule it
Scheduled runs cannot answer a sign-in prompt, so use app-only sign-in with a certificate. Microsoft’s steps are in Use app-only authentication with the Microsoft Graph PowerShell SDK; in short:
- Create an app registration in the Microsoft Entra admin center (single tenant).
- Create a certificate on the machine that will run the task, install it in the certificate store of the account that runs the task, and upload the public key (.cer) to the app registration.
- Under API permissions add these Application permissions for Microsoft Graph and grant admin consent:
User.Read.AllandAuditLog.Read.All. - Note the application (client) ID, the tenant ID and the certificate thumbprint.
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -ExecutionPolicy Bypass -File C:\Scripts\Get-EntraInactiveUsers.ps1 -Days 90 -LicensedOnly -CsvPath C:\Reports\inactive.csv -TenantId contoso.onmicrosoft.com -ClientId <app-id> -CertificateThumbprint <thumbprint>'
$trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At 7am
Register-ScheduledTask -TaskName 'Entra inactive users' -Action $action -Trigger $trigger -User 'CONTOSO\svc-reports' -Password '<password>'
Run the task as the account whose certificate store holds the certificate (a dedicated service account works well), and keep the private key on that machine only. The app has tenant-wide read access, so treat the certificate like an admin password and set an expiry date you will notice. The CSV is overwritten on every run; add the date to the file name in a small wrapper if you want history.
How it works
Connect-MgGraphwithUser.Read.AllandAuditLog.Read.All(or app-only with the certificate).- One paged query:
GET /users?$select=id,displayName,userPrincipalName,userType,accountEnabled,createdDateTime,assignedLicenses,onPremisesSyncEnabled,externalUserState,signInActivity&$top=500. Microsoft limits pages to 500 users when signInActivity is selected; the script follows@odata.nextLink. - Filtering is done locally. Microsoft does not allow signInActivity to be filtered together with other properties in the same query, so reading everything once and filtering in PowerShell is simpler and avoids that restriction.
- For each user the script picks the date according to
-Basis, compares it with the cut-off (now minus-Days, in UTC) and keeps the user if it is older or missing. - Results are sorted with never-signed-in accounts first, then by days inactive.
Limitations
- Up to 24 hours behind. Microsoft notes the last sign-in values can take up to a day to update. Do not use a 1-day window.
- History limits. Microsoft keeps interactive sign-ins back to April 2020 and non-interactive back to May 2020, so an empty value can also mean “not since then”.
- Guests sign in through their home tenant; for guest clean-up also look at Entra ID access reviews.
- Service and shared-mailbox accounts often never sign in on purpose. Check before you act on the NeverSignedIn rows.
- Before disabling anyone, export the CSV, confirm with the account owners, and disable first rather than delete; deleted users can be restored for 30 days.
- Not yet run against a live tenant (see the note at the top).
Official documentation: signInActivity resource type · How to manage inactive user accounts · List users (page size with signInActivity)
Related: Find Inactive AD Users and Computers: PowerShell Cleanup in 5 Steps · Employee Offboarding Checklist: Secure AD, M365 and Workspace Steps · Get-ADUser PowerShell Examples: 25 Queries for Active Directory · Microsoft 365 shared mailbox vs distribution group vs Microsoft 365 Group
See also: Microsoft 365 MFA Status Report: PowerShell Script for Graph · Windows LAPS with Intune and Entra ID: Setup and Retrieval · Intune Device Compliance Report: PowerShell Script via Graph · Microsoft 365 User Offboarding PowerShell Script (with -WhatIf)
The script
# Entra ID Inactive Users Report: signInActivity PowerShell Script (v1.0.0) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/entra-inactive-users-report/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
<#
.SYNOPSIS
Find inactive Microsoft Entra ID (Microsoft 365) users from signInActivity: members and guests,
licensed or not, with last successful, interactive and non-interactive sign-in dates. CSV/HTML output.
.DESCRIPTION
Read-only. Lists users through Microsoft Graph (GET /users with $select=signInActivity) using the
Microsoft.Graph.Authentication module, then works out how many days each account has been inactive.
Which date counts as "last activity" (-Basis):
Successful (default) lastSuccessfulSignInDateTime (interactive or non-interactive, successful only).
Microsoft started filling this property on 1 December 2023 and did not backfill it,
so when it is empty the script falls back to the newest of lastSignInDateTime and
lastNonInteractiveSignInDateTime and says so in the BasisUsed column.
AnyAttempt Newest of all three dates. lastSignInDateTime and lastNonInteractiveSignInDateTime
also record FAILED attempts, so a password-spray target can look "active".
Accounts with no sign-in data at all are reported as NeverSignedIn (or "last sign-in before April 2020").
Accounts created inside the -Days window are skipped unless -IncludeRecentlyCreated is used.
Requirements (Microsoft Learn): signInActivity needs a Microsoft Entra ID P1 or P2 licence in the tenant
and the AuditLog.Read.All permission (plus User.Read.All). Delegated: the signed-in admin needs at least
the Reports Reader role (Global Reader also works). App-only: User.Read.All and AuditLog.Read.All as
Application permissions with admin consent. The value can lag real activity by up to 24 hours.
.PARAMETER Days Inactive for at least this many days (default 90).
.PARAMETER UserType All (default), Member or Guest.
.PARAMETER LicensedOnly Only users with at least one assigned licence.
.PARAMETER IncludeDisabled Also report disabled accounts (default: enabled accounts only).
.PARAMETER IncludeRecentlyCreated Also report accounts created less than -Days ago.
.PARAMETER Basis Successful (default) or AnyAttempt. See DESCRIPTION.
.PARAMETER CsvPath Write the result to this CSV file.
.PARAMETER HtmlPath Write the result to this HTML file.
.PARAMETER PassThru Output the objects to the pipeline.
.PARAMETER TenantId Tenant ID or domain (required for app-only sign-in).
.PARAMETER ClientId App registration (client) ID for app-only sign-in.
.PARAMETER CertificateThumbprint Certificate thumbprint for app-only sign-in.
.EXAMPLE .\Get-EntraInactiveUsers.ps1 -Days 90 -CsvPath .\inactive-90.csv
.EXAMPLE .\Get-EntraInactiveUsers.ps1 -Days 60 -LicensedOnly -UserType Member -HtmlPath .\licensed-inactive.html
.EXAMPLE .\Get-EntraInactiveUsers.ps1 -Days 180 -UserType Guest -IncludeDisabled -CsvPath .\stale-guests.csv
.EXAMPLE .\Get-EntraInactiveUsers.ps1 -TenantId contoso.onmicrosoft.com -ClientId <app-id> -CertificateThumbprint <thumbprint> -CsvPath C:\Reports\inactive.csv
.NOTES
Name: Get-EntraInactiveUsers.ps1
Purpose: Inactive user report for Microsoft Entra ID based on signInActivity
Source: https://srvscripts.com/scripts/entra-inactive-users-report/
License: MIT
Version: 1.0.0
Requires: Windows PowerShell 5.1 or PowerShell 7, module Microsoft.Graph.Authentication.
#>
[CmdletBinding(DefaultParameterSetName = 'Interactive')]
param(
[ValidateRange(1, 3650)]
[int]$Days = 90,
[ValidateSet('All', 'Member', 'Guest')]
[string]$UserType = 'All',
[switch]$LicensedOnly,
[switch]$IncludeDisabled,
[switch]$IncludeRecentlyCreated,
[ValidateSet('Successful', 'AnyAttempt')]
[string]$Basis = 'Successful',
[string]$CsvPath,
[string]$HtmlPath,
[switch]$PassThru,
[Parameter(ParameterSetName = 'Interactive')]
[Parameter(ParameterSetName = 'AppOnly', Mandatory = $true)]
[string]$TenantId,
[Parameter(ParameterSetName = 'AppOnly', Mandatory = $true)]
[ValidatePattern('^[0-9a-fA-F-]{36}$')]
[string]$ClientId,
[Parameter(ParameterSetName = 'AppOnly', Mandatory = $true)]
[ValidatePattern('^[0-9a-fA-F]{40}$')]
[string]$CertificateThumbprint
)
Set-StrictMode -Version 2
$ErrorActionPreference = 'Stop'
$Graph = 'https://graph.microsoft.com/v1.0'
function Write-Status([string]$Message) { Write-Information $Message -InformationAction Continue }
function Invoke-GraphWithRetry {
param([string]$Uri)
$attempt = 0
while ($true) {
$attempt++
try {
return Invoke-MgGraphRequest -Method GET -Uri $Uri -OutputType HashTable -ErrorAction Stop
} catch {
if ($attempt -lt 5 -and $_.Exception.Message -match '429|TooManyRequests|503|ServiceUnavailable|504|GatewayTimeout') {
Start-Sleep -Seconds ([math]::Pow(2, $attempt) * 5)
continue
}
throw
}
}
}
function Get-GraphCollection([string]$Uri) {
$next = $Uri
while ($next) {
$r = Invoke-GraphWithRetry -Uri $next
if ($r['value']) { foreach ($i in $r['value']) { $i } }
$next = $r['@odata.nextLink']
}
}
function Get-Value($Hash, [string]$Key) {
if ($null -ne $Hash -and $Hash.ContainsKey($Key)) { return $Hash[$Key] }
return $null
}
function ConvertTo-UtcDate($Value) {
if ($null -eq $Value -or "$Value" -eq '') { return $null }
if ($Value -is [datetime]) { return $Value.ToUniversalTime() }
if ($Value -is [datetimeoffset]) { return $Value.UtcDateTime }
return ([datetimeoffset]::Parse([string]$Value, [Globalization.CultureInfo]::InvariantCulture)).UtcDateTime
}
function Get-Newest([object[]]$Dates) {
$d = @($Dates | Where-Object { $null -ne $_ } | Sort-Object -Descending)
if ($d.Count) { return $d[0] }
return $null
}
# ---- Connect --------------------------------------------------------------------------------------------
if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) {
throw 'Module Microsoft.Graph.Authentication is not installed. Run: Install-Module Microsoft.Graph.Authentication -Scope CurrentUser'
}
Import-Module Microsoft.Graph.Authentication
if ($PSCmdlet.ParameterSetName -eq 'AppOnly') {
Connect-MgGraph -TenantId $TenantId -ClientId $ClientId -CertificateThumbprint $CertificateThumbprint -NoWelcome
} else {
$cp = @{ Scopes = @('User.Read.All', 'AuditLog.Read.All'); NoWelcome = $true }
if ($TenantId) { $cp.TenantId = $TenantId }
Connect-MgGraph @cp
}
$ctx = Get-MgContext
if (-not $ctx) { throw 'Not connected to Microsoft Graph.' }
# ---- Read users -----------------------------------------------------------------------------------------
$select = 'id,displayName,userPrincipalName,userType,accountEnabled,createdDateTime,assignedLicenses,onPremisesSyncEnabled,externalUserState,signInActivity'
Write-Status 'Reading users and signInActivity (500 per page)...'
try {
$users = @(Get-GraphCollection -Uri "$Graph/users?`$select=$select&`$top=500")
} catch {
if ($_.Exception.Message -match 'Forbidden|403|premium|license|Authorization_RequestDenied') {
throw "Graph refused signInActivity: $($_.Exception.Message). It needs a Microsoft Entra ID P1/P2 licence in the tenant, the AuditLog.Read.All permission and (delegated) at least the Reports Reader role."
}
throw
}
Write-Status ("{0} user objects read." -f $users.Count)
$now = (Get-Date).ToUniversalTime()
$cutoff = $now.AddDays(-$Days)
$rows = [System.Collections.Generic.List[object]]::new()
foreach ($u in $users) {
$type = [string](Get-Value $u 'userType')
if ($UserType -ne 'All' -and $type -ne $UserType) { continue }
$enabled = Get-Value $u 'accountEnabled'
if (-not $IncludeDisabled -and $enabled -eq $false) { continue }
$licCount = @(@(Get-Value $u 'assignedLicenses') | Where-Object { $_ }).Count
if ($LicensedOnly -and $licCount -eq 0) { continue }
$created = ConvertTo-UtcDate (Get-Value $u 'createdDateTime')
if (-not $IncludeRecentlyCreated -and $created -and $created -gt $cutoff) { continue }
$sia = Get-Value $u 'signInActivity'
$succ = ConvertTo-UtcDate (Get-Value $sia 'lastSuccessfulSignInDateTime')
$inter = ConvertTo-UtcDate (Get-Value $sia 'lastSignInDateTime')
$nonInter = ConvertTo-UtcDate (Get-Value $sia 'lastNonInteractiveSignInDateTime')
if ($Basis -eq 'AnyAttempt') {
$last = Get-Newest @($succ, $inter, $nonInter); $used = 'AnyAttempt'
} elseif ($succ) {
$last = $succ; $used = 'LastSuccessful'
} else {
$last = Get-Newest @($inter, $nonInter); $used = if ($last) { 'Fallback: last attempt (no successful sign-in recorded)' } else { '' }
}
if ($last -and $last -gt $cutoff) { continue }
$daysInactive = if ($last) { [int][math]::Floor(($now - $last).TotalDays) } else { $null }
$rows.Add([pscustomobject][ordered]@{
DisplayName = Get-Value $u 'displayName'
UserPrincipalName = Get-Value $u 'userPrincipalName'
UserType = $type
AccountEnabled = $enabled
IsLicensed = ($licCount -gt 0)
LicenseCount = $licCount
CreatedDateTime = $created
LastSuccessfulSignIn = $succ
LastInteractiveSignIn = $inter
LastNonInteractiveSignIn = $nonInter
LastActivity = $last
DaysInactive = $daysInactive
Status = if ($last) { "Inactive $daysInactive days" } else { 'NeverSignedIn (or last sign-in before April 2020)' }
BasisUsed = $used
OnPremisesSynced = [bool](Get-Value $u 'onPremisesSyncEnabled')
ExternalUserState = Get-Value $u 'externalUserState'
Id = Get-Value $u 'id'
})
}
$out = @($rows | Sort-Object @{ e = { if ($null -eq $_.DaysInactive) { [int]::MaxValue } else { $_.DaysInactive } }; Descending = $true }, UserPrincipalName)
if ($CsvPath) {
$out | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8
Write-Status ("CSV written: {0}" -f $CsvPath)
}
if ($HtmlPath) {
$css = '<style>body{font-family:Segoe UI,Arial,sans-serif;font-size:13px}table{border-collapse:collapse}th,td{border:1px solid #ccc;padding:4px 6px;text-align:left}th{background:#eee}</style>'
$pre = "<h2>Inactive Entra ID users ($Days+ days)</h2><p>Tenant $($ctx.TenantId). Generated $(Get-Date -Format 'yyyy-MM-dd HH:mm'). Basis: $Basis. Users: $($out.Count).</p>"
$out | Select-Object -Property * -ExcludeProperty Id | ConvertTo-Html -Head $css -PreContent $pre | Out-File -FilePath $HtmlPath -Encoding UTF8
Write-Status "HTML written: $HtmlPath"
}
$never = @($out | Where-Object { $null -eq $_.LastActivity }).Count
$guests = @($out | Where-Object { $_.UserType -eq 'Guest' }).Count
$lic = @($out | Where-Object { $_.IsLicensed }).Count
Write-Status ("Inactive {0}+ days: {1} (guests {2}, licensed {3}, never signed in {4})." -f $Days, $out.Count, $guests, $lic, $never)
if ($PassThru) { return $out }
if (-not $CsvPath -and -not $HtmlPath) {
$out | Select-Object DisplayName, UserPrincipalName, UserType, IsLicensed, AccountEnabled, LastActivity, DaysInactive | Format-Table -AutoSize
}
94faf62699f2536e57e507f5ebcf124602155c47e4b956407223d6f5c052eb97curl -fsSL -o Get-EntraInactiveUsers.ps1 https://scr.srvscripts.com/entra-inactive-users-report/Get-EntraInactiveUsers.ps1 && curl -fsSL https://scr.srvscripts.com/entra-inactive-users-report/Get-EntraInactiveUsers.ps1.sha256 | sha256sum -cInvoke-WebRequest -Uri 'https://scr.srvscripts.com/entra-inactive-users-report/Get-EntraInactiveUsers.ps1' -OutFile 'Get-EntraInactiveUsers.ps1'; if ((Get-FileHash 'Get-EntraInactiveUsers.ps1' -Algorithm SHA256).Hash -eq '94FAF62699F2536E57E507F5EBCF124602155C47E4B956407223D6F5C052EB97') { 'OK: the file is intact' } else { 'MISMATCH: do not run this file' }Copy the whole line. In Windows PowerShell, curl and sha256sum are not the Linux tools, so use the PowerShell line there.Also on GitHub: github.com/srvscripts/scripts
Frequently asked questions
How do I find inactive users in Microsoft 365?
Read signInActivity for each user through Microsoft Graph and compare lastSuccessfulSignInDateTime with your cut-off date. This script does that for the whole tenant and exports a CSV.
Why is signInActivity empty or refused?
It needs a Microsoft Entra ID P1 or P2 licence in the tenant and the AuditLog.Read.All permission. An empty value on a single user means no recorded sign-in.
Which date should I use for inactive users?
lastSuccessfulSignInDateTime. The other two dates also record failed attempts, so an attacked account can look active.
Which admin role is needed?
Microsoft lists Reports Reader as the least privileged role that can read sign-in activity. Global Reader also works.
Does the script disable inactive users?
No. It only reports. Review the list, then disable accounts in Entra ID, or in on-premises AD for synced users.