Public/Get-InactiveUsersWithoutMFA.ps1
|
<# .SYNOPSIS Finds inactive user accounts without MFA capability. .DESCRIPTION Identifies user accounts that have not signed in recently and lack MFA registration. These accounts represent a security risk as they may be forgotten, unmonitored, and lack basic security protections. .PARAMETER DaysInactive Number of days without sign-ins to consider a user "inactive". Default is 90. .PARAMETER IncludeGuests Include guest users in the audit. Default is $false. .PARAMETER ExportPath Optional path to export results to CSV. .EXAMPLE Get-InactiveUsersWithoutMFA Returns all inactive users (90+ days) without MFA. .EXAMPLE Get-InactiveUsersWithoutMFA -DaysInactive 180 Finds users inactive for 180+ days without MFA. .EXAMPLE Get-InactiveUsersWithoutMFA -IncludeGuests $true Includes guest users in the audit. .NOTES Author: Kent Agent (kentagent-ai) Created: 2026-03-11 Requires: Microsoft.Graph PowerShell module Permissions: User.Read.All, UserAuthenticationMethod.Read.All, AuditLog.Read.All .LINK https://github.com/kentagent-ai/EntraIDSecurityScripts #> function Get-InactiveUsersWithoutMFA { [CmdletBinding()] param( [Parameter(Mandatory = $false)] [ValidateRange(1, 365)] [int]$DaysInactive = 90, [Parameter(Mandatory = $false)] [bool]$IncludeGuests = $false, [Parameter(Mandatory = $false)] [string]$ExportPath ) begin { # Verify Graph connection $context = Get-MgContext if (-not $context) { throw "Not connected to Microsoft Graph. Run: Connect-MgGraph -Scopes 'User.Read.All', 'UserAuthenticationMethod.Read.All', 'AuditLog.Read.All'" } $inactiveThreshold = (Get-Date).AddDays(-$DaysInactive) $results = [System.Collections.Generic.List[PSCustomObject]]::new() } process { Write-Verbose "Retrieving users with sign-in activity..." try { # Get users with sign-in activity $users = Get-MgUser -All -Property Id,DisplayName,UserPrincipalName,AccountEnabled,UserType,CreatedDateTime,SignInActivity -ErrorAction Stop } catch { throw "Failed to retrieve users: $_" } Write-Verbose "Processing $($users.Count) users..." foreach ($user in $users) { # Filter disabled accounts if (-not $user.AccountEnabled) { continue } # Filter guests if requested if (-not $IncludeGuests -and $user.UserType -eq 'Guest') { continue } # Check last sign-in $lastSignIn = $null $isInactive = $false if ($user.SignInActivity) { $lastSignIn = $user.SignInActivity.LastSignInDateTime if ($lastSignIn) { if ($lastSignIn -lt $inactiveThreshold) { $isInactive = $true } } else { # Never signed in $isInactive = $true } } else { # No sign-in data $isInactive = $true } if (-not $isInactive) { continue # User is active } # Check MFA registration $hasMFA = $false $mfaMethods = @() try { $authMethods = Get-MgUserAuthenticationMethod -UserId $user.Id -ErrorAction Stop foreach ($method in $authMethods) { $methodType = $method.AdditionalProperties['@odata.type'] # Count any MFA method except password and email if ($methodType -notin @('#microsoft.graph.passwordAuthenticationMethod', '#microsoft.graph.emailAuthenticationMethod')) { $hasMFA = $true $methodName = switch ($methodType) { '#microsoft.graph.fido2AuthenticationMethod' { 'FIDO2' } '#microsoft.graph.windowsHelloForBusinessAuthenticationMethod' { 'Windows Hello' } '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod' { 'Authenticator App' } '#microsoft.graph.phoneAuthenticationMethod' { 'Phone (SMS/Voice)' } '#microsoft.graph.softwareOathAuthenticationMethod' { 'OATH Token' } default { $methodType } } $mfaMethods += $methodName } } } catch { Write-Verbose "Could not retrieve auth methods for $($user.UserPrincipalName): $_" } if ($hasMFA) { continue # User has MFA } # Calculate days inactive $daysInactiveCal = if ($lastSignIn) { [math]::Round(((Get-Date) - $lastSignIn).TotalDays) } else { $null } # Determine risk level $riskLevel = if ($daysInactiveCal -gt 180) { 'HIGH' } elseif ($daysInactiveCal -gt 90 -or $null -eq $daysInactiveCal) { 'MEDIUM' } else { 'LOW' } $recommendation = if ($null -eq $lastSignIn) { 'Never signed in - Consider disabling or deleting' } elseif ($daysInactiveCal -gt 180) { 'Inactive >180 days without MFA - Disable and review' } else { 'Inactive without MFA - Require MFA or disable' } $results.Add([PSCustomObject]@{ DisplayName = $user.DisplayName UserPrincipalName = $user.UserPrincipalName UserId = $user.Id UserType = $user.UserType AccountEnabled = $user.AccountEnabled CreatedDateTime = $user.CreatedDateTime LastSignInDateTime = $lastSignIn DaysInactive = $daysInactiveCal HasMFA = $hasMFA RiskLevel = $riskLevel Recommendation = $recommendation }) } } end { # Sort by risk and days inactive $results = $results | Sort-Object @{Expression={ switch ($_.RiskLevel) { 'HIGH' { 1 } 'MEDIUM' { 2 } 'LOW' { 3 } } }}, DaysInactive -Descending # Summary $high = ($results | Where-Object { $_.RiskLevel -eq 'HIGH' }).Count $neverSignedIn = ($results | Where-Object { $null -eq $_.LastSignInDateTime }).Count Write-Host "`n=== Inactive Users Without MFA ===" -ForegroundColor Yellow Write-Host "Total inactive users without MFA: $($results.Count)" -ForegroundColor White Write-Host "HIGH risk (>180 days): $high" -ForegroundColor $(if ($high -gt 0) { 'Red' } else { 'Green' }) Write-Host "Never signed in: $neverSignedIn" -ForegroundColor $(if ($neverSignedIn -gt 0) { 'Yellow' } else { 'Green' }) Write-Host "==================================`n" -ForegroundColor Yellow # Export if requested if ($ExportPath) { try { $results | Export-Csv -Path $ExportPath -NoTypeInformation -Encoding UTF8 Write-Host "Results exported to: $ExportPath" -ForegroundColor Green } catch { Write-Error "Failed to export results: $_" } } return $results } } Export-ModuleMember -Function Get-InactiveUsersWithoutMFA -ErrorAction SilentlyContinue |