Exporting Inactive Users in SharePoint: What You Need to Know

Exporting a list of inactive users is mostly a housekeeping task — unused accounts are a security and licensing cost, and this is how you actually find them instead of guessing.


In this post: Via PowerShell · Via the admin center (no scripting) · Third-party tools · LastSignInDateTime is lying to you, a little · Actually acting on the list · Best practices · When would you actually use this? · References · Related reading


Via PowerShell

Older versions of this script (and a lot of guides still floating around) use Connect-MsolService and Get-MsolUser — the MSOnline module those come from is retired, and it never exposed a real last-sign-in timestamp anyway (LastPasswordChangeTimestamp is not the same thing as “last logged in”). The current, correct approach uses the Microsoft Graph PowerShell SDK and the actual signInActivity property, which requires AuditLog.Read.All and User.Read.All permissions:

Connect-MgGraph -Scopes "AuditLog.Read.All", "User.Read.All"

$daysInactive = 90
$cutoffDate = (Get-Date).AddDays(-$daysInactive)

$inactiveUsers = Get-MgUser -All -Property DisplayName, UserPrincipalName, SignInActivity |
    Select-Object DisplayName, UserPrincipalName, @{N='LastSignIn'; E={$_.SignInActivity.LastSignInDateTime}} |
    Where-Object { $_.LastSignIn -eq $null -or $_.LastSignIn -lt $cutoffDate }

$inactiveUsers | Export-Csv -Path "InactiveUsers.csv" -NoTypeInformation
Write-Host "Export completed! Check InactiveUsers.csv"

One real gotcha worth knowing: filtering purely on “LastSignIn older than the cutoff” silently misses accounts that have never signed in at all — for those, SignInActivity comes back $null rather than a date, which is why the condition above explicitly checks for $null too. Skip that check and a stale account that was created but never used won’t show up in your export, even though it’s arguably the highest-priority one to find.


LastSignInDateTime is lying to you, a little

Worth knowing before trusting the script above as a definitive “who’s actually inactive” answer: Microsoft’s own documentation for signInActivity is explicit that lastSignInDateTime “records the last time a user attempted an interactive sign-in — whether the attempt was successful or not,” and warns that “this value might not accurately reflect actual system usage.” An account with repeated failed sign-in attempts (a wrong password, a locked-out account someone keeps trying anyway) shows up as recently active in that field even though nobody has actually gotten in. Separately, lastSignInDateTime only tracks interactive sign-ins — a user who never opens a browser but has Outlook or Teams silently refreshing tokens in the background for them looks fully inactive by this property alone, even though they’re a real, current user.

Microsoft’s fix for exactly this, effective December 2023, is the lastSuccessfulSignInDateTime property — the most recent successful sign-in, interactive or not, with failed attempts excluded. It’s the property Microsoft’s own docs now recommend when you actually need to know “was this account truly accessed”:

$inactiveUsers = Get-MgUser -All -Property DisplayName, UserPrincipalName, SignInActivity |
    Select-Object DisplayName, UserPrincipalName,
        @{N='LastSuccessfulSignIn'; E={$_.SignInActivity.LastSuccessfulSignInDateTime}} |
    Where-Object { $_.LastSuccessfulSignIn -eq $null -or $_.LastSuccessfulSignIn -lt $cutoffDate }

One caveat worth flagging honestly: this property’s data isn’t backfilled, so a tenant that only recently started tracking it may show gaps for older activity that predates when Microsoft turned it on. For a genuinely thorough audit, pull all three properties (LastSignInDateTime, LastNonInteractiveSignInDateTime, LastSuccessfulSignInDateTime) side by side rather than picking one — an account inactive across all three is a much stronger signal than any single property flagging it alone.

Via the admin center (no scripting)

Microsoft 365 Admin Center > Reports > Usage > Active Users Report, then filter by login activity. Good enough for a one-off check; the PowerShell route is better if you’re doing this regularly.

Third-party tools

Quest, ManageEngine, and AvePoint add automated tracking, real-time alerts, and compliance auditing on top of what the built-in options give you — worth it if you’re doing this at real scale.


Actually acting on the list

The best practices below say to decide the actual next step in advance, not just export and stop — worth showing what that next step looks like when the decision is “block sign-in.” This needs different, write-level permissions than the read-only export above (User.ReadWrite.All, not just User.Read.All), which is itself worth knowing before assuming the same connected session can do both:

Connect-MgGraph -Scopes "User.ReadWrite.All"

foreach ($user in $inactiveUsers) {
    Update-MgUser -UserId $user.UserPrincipalName -BodyParameter @{ accountEnabled = $false }
    Write-Host "Blocked sign-in for: $($user.UserPrincipalName)"
}

This blocks future sign-ins without deleting the account or its data — reversible with the same cmdlet and accountEnabled = $true, which is why it’s the safer first action compared to deleting an account outright. Worth a quick manual spot-check on a handful of accounts before running this against the full export, particularly for anything with an admin role attached, where disabling can behave differently or need additional privileges to complete.


Best practices
  • Define “inactive” before you export anything — 90, 180, or 365 days of no login is a real business/security decision, not a default to accept blindly. Pick the threshold that matches your actual risk tolerance.
  • Cross-verify before acting — a user might look inactive in one report but still be accessing SharePoint via mobile or an API integration. Check Microsoft 365 usage reports or audit logs before disabling anything.
  • Handle exported data carefully — it’s personal information; restrict access to the report and anonymize where you can.
  • Decide the actual next step in advance — disable, remove access, downgrade the license, or notify the user first. “Export the list” isn’t the finish line.

Define \”inactive\” before you export anything — 90, 180, or 365 days is a real business decision, not a default to accept blindly.

When would you actually use this?
  • Licensing costs need trimming and nobody’s sure which accounts are actually still in use — the PowerShell script above gives you a real answer, not a guess.
  • A security review is asking about dormant accounts as an attack surface — inactive accounts with valid credentials are exactly the kind of thing that gets flagged.
  • You need this as an ongoing, repeatable process rather than a one-time check — that’s when the third-party tooling starts to earn its cost.

References


Hope that’s useful for your setup. Let me know in the comments.


App Catalog Authentication Automation Backup Compliance Content Type CSS Flows Google Javascript Limitations List Metadata MFA Microsoft Node NodeJs O365 OneDrive Permissions PnP PnPJS Policy PowerApps Power Automate PowerAutomate PowerPlatform PowerShell React ReactJs Rest API Rest Endpoint Security Send an HTTP Request to SharePoint SharePoint SharePoint List SharePoint Modern SharePoint Online SPFX SPO Sync Tags Teams Termstore Versioning

Leave a Comment

Your email address will not be published. Required fields are marked *