To find Conditional Access policies with deleted objects, collect the user and group IDs each policy includes or excludes, look them up in the directory, and keep the policies that name an ID the directory no longer returns. A policy names users and groups by ID, so the ID stays in the policy after the object is deleted. A user or Microsoft 365 group deleted in the last 30 days can be restored with the same ID, and so, in preview, can a cloud security group.
| Method | Best fit |
|---|---|
| PowerShell | A CSV of each policy setting that names a user or group no longer in the directory. |
| Admin centre | Checking 1 policy's users and groups by eye. |
| Altivane | Recommended for recurring reviews, covering roles, apps and locations too, with the count rated. |
Before you start
Use an account in one of these roles: Security Reader, Global Reader, Security Administrator or Conditional Access Administrator. Conditional Access needs Microsoft Entra ID P1. The PowerShell example requests Policy. and Directory.; your organisation may require administrator consent.
Know what the script checks. It looks up the users and groups each policy includes or excludes. All, None and GuestsOrExternalUsers are keywords, not IDs, so it leaves them out. The examples read and export; they don't edit any policy.
Option 1
Find policies with deleted objects with PowerShell
Install the modules once, then connect, gather the IDs the policies name and look them up in batches of up to 1,000.
# Install once, if the modules are not already installed.
$modules = 'Microsoft.Graph.Authentication', 'Microsoft.Graph.Identity.SignIns',
'Microsoft.Graph.DirectoryObjects'
Install-Module $modules -Scope CurrentUser$ErrorActionPreference = 'Stop'
Connect-MgGraph -Scopes 'Policy.Read.All', 'Directory.Read.All' -NoWelcome
try {
$keywords = 'All', 'None', 'GuestsOrExternalUsers'
$references = @(foreach ($policy in Get-MgIdentityConditionalAccessPolicy -All) {
$users = $policy.Conditions.Users
$lists = [ordered]@{
'Included user' = $users.IncludeUsers
'Excluded user' = $users.ExcludeUsers
'Included group' = $users.IncludeGroups
'Excluded group' = $users.ExcludeGroups
}
foreach ($setting in $lists.Keys) {
foreach ($id in @($lists[$setting]) | Where-Object { $_ -and $_ -notin $keywords }) {
[pscustomobject]@{
Policy = $policy.DisplayName
State = $policy.State
Setting = $setting
Id = $id
}
}
}
})
$ids = @($references | ForEach-Object Id | Sort-Object -Unique)
$found = @{}
for ($i = 0; $i -lt $ids.Count; $i += 1000) {
$batch = $ids[$i..([Math]::Min($i + 999, $ids.Count - 1))]
Get-MgDirectoryObjectById -Ids $batch -Types 'user', 'group' |
ForEach-Object { $found[$_.Id] = $true }
}
$report = @($references | Where-Object { -not $found[$_.Id] } | Sort-Object Policy, Setting)
if ($report.Count -eq 0) {
Write-Output 'Every user and group the policies name is still in the directory.'
} else {
$report | Export-Csv -Path './ca-deleted-objects.csv' -NoTypeInformation -Encoding utf8
$count = @($report | ForEach-Object Policy | Sort-Object -Unique).Count
Write-Output "$($report.Count) references in $count policies name a deleted user or group."
}
} finally {
Disconnect-MgGraph | Out-Null
}Each row is 1 setting of 1 policy, such as Excluded group, with the ID the directory didn't return. An excluded ID matters most: the accounts it was meant to leave out may now be covered. State shows whether the policy is enabled, report-only or disabled. The script checks users and groups only; named locations need their own comparison.
Option 2
Check a policy's users and groups in the admin centre
- Sign in to the Microsoft Entra admin centre for the correct tenant.
- Go to Entra ID > Conditional Access > Policies and open a policy.
- Under Assignments, select Users and note the users and groups on the Include and Exclude tabs. Leave the policy without saving.
- Look for each one in Entra ID > Users or Entra ID > Groups. One that is missing may be in Deleted users or Deleted groups, where it can be restored for 30 days.
This suits checking 1 policy you are about to change. A deleted object is hard to spot by eye across many policies, so for every policy at once, use PowerShell or a report.
Option 3 ยท Recommended for recurring reviews
Report on policies with deleted objects with Altivane
- Open Altivane and choose the tenant.
- Build the tenant's documents with the Conditional Access scorecard report in full, so the policies are listed as well as counted.
- Open the tenant's Excel workbook and find the Policies Referencing Deleted sheet: each policy with its state and every setting that names an object Altivane can't find, labelled not found, possibly deleted.
- Read Conditional Access Summary for the count, and Policy Details for every setting of the policy.
Altivane checks the users, groups, roles, applications, workload identities and locations each policy names, not only users and groups. Where the directory lookup fails, the count says Not read and the table says it was not collected, rather than showing 0. The scorecard marks the table Warning from 1 policy and Critical from 3 until you change the thresholds. The build also writes the tenant's Word report.
Contact usCommon questions
Can I fix it by restoring the account or group?
Within 30 days, for users and Microsoft 365 groups, and in preview for cloud security groups. They are soft deleted and keep their object ID, so the policy finds them again once restored. App registrations, service principals and named locations can be restored for 30 days too. Once an object is deleted for good, edit the policy to remove the reference.
How do I find out who deleted it?
The audit log records a Delete user or Delete group event for each deletion, with who made it. To keep that record past the log's 7 or 30 days, see how to export the audit log.
Does the script check named locations?
No. It keeps to users and groups. To compare policies with the named locations, see how to find unused named locations, which reads the location IDs the policies use.
Microsoft references
Technical references checked on 7 November 2026. The example scripts have been reviewed and syntax checked; they have not been run against a live tenant.