How to find inactive guest accounts in Entra ID
Guest accounts are the easiest thing in a tenant to accumulate and the hardest to clean up. Somebody invites an agency for one campaign, a consultant for one migration, an auditor for one week in March. The project ends, the invitation does not. Two years later the directory holds several hundred external identities and nobody can say which of them still needs access.
The mechanical part — listing guests and their last sign-in — takes about five minutes. The judgement part is where this goes wrong, so it is worth understanding what the data actually says before you start disabling anything.
What Entra ID actually tells you
Every user object in Microsoft Graph carries a signInActivity property with, among others, lastSignInDateTime and lastNonInteractiveSignInDateTime. Reading it needs the AuditLog.Read.All permission on top of User.Read.All — a detail that trips up most first attempts, because without it the property simply comes back empty rather than throwing a clear error.
Two caveats matter more than the syntax:
- Sign-in data depends on licensing. Full sign-in reporting is a paid feature — on a tenant without Entra ID P1 or P2 you may get nothing at all. An empty
signInActivityis therefore not evidence of an inactive account. It is evidence of no data. - Interactive is not the whole story. An account can look dormant on
lastSignInDateTimewhilelastNonInteractiveSignInDateTimeshows regular token refreshes from a client that never prompts anyone. Look at both.
Pulling the list
This reads the tenant and writes a CSV. It changes nothing:
Connect-MgGraph -Scopes 'User.Read.All','AuditLog.Read.All','Group.Read.All'
$guests = Get-MgUser -All -Filter "userType eq 'Guest'" -Property @(
'id','displayName','userPrincipalName','createdDateTime',
'accountEnabled','externalUserState','signInActivity'
)
$guests | Select-Object DisplayName, UserPrincipalName, AccountEnabled, ExternalUserState,
@{ n = 'LastSignIn'; e = { $_.SignInActivity.LastSignInDateTime } },
@{ n = 'LastNonInter'; e = { $_.SignInActivity.LastNonInteractiveSignInDateTime } },
@{ n = 'DaysInactive'; e = {
if ($_.SignInActivity.LastSignInDateTime) {
[int](New-TimeSpan -Start $_.SignInActivity.LastSignInDateTime -End (Get-Date)).TotalDays
}
} } |
Sort-Object DaysInactive -Descending |
Export-Csv .\guests.csv -NoTypeInformation -Encoding UTF8
If your tenant rejects that combination of -Filter and signInActivity, drop the filter and select on $_.UserType -eq 'Guest' in PowerShell instead. It is slower on a large directory but it always works.
Why "no sign-in for a year" is not a verdict
The number of days is an input to a decision, not the decision. Before treating a guest as disposable, three groups deserve a second look:
- Rare but deliberate. External auditors, board members, break-glass partner accounts — used once a year, on purpose, and painfully missed when they are gone at exactly the wrong moment.
- Pending invitations. An account with
externalUserStateofPendingAcceptancehas never signed in by definition. It is not inactive; it was never activated. That is a different problem with a different fix. - Guests holding roles. An external identity with an administrative role assignment should never be removed as part of a routine sweep. Find those first and handle them individually.
Group membership is the other signal worth pulling. A guest who belongs only to the default All Guest Users group is a very different proposition from one sitting in a group that grants access to a live application.
A sequence that survives contact with reality
The safe pattern is not scan-and-delete. It is:
- Report. Produce the list, share it with whoever owns the relationship — project leads know things the directory does not.
- Block. Disable the account rather than deleting it. Disabling is instantly reversible and it surfaces the accounts somebody actually needed: they complain within a week.
- Wait. Give it a grace period. Thirty days catches monthly processes; a quarter catches quarterly ones.
- Delete. Only then, and only for accounts that stayed quiet through the whole window.
One thing Entra ID will not do for you: it does not record when an account was disabled. The directory knows that an account is blocked, not since when. If your grace period is going to mean anything, you have to track that date yourself — in a file, in a spreadsheet, or in an extension attribute on the account.
Protect the exceptions systematically
Every tenant has accounts that must never be touched, and remembering them is not a strategy. Encode the exception instead: a dedicated exclusion group, or an extension attribute such as extensionAttribute15 = DoNotDelete set on the account. Then the rule becomes mechanical, survives the person who invented it, and works the same on the tenth cleanup as on the first.
Deletion is less final than it feels
Deleting a user in Entra ID is a soft delete. The account moves to a recycle bin and stays recoverable for 30 days, from the portal or with Restore-MgDirectoryDeletedItem. That is a genuine safety net — but it is a 30-day one, and it does not restore everything perfectly, so it should be the backstop rather than the plan.
Doing this repeatedly
The script above is fine for a one-off. The trouble starts on the second pass, when you need to remember which accounts you blocked last quarter, which ones are inside their grace period, which are exempt, and which came back to life. That state does not live in the directory, and a spreadsheet ages badly.
That is the gap Guest Account Cleanup fills: the same read-only scan, plus the block dates, the exclusion rules and the grace-period arithmetic kept in one place — with a dry run switched on by default, so nothing changes until you decide it should.
Cleaning up a directory you inherited?
Tell us roughly how many guests you are dealing with and we will walk you through it. A scan is read-only.
Get in Touch