Back to Blog

Finding Inactive Microsoft 365 Users with PowerShell and Microsoft Graph API

May 29, 2025|10 min read

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:

  1. Review the entire code to understand what it does and how it works
  2. Test in a non-production environment if possible
  3. Verify you have the necessary permissions to run the script
  4. 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:

  1. Save the script to your computer as Get-InactiveUsers.ps1
  2. Open PowerShell as an administrator
  3. Navigate to the directory where you saved the script
  4. 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:

  1. Connect to Microsoft Graph API (you'll be prompted to authenticate)
  2. Retrieve all user accounts from your Microsoft 365 tenant
  3. Check each user's sign-in history to determine when they last logged in
  4. Identify users who haven't signed in for the specified number of days
  5. Display the results in the console
  6. 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.