The ten Microsoft Graph PowerShell scripts every Entra ID administrator should keep within arm's reach — for security reviews, cleanup, and the questions leadership always asks.
Every one of these scripts answers a question we get asked in real tenants: Who still doesn't have MFA? Which accounts are stale? Who is a Global Admin? Which app secrets are about to expire and break something? All of them use the modern Microsoft Graph PowerShell SDK — the old MSOnline and AzureAD modules are deprecated and should not be used for new work.
All scripts below are read-only reports except #9 and #10, which make changes. As always: test in a test environment before using in production, and connect with the least-privileged scopes each task needs.
Before You Start: Install and Connect
Install the Graph SDK once, then connect with only the scopes required for the script you're running. Delegated (interactive) sign-in is fine for ad-hoc admin work; for unattended automation, use certificate-based app-only authentication.
# One-time install (CurrentUser scope avoids needing local admin)
Install-Module Microsoft.Graph -Scope CurrentUser
# Connect with the scopes used across this article
Connect-MgGraph -Scopes "User.Read.All","AuditLog.Read.All",
"UserAuthenticationMethod.Read.All","Policy.Read.All",
"Application.Read.All","RoleManagement.Read.Directory",
"IdentityRiskyUser.Read.All","Group.Read.All"
# Confirm who you are and what you're allowed to do
Get-MgContext | Select-Object Account, TenantId, Scopes
What each script needs
| # | Script | Key Graph scope |
| 1 | MFA registration report | UserAuthenticationMethod.Read.All |
| 2 | Inactive accounts (90+ days) | AuditLog.Read.All + User.Read.All |
| 3 | Backup Conditional Access policies | Policy.Read.All |
| 4 | Privileged role audit | RoleManagement.Read.Directory |
| 5 | Expiring app secrets & certificates | Application.Read.All |
| 6 | Stale guest accounts | User.Read.All + AuditLog.Read.All |
| 7 | Risky users & risky sign-ins | IdentityRiskyUser.Read.All |
| 8 | License assignment report | User.Read.All + Organization.Read.All |
| 9 | Bulk-disable accounts from CSV | User.ReadWrite.All |
| 10 | Group membership snapshot & export | Group.Read.All |
1. Find Users Without MFA Registered
The single highest-value report in this list. Unregistered users are the accounts a password-spray attack will actually compromise.
# Users who have NOT registered any MFA method
Get-MgReportAuthenticationMethodUserRegistrationDetail -All |
Where-Object { -not $_.IsMfaRegistered } |
Select-Object UserPrincipalName, UserDisplayName, IsAdmin,
@{n='DefaultMethod';e={$_.DefaultMfaMethod}} |
Sort-Object IsAdmin -Descending |
Export-Csv .\Users-Without-MFA.csv -NoTypeInformation
Write-Host "Report saved to Users-Without-MFA.csv"
Pay special attention to any row where IsAdmin is True — an unregistered admin account is a critical finding, not a to-do item.
2. Find Inactive Accounts (No Sign-In for 90+ Days)
Dormant accounts are free attack surface: nobody notices when they get compromised. Review, disable, then remove.
$cutoff = (Get-Date).AddDays(-90)
Get-MgUser -All -Property DisplayName,UserPrincipalName,AccountEnabled,SignInActivity,UserType |
Where-Object {
$_.AccountEnabled -and
($null -eq $_.SignInActivity.LastSignInDateTime -or
$_.SignInActivity.LastSignInDateTime -lt $cutoff)
} |
Select-Object DisplayName, UserPrincipalName, UserType,
@{n='LastSignIn';e={$_.SignInActivity.LastSignInDateTime}} |
Sort-Object LastSignIn |
Export-Csv .\Inactive-Users-90d.csv -NoTypeInformation
Note: SignInActivity requires an Entra ID P1 (or higher) license in the tenant, and a LastSignInDateTime of null usually means the account has never signed in at all — those deserve the closest look.
3. Back Up All Conditional Access Policies to JSON
Before anyone edits a Conditional Access policy, you want a point-in-time backup you can diff or restore from. This exports every policy to its own JSON file.
$backupDir = "C:\CA-Backup\$(Get-Date -Format 'yyyy-MM-dd')"
New-Item -ItemType Directory -Path $backupDir -Force | Out-Null
Get-MgIdentityConditionalAccessPolicy -All | ForEach-Object {
$name = ($_.DisplayName -replace '[\\/:*?"|]', '_')
$_ | ConvertTo-Json -Depth 10 |
Out-File -FilePath (Join-Path $backupDir "$name.json") -Encoding utf8
Write-Host "Backed up: $($_.DisplayName) [$($_.State)]"
}
Run this on a schedule and keep the folder in version control — it doubles as change history for your Conditional Access estate.
4. Audit Privileged Role Assignments
Answer "who is a Global Admin?" with evidence, not memory. This lists every directory role assignment with the member behind it.
$assignments = Get-MgRoleManagementDirectoryRoleAssignment -All -ExpandProperty Principal
$assignments | ForEach-Object {
$role = Get-MgRoleManagementDirectoryRoleDefinition -UnifiedRoleDefinitionId $_.RoleDefinitionId
[pscustomobject]@{
Role = $role.DisplayName
Principal = $_.Principal.AdditionalProperties['displayName']
UPN = $_.Principal.AdditionalProperties['userPrincipalName']
Type = ($_.Principal.AdditionalProperties['@odata.type'] -replace '#microsoft.graph.','')
}
} | Sort-Object Role | Export-Csv .\Privileged-Role-Audit.csv -NoTypeInformation
Healthy tenants keep two to four Global Administrators (including a break-glass account) and grant everything else through lesser roles — ideally as eligible assignments in Privileged Identity Management rather than standing access.
5. Find Expiring App Secrets and Certificates
An expired client secret is the classic 2 a.m. outage: integrations silently stop authenticating. This flags anything expiring in the next 60 days — and anything already expired.
$deadline = (Get-Date).AddDays(60)
Get-MgApplication -All | ForEach-Object {
$app = $_
($app.PasswordCredentials + $app.KeyCredentials) | ForEach-Object {
if ($_.EndDateTime -lt $deadline) {
[pscustomobject]@{
App = $app.DisplayName
AppId = $app.AppId
Credential = if ($_.Hint) { "Secret ($($_.Hint)...)" } else { "Certificate" }
Expires = $_.EndDateTime
Status = if ($_.EndDateTime -lt (Get-Date)) { "EXPIRED" } else { "Expiring soon" }
}
}
}
} | Sort-Object Expires | Format-Table -AutoSize
Better yet: replace secrets with certificates entirely — we cover the full process in Replace Client Secrets with Certificate Authentication and how to rotate them without downtime.
6. Find Stale Guest Accounts
Guests accumulate forever unless someone looks. Old vendor and contractor accounts still have whatever access they were ever granted.
$cutoff = (Get-Date).AddDays(-90)
Get-MgUser -All -Filter "userType eq 'Guest'" `
-Property DisplayName,Mail,AccountEnabled,CreatedDateTime,SignInActivity |
Where-Object {
$null -eq $_.SignInActivity.LastSignInDateTime -or
$_.SignInActivity.LastSignInDateTime -lt $cutoff
} |
Select-Object DisplayName, Mail, AccountEnabled, CreatedDateTime,
@{n='LastSignIn';e={$_.SignInActivity.LastSignInDateTime}} |
Sort-Object LastSignIn |
Export-Csv .\Stale-Guests.csv -NoTypeInformation
For ongoing hygiene, pair this report with Entra access reviews so guest access expires unless someone re-approves it.
7. Review Risky Users and Risky Sign-Ins
If you have Entra ID P2, Identity Protection is already scoring your users — this pulls anyone it currently considers at risk, plus the last week of risky sign-ins.
# Users currently flagged at risk
Get-MgRiskyUser -Filter "riskState eq 'atRisk'" |
Select-Object UserPrincipalName, RiskLevel, RiskState, RiskLastUpdatedDateTime |
Sort-Object RiskLevel
# Risky sign-ins from the last 7 days
$since = (Get-Date).AddDays(-7).ToString("yyyy-MM-ddTHH:mm:ssZ")
Get-MgRiskDetection -Filter "detectedDateTime ge $since" |
Select-Object UserPrincipalName, RiskEventType, RiskLevel,
DetectedDateTime, IPAddress |
Sort-Object DetectedDateTime -Descending | Format-Table -AutoSize
Any high-risk user warrants an immediate password reset, session revocation, and a check of their mailbox rules and registered MFA methods.
8. License Assignment Report
Know what you're paying for. This maps every user to their assigned SKUs and shows totals so you can spot unused licenses.
# Tenant-wide license totals
Get-MgSubscribedSku |
Select-Object SkuPartNumber,
@{n='Purchased';e={$_.PrepaidUnits.Enabled}},
@{n='Assigned';e={$_.ConsumedUnits}},
@{n='Unused';e={$_.PrepaidUnits.Enabled - $_.ConsumedUnits}} |
Format-Table -AutoSize
# Per-user assignments
$skuMap = @{}
Get-MgSubscribedSku | ForEach-Object { $skuMap[$_.SkuId] = $_.SkuPartNumber }
Get-MgUser -All -Property DisplayName,UserPrincipalName,AssignedLicenses |
Where-Object { $_.AssignedLicenses.Count -gt 0 } |
Select-Object DisplayName, UserPrincipalName,
@{n='Licenses';e={($_.AssignedLicenses | ForEach-Object { $skuMap[$_.SkuId] }) -join '; '}} |
Export-Csv .\License-Report.csv -NoTypeInformation
9. Bulk-Disable Accounts from a CSV (Offboarding)
The first script in this list that makes changes. Feed it a CSV with a UserPrincipalName column (for example, the inactive-accounts report from #2 after review) and it disables each account and revokes active sessions.
# Requires: Connect-MgGraph -Scopes "User.ReadWrite.All"
$users = Import-Csv .\To-Disable.csv
foreach ($u in $users) {
try {
Update-MgUser -UserId $u.UserPrincipalName -AccountEnabled:$false
Revoke-MgUserSignInSession -UserId $u.UserPrincipalName | Out-Null
Write-Host "Disabled + sessions revoked: $($u.UserPrincipalName)"
}
catch {
Write-Warning "FAILED: $($u.UserPrincipalName) - $($_.Exception.Message)"
}
}
Review the CSV before running. Disabling is reversible; the session revocation will force every device the user has to reauthenticate. Never point this at an unreviewed export.
10. Group Membership Snapshot and Export
Perfect for audits and change tickets: a point-in-time export of who is in which group, including whether membership is assigned or dynamic.
$report = foreach ($g in Get-MgGroup -All -Property Id,DisplayName,GroupTypes) {
foreach ($m in Get-MgGroupMember -GroupId $g.Id -All) {
[pscustomobject]@{
Group = $g.DisplayName
Membership = if ($g.GroupTypes -contains 'DynamicMembership') { 'Dynamic' } else { 'Assigned' }
Member = $m.AdditionalProperties['displayName']
UPN = $m.AdditionalProperties['userPrincipalName']
}
}
}
$report | Export-Csv .\Group-Membership-$(Get-Date -Format 'yyyy-MM-dd').csv -NoTypeInformation
Write-Host "Exported $($report.Count) membership rows."
In a large tenant, scope this to the groups you care about (for example, groups used in Conditional Access or license assignment) by adding a -Filter to Get-MgGroup.
Make Them Yours
Save these as .ps1 files in a scripts repository, run the read-only reports on a monthly schedule, and store the CSV output where your team reviews it — a report nobody reads is the same as no report. When you're ready to run them unattended, switch from interactive sign-in to certificate-based app-only authentication so no credentials ever live in the script.
Want this level of visibility into your Microsoft 365 tenant without doing it yourself? That's exactly what our Managed IT Services and Microsoft 365 Services deliver — including scheduled security reporting and remediation.
References