๐ฅ Recipe ยท User & Group Management
Report Inactive Members and Guest Accounts (90+ Days) via Microsoft Graph
Identify stale member and guest accounts in Entra ID using signInActivity and export a consolidated CSV for review and cleanup
Complexity
Intermediate
Impact
security + compliance + cost-optimization + identity-hygiene
Context
Why This Matters
Stale user accounts โ both internal members who have stopped signing in and guest (B2B) accounts that were invited but never used โ are one of the most common sources of identity risk in Microsoft 365. Each dormant account is a potential attack surface: inherited licenses waste money, forgotten guests retain access to SharePoint sites and Teams, and inactive privileged accounts can be compromised without anyone noticing.
This recipe pulls every user from Entra ID, inspects the signInActivity property (interactive and non-interactive), and produces a CSV listing every member inactive for 90+ days and every guest account, regardless of last activity. The output is suitable for license reclamation reviews, quarterly access recertification, and compliance reporting (SOC 2 CC6.2, ISO 27001 A.9.2.5).
Run it on a recurring schedule (monthly or quarterly) as part of a broader joiner-mover-leaver hygiene program.
Expected Outcomes
- A CSV export (
Inactive_and_Guest_Accounts_Report.csv) listing every inactive member and every guest account in the tenant - Each row includes DisplayName, UserPrincipalName, UserType, AccountEnabled, LastSignIn, LastNonInteractiveSignIn, IsInactive90Days, and IsGuest
- A summary count of inactive members vs. inactive guests
- A reusable artifact for license reclamation, guest cleanup, and access recertification workflows
Risks & Considerations
Before you act on the report
- signInActivity requires Entra ID P1 or P2. Without a premium license the property is null for all users and the report will flag everyone as inactive.
- Service and break-glass accounts. Emergency-access accounts are intentionally rarely used โ exclude them before disabling anything.
- Non-interactive sign-ins matter. An account may show no interactive sign-in but still be used by a mail client, mobile device, or service principal. Always check
lastNonInteractiveSignInDateTimebefore deprovisioning. - Guests with recent SharePoint/Teams activity. A guest may appear inactive in sign-in logs but still hold access to shared resources. Cross-check before bulk removal.
- Data residency. The CSV contains PII (UPNs, display names). Store it in an approved location and delete when no longer needed.
- Do not bulk-disable from this report. Treat it as input to a review workflow โ notify account owners and allow a grace period before disabling or deleting.
Required Permissions
| Permission | Why It's Needed |
|---|---|
| User.Read.All | Read user profile properties including userType and accountEnabled |
| AuditLog.Read.All | Required to read the signInActivity property on user objects |
| Directory.Read.All | Broader directory read access used when enumerating all users across the tenant |
The fastest way to get this done: just ask Dex. Copy the prompt below and paste it into your Dex conversation.
For IT Admins
Paste into Dex Pro
Generate the report from the Microsoft Entra admin center
The Entra portal exposes inactive-user insights without scripting, but the view is split across two places.
Step 1 โ Review inactive users
- Sign in to the Microsoft Entra admin center as a Global Reader, User Administrator, or Global Administrator.
- Navigate to .
- Set the inactivity threshold to 90 days.
- Review the list of users whose last sign-in date exceeds the threshold.
- Click Download to export the results as CSV.
Step 2 โ Export guest accounts
- Navigate to .
- Click Add filter and choose User type โ Guest.
- Click Download users and select the columns you need (at minimum: displayName, userPrincipalName, userType, accountEnabled, externalUserState).
- Save the CSV alongside the inactive-users export.
Step 3 โ Consolidate
Open both CSVs in Excel and use a VLOOKUP or Power Query merge on userPrincipalName to produce a single file flagged by IsGuest and IsInactive90Days. The scripted approaches below do this in one pass.
Query Microsoft Graph directly
The /users endpoint exposes signInActivity when you request it in $select. This property requires Entra ID P1/P2 and the AuditLog.Read.All permission.
Request
GET https://graph.microsoft.com/v1.0/users?$select=id,displayName,userPrincipalName,userType,externalUserState,signInActivity,accountEnabled&$top=999
Authorization: Bearer <token>
ConsistencyLevel: eventualResponse (truncated)
{
"value": [
{
"id": "a1b2c3d4-...",
"displayName": "Jane Doe",
"userPrincipalName": "jane@contoso.com",
"userType": "Member",
"accountEnabled": true,
"signInActivity": {
"lastSignInDateTime": "2024-03-15T10:22:31Z",
"lastNonInteractiveSignInDateTime": "2024-04-02T08:11:09Z"
}
}
],
"@odata.nextLink": "https://graph.microsoft.com/v1.0/users?$skiptoken=..."
}Server-side filtering (advanced queries)
You can push the 90-day filter to Graph using advanced query parameters. This requires ConsistencyLevel: eventual and $count=true:
GET https://graph.microsoft.com/v1.0/users?$filter=signInActivity/lastSignInDateTime le 2024-08-27T00:00:00Z&$select=displayName,userPrincipalName,userType,signInActivity&$count=true
ConsistencyLevel: eventualGuest-only query
GET https://graph.microsoft.com/v1.0/users?$filter=userType eq 'Guest'&$select=displayName,userPrincipalName,externalUserState,signInActivity&$count=true
ConsistencyLevel: eventualPagination
Always follow @odata.nextLink until it is absent. Tenants with more than 999 users will paginate.
Run the report with Microsoft Graph PowerShell
The Microsoft.Graph module wraps the Graph endpoints above and handles pagination automatically with -All.
Install and connect
Install-Module Microsoft.Graph -Scope CurrentUser
Connect-MgGraph -Scopes "User.Read.All","AuditLog.Read.All","Directory.Read.All"Fetch all users with sign-in activity
# signInActivity is a beta-era property now in v1.0; always include it explicitly
$users = Get-MgUser -All -Property Id,DisplayName,UserPrincipalName,UserType,AccountEnabled,SignInActivity,ExternalUserState
$cutoff = (Get-Date).AddDays(-90)Classify and export
$report = $users | ForEach-Object {
$last = $_.SignInActivity.LastSignInDateTime
$isInactive = (-not $last) -or ($last -lt $cutoff)
$isGuest = $_.UserType -eq 'Guest'
if ($isInactive -or $isGuest) {
[pscustomobject]@{
DisplayName = $_.DisplayName
UserPrincipalName = $_.UserPrincipalName
UserType = $_.UserType
AccountEnabled = $_.AccountEnabled
LastSignIn = if ($last) { $last } else { 'Never' }
LastNonInteractive= $_.SignInActivity.LastNonInteractiveSignInDateTime
IsInactive90Days = $isInactive
IsGuest = $isGuest
}
}
}
$report | Export-Csv -Path ".\Inactive_and_Guest_Accounts_Report.csv" -NoTypeInformation
Write-Host "Exported $($report.Count) accounts."The downloadable script below parameterises the inactivity threshold, supports separating member and guest reports, and includes error handling.
Next steps