Finding Inactive Microsoft 365 Users with PowerShell and Microsoft Graph API
If you're managing Microsoft 365 users, you've probably needed to identify inactive accounts for security audits, license optimization, or cleanup operations. In this post, I'll share a PowerShell script that uses the Microsoft Graph API to find users who haven't logged in for a specified period (default: 60 days).
This updated version includes enhanced functionality to handle environments where sign-in logs may be unavailable, using alternative data sources like password change dates to determine user activity.
⚠️ Important Warning ⚠️
Before running any script in your environment:
- Review the entire code to understand what it does and how it works
- Test in a non-production environment if possible
- Verify you have the necessary permissions to run the script
- This script is read-only and doesn't make any changes to your environment, but always exercise caution
Prerequisites
Before running this script, you'll need:
- PowerShell 5.1 or higher
- Microsoft Graph PowerShell modules installed
- Appropriate permissions in your Microsoft 365 tenant (Global Reader or Global Admin role recommended)
To install the required modules, run:
Install-Module Microsoft.Graph -Scope CurrentUser
The Script
Here's the complete script. Save it as Get-InactiveUsers.ps1
:
🔄 Updated Version
This is an improved version of the script that addresses a common issue where all users show "Never" for last login time. The script now includes:
- Alternative data sources when sign-in logs aren't available
- Automatic detection of sign-in log access issues
- Additional parameters for more flexibility
#Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Users, Microsoft.Graph.Identity.SignIns
<#
.SYNOPSIS
Identifies users who haven't logged in for the last 60 days and exports the results.
.DESCRIPTION
This script connects to Microsoft Graph API, retrieves all users, checks their sign-in logs,
and identifies users who haven't logged in for the specified number of days (default: 60).
Results are displayed in the console and exported to a CSV file on the desktop.
.PARAMETER InactiveDays
Number of days to consider a user inactive. Default is 60 days.
.PARAMETER ExcludeServiceAccounts
If specified, attempts to exclude common service account patterns.
.PARAMETER IncludeAllUsers
If specified, includes all users in the results regardless of their last activity date.
.PARAMETER UseBasicAuthentication
If specified, uses basic authentication method when connecting to Microsoft Graph.
.EXAMPLE
.Get-InactiveUsers.ps1
Finds all users who haven't logged in for 60 days and exports the results.
.EXAMPLE
.Get-InactiveUsers.ps1 -InactiveDays 30 -ExcludeServiceAccounts
Finds users inactive for 30 days, excluding accounts that match service account patterns.
.NOTES
Author: Rooted in DXB
Date: May 29, 2025
Requires: Microsoft Graph PowerShell modules
#>
[CmdletBinding()]
param(
[Parameter(Mandatory=$false)]
[int]$InactiveDays = 60,
[Parameter(Mandatory=$false)]
[switch]$ExcludeServiceAccounts,
[Parameter(Mandatory=$false)]
[switch]$IncludeAllUsers,
[Parameter(Mandatory=$false)]
[switch]$UseBasicAuthentication
)
function Connect-ToMicrosoftGraph {
[CmdletBinding()]
param(
[Parameter(Mandatory=$false)]
[switch]$UseBasicAuth
)
try {
# Check if already connected
$graphConnection = Get-MgContext
if (-not $graphConnection) {
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan
if ($UseBasicAuth) {
Write-Host "Using basic authentication method..." -ForegroundColor Yellow
# Use basic authentication method
Connect-MgGraph -Scopes "AuditLog.Read.All", "User.Read.All", "Directory.Read.All" -NoWelcome
}
else {
# Use modern authentication (default)
Connect-MgGraph -Scopes "AuditLog.Read.All", "User.Read.All", "Directory.Read.All" -NoWelcome
}
Write-Host "Connected to Microsoft Graph successfully!" -ForegroundColor Green
# Verify the connection
$context = Get-MgContext
Write-Host "Connected as: $($context.Account)" -ForegroundColor Green
Write-Host "Scopes: $($context.Scopes -join ', ')" -ForegroundColor Green
}
else {
# Check if we have the required scopes
$requiredScopes = @("AuditLog.Read.All", "User.Read.All", "Directory.Read.All")
$missingScopes = $requiredScopes | Where-Object { $_ -notin $graphConnection.Scopes }
if ($missingScopes) {
Write-Host "Reconnecting to Microsoft Graph with required scopes..." -ForegroundColor Cyan
Write-Host "Missing scopes: $($missingScopes -join ', ')" -ForegroundColor Yellow
Disconnect-MgGraph -ErrorAction SilentlyContinue
if ($UseBasicAuth) {
Connect-MgGraph -Scopes $requiredScopes -NoWelcome
}
else {
Connect-MgGraph -Scopes $requiredScopes -NoWelcome
}
Write-Host "Connected to Microsoft Graph successfully!" -ForegroundColor Green
}
else {
Write-Host "Already connected to Microsoft Graph with required scopes." -ForegroundColor Green
}
}
return $true
}
catch {
Write-Host "Error connecting to Microsoft Graph: $($_.Exception.Message)" -ForegroundColor Red
return $false
}
}
function Get-InactiveUsersReport {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)]
[int]$Days,
[Parameter(Mandatory=$false)]
[switch]$ExcludeServiceAccounts,
[Parameter(Mandatory=$false)]
[switch]$IncludeAllUsers,
[Parameter(Mandatory=$false)]
[switch]$UseAlternativeMethod
)
try {
# Set cutoff date
$cutoffDate = (Get-Date).AddDays(-$Days)
Write-Verbose "Cutoff date set to $cutoffDate"
Write-Host "Finding users who haven't logged in since $($cutoffDate.ToString('yyyy-MM-dd'))..." -ForegroundColor Cyan
# Build filter for service accounts if specified
$filter = ""
if ($ExcludeServiceAccounts) {
# Common patterns for service accounts
$serviceAccountPatterns = @(
"svc", "service", "admin", "noreply", "donotreply", "system",
"automation", "scanner", "scan", "backup", "sync", "feed", "bot",
"api", "alert", "monitor", "report", "no-reply", "do-not-reply"
)
$filterParts = foreach ($pattern in $serviceAccountPatterns) {
"(not contains(userPrincipalName, '$pattern'))"
}
$filter = $filterParts -join " and "
Write-Host "Excluding potential service accounts from results." -ForegroundColor Yellow
}
# Get all users
Write-Host "Retrieving user accounts..." -ForegroundColor Cyan
if ($filter) {
$users = Get-MgUser -All -Property "Id,DisplayName,UserPrincipalName,AccountEnabled,CreatedDateTime,JobTitle,Department" -Filter $filter
}
else {
$users = Get-MgUser -All -Property "Id,DisplayName,UserPrincipalName,AccountEnabled,CreatedDateTime,JobTitle,Department"
}
Write-Host "Found $($users.Count) user accounts. Checking sign-in activity..." -ForegroundColor Cyan
# Prepare output array
$inactiveUsers = @()
$userCount = $users.Count
$i = 0
foreach ($user in $users) {
$i++
Write-Progress -Activity "Checking user sign-ins" -Status "$i of $userCount - $($user.UserPrincipalName)" -PercentComplete (($i / $userCount) * 100)
try {
# First try the standard method with sign-in logs
$signIns = $null
$lastSignInDate = $null
$usedAlternativeMethod = $false
if (-not $UseAlternativeMethod) {
try {
Write-Verbose "Checking sign-ins for $($user.UserPrincipalName) (ID: $($user.Id))"
$signInFilter = "userId eq '$($user.Id)'"
$signIns = Get-MgAuditLogSignIn -Filter $signInFilter -Top 10 -ErrorAction Stop
# Debug information
Write-Verbose "Retrieved $($signIns.Count) sign-in records for $($user.UserPrincipalName)"
}
catch {
Write-Warning "Error retrieving sign-ins for $($user.UserPrincipalName): $($_.Exception.Message)"
$signIns = $null
}
}
# If sign-in logs method failed or returned no results, try alternative method
if ((-not $signIns -or $signIns.Count -eq 0) -or $UseAlternativeMethod) {
Write-Verbose "Using alternative method to check activity for $($user.UserPrincipalName)"
$usedAlternativeMethod = $true
try {
# Get last password change as a fallback activity indicator
$userDetails = Get-MgUser -UserId $user.Id -Property "lastPasswordChangeDateTime,createdDateTime" -ErrorAction Stop
if ($userDetails.LastPasswordChangeDateTime) {
$lastSignInDate = [DateTime]$userDetails.LastPasswordChangeDateTime
Write-Verbose "Using last password change date: $lastSignInDate"
}
elseif ($userDetails.CreatedDateTime) {
# If no password change, use account creation date
$lastSignInDate = [DateTime]$userDetails.CreatedDateTime
Write-Verbose "Using account creation date: $lastSignInDate"
}
}
catch {
Write-Warning "Error retrieving alternative activity data for $($user.UserPrincipalName): $($_.Exception.Message)"
}
}
# Process standard sign-in logs if available
if ($signIns -and $signIns.Count -gt 0) {
# Get latest sign-in
$lastSignIn = ($signIns | Sort-Object CreatedDateTime -Descending | Select-Object -First 1).CreatedDateTime
$lastSignInDate = [DateTime]$lastSignIn
$daysSinceLastSignIn = [math]::Round((New-TimeSpan -Start $lastSignInDate -End (Get-Date)).TotalDays)
if ($lastSignInDate -lt $cutoffDate -or $IncludeAllUsers) {
$inactiveUsers += [PSCustomObject]@{
DisplayName = $user.DisplayName
UserPrincipalName = $user.UserPrincipalName
Department = $user.Department
JobTitle = $user.JobTitle
LastSignIn = $lastSignInDate.ToString("yyyy-MM-dd HH:mm")
DaysSinceLastSignIn = $daysSinceLastSignIn
AccountEnabled = $user.AccountEnabled
AccountCreated = $user.CreatedDateTime.ToString("yyyy-MM-dd")
Method = "Sign-in Logs"
}
}
}
# Use alternative method data if available
elseif ($usedAlternativeMethod -and $lastSignInDate) {
$daysSinceLastSignIn = [math]::Round((New-TimeSpan -Start $lastSignInDate -End (Get-Date)).TotalDays)
if ($lastSignInDate -lt $cutoffDate -or $IncludeAllUsers) {
$inactiveUsers += [PSCustomObject]@{
DisplayName = $user.DisplayName
UserPrincipalName = $user.UserPrincipalName
Department = $user.Department
JobTitle = $user.JobTitle
LastSignIn = $lastSignInDate.ToString("yyyy-MM-dd HH:mm") + " *" # Mark as alternative method
DaysSinceLastSignIn = $daysSinceLastSignIn
AccountEnabled = $user.AccountEnabled
AccountCreated = $user.CreatedDateTime.ToString("yyyy-MM-dd")
Method = "Alternative (Password/Creation)"
}
}
}
else {
# No sign-ins found at all (never signed in)
$inactiveUsers += [PSCustomObject]@{
DisplayName = $user.DisplayName
UserPrincipalName = $user.UserPrincipalName
Department = $user.Department
JobTitle = $user.JobTitle
LastSignIn = "Never"
DaysSinceLastSignIn = "N/A"
AccountEnabled = $user.AccountEnabled
AccountCreated = $user.CreatedDateTime.ToString("yyyy-MM-dd")
Method = "No Data Available"
}
}
}
catch {
Write-Warning "Failed to check sign-ins for $($user.UserPrincipalName): $_"
}
}
Write-Progress -Activity "Checking user sign-ins" -Completed
return $inactiveUsers
}
catch {
Write-Host "Error generating inactive users report: $($_.Exception.Message)" -ForegroundColor Red
return @()
}
}
# Main script execution
Clear-Host
Write-Host "=== Inactive Users Report ===" -ForegroundColor Magenta
# Connect to Microsoft Graph
$connected = Connect-ToMicrosoftGraph -UseBasicAuth:$UseBasicAuthentication
if (-not $connected) {
Write-Host "Failed to connect to Microsoft Graph. Exiting script." -ForegroundColor Red
exit
}
# Try to get a test user to verify access to sign-in logs
Write-Host "Verifying access to sign-in logs..." -ForegroundColor Cyan
try {
$testSignIns = Get-MgAuditLogSignIn -Top 1 -ErrorAction Stop
if ($testSignIns -and $testSignIns.Count -gt 0) {
Write-Host "Successfully retrieved sign-in logs. Proceeding with report." -ForegroundColor Green
}
else {
Write-Host "Warning: Could not retrieve any sign-in logs. You may see 'Never' for all users." -ForegroundColor Yellow
Write-Host "This may be due to insufficient permissions or no sign-in data available." -ForegroundColor Yellow
}
}
catch {
Write-Host "Warning: Error accessing sign-in logs: $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host "You may need additional permissions or there might be an issue with the Microsoft Graph API." -ForegroundColor Yellow
}
# Get inactive users
$useAlternative = $false
if ($testSignIns -eq $null -or $testSignIns.Count -eq 0) {
Write-Host "Using alternative method for retrieving user activity data..." -ForegroundColor Yellow
$useAlternative = $true
}
$inactiveUsers = Get-InactiveUsersReport -Days $InactiveDays -ExcludeServiceAccounts:$ExcludeServiceAccounts -IncludeAllUsers:$IncludeAllUsers -UseAlternativeMethod:$useAlternative
# Display results
if ($inactiveUsers.Count -gt 0) {
Write-Host "
Found $($inactiveUsers.Count) users who haven't logged in for $($InactiveDays) days or more:" -ForegroundColor Yellow
$inactiveUsers | Format-Table -Property DisplayName, UserPrincipalName, LastSignIn, DaysSinceLastSignIn, AccountEnabled, Method -AutoSize
# Export to desktop
$desktopPath = [Environment]::GetFolderPath("Desktop")
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$csvPath = Join-Path -Path $desktopPath -ChildPath "InactiveUsers_$($InactiveDays)days_$timestamp.csv"
$inactiveUsers | Export-Csv -Path $csvPath -NoTypeInformation
Write-Host "
Results exported to: $csvPath" -ForegroundColor Green
Write-Host "
Note: Entries marked with * used alternative data sources (password change or account creation date)." -ForegroundColor Yellow
}
else {
Write-Host "
No inactive users found in the last $($InactiveDays) days." -ForegroundColor Green
}
# Disconnect from Microsoft Graph
try {
Disconnect-MgGraph -ErrorAction SilentlyContinue
Write-Host "
Disconnected from Microsoft Graph." -ForegroundColor Cyan
}
catch {
# Ignore any disconnection errors
}
Key Features
- Identifies users who haven't logged in for a specified number of days (default: 60)
- Exports results to a CSV file on your desktop for easy reporting
- Option to exclude service accounts from the results
- Detailed output including last sign-in date, days since last sign-in, and account status
- Uses Microsoft Graph API for accurate data retrieval
- NEW: Alternative data sources when sign-in logs are unavailable
- NEW: Option to use basic authentication method for environments with limited API access
- NEW: Improved error handling with detailed feedback on data sources used
How to Use the Script
Follow these steps to use the script:
- Save the script to your computer as
Get-InactiveUsers.ps1
- Open PowerShell as an administrator
- Navigate to the directory where you saved the script
- Run the script using one of the following commands:
# Basic usage (finds users inactive for 60 days) .\Get-InactiveUsers.ps1 # Custom inactivity period (e.g., 30 days) .\Get-InactiveUsers.ps1 -InactiveDays 30 # Exclude service accounts from the results .\Get-InactiveUsers.ps1 -ExcludeServiceAccounts # If you're getting 'Never' for all users, try the alternative method .\Get-InactiveUsers.ps1 -UseBasicAuthentication # Include all users regardless of last activity date .\Get-InactiveUsers.ps1 -IncludeAllUsers
What the Script Does
When you run the script, it will:
- Connect to Microsoft Graph API (you'll be prompted to authenticate)
- Retrieve all user accounts from your Microsoft 365 tenant
- Check each user's sign-in history to determine when they last logged in
- Identify users who haven't signed in for the specified number of days
- Display the results in the console
- Export the results to a CSV file on your desktop
Understanding the Results
The script provides the following information for each inactive user:
- DisplayName: The user's display name
- UserPrincipalName: The user's email address/UPN
- Department: The user's department (if set)
- JobTitle: The user's job title (if set)
- LastSignIn: The date and time of the user's last sign-in (or "Never" if they've never signed in)
- DaysSinceLastSignIn: The number of days since the user last signed in
- AccountEnabled: Whether the account is enabled or disabled
- AccountCreated: When the account was created
- Method: The data source used to determine the last activity (Sign-in Logs, Alternative, or No Data Available)
About Alternative Data Sources
If the script cannot access sign-in logs (which is common in some tenants), it will automatically fall back to alternative data sources:
- Last password change date: Used as the primary alternative indicator of user activity
- Account creation date: Used if no password change data is available
Entries using alternative data sources are marked with an asterisk (*) in the LastSignIn column.
Performance Considerations
For large tenants with many users, this script may take some time to run. The script includes a progress bar to show you the status as it processes each user.
If you have thousands of users, consider running the script during off-hours or modifying it to target specific user groups rather than the entire tenant.
Security Note
This script requires the following Microsoft Graph permissions:
- AuditLog.Read.All: To read sign-in logs
- User.Read.All: To read user information
You'll be prompted to consent to these permissions when you run the script. The script only reads data and does not make any changes to your environment.
Conclusion
Regularly identifying and managing inactive user accounts is an important part of maintaining security and optimizing license usage in Microsoft 365. This script provides a simple way to get this information using the Microsoft Graph API.
Remember to always review scripts before running them in your environment, and ensure you understand what they do and how they work.
Disclaimer: This script is provided as-is with no warranties. Always test scripts in a non-production environment before using them in production.