Short answer: run .\Get-M365MfaReport.ps1 -CsvPath .\mfa.csv. It signs in to Microsoft Graph, reads Microsoft’s per-user registration report (/reports/authenticationMethods/userRegistrationDetails) and joins it with each user’s licence and sign-in data, so you get one row per user with the registered methods, MFA capable, default method and admin flag. Tenants without Microsoft Entra ID P1/P2 fall back to reading /users/{id}/authentication/methods per user. Use -AdminsOnly -NotMfaCapableOnly to find the accounts to fix first.
Commands checked against the official documentation (linked below) on 6 October 2026; not yet run on our lab servers. The script was syntax-checked with PowerShell 7.4.6 and PSScriptAnalyzer 1.25 (no errors or warnings) but has not yet been run against a live Microsoft 365 tenant. Every cmdlet, endpoint, property and permission it uses was checked against Microsoft Learn. If something behaves differently for you, tell us and we will fix the script.
Table of Contents
What it does
The old way of checking MFA (per-user MFA state from the MSOnline module) is gone, and per-user MFA says nothing about what a user has actually registered. Microsoft Graph now offers two current ways to answer “who can do MFA”, and the script supports both:
| Source | Endpoint | Needs | Gives |
|---|---|---|---|
| Report (default) | GET /reports/authenticationMethods/userRegistrationDetails | Entra ID P1 or P2 in the tenant, AuditLog.Read.All | isMfaRegistered, isMfaCapable, isPasswordlessCapable, isSsprRegistered, isAdmin, methodsRegistered, default and system-preferred method |
| PerUser (fallback) | GET /users/{id}/authentication/methods | UserAuthenticationMethod.Read.All, no premium licence | The method objects themselves; the script derives “MFA registered” and “passwordless capable” from their types |
With -Source Auto (the default) the script tries the report first and only falls back to per-user reads if Graph refuses it. The report is one paged call for the whole tenant, so it is fast; the per-user mode makes one call per user and is slow on large tenants.
- Licence columns come from
assignedLicensesonGET /users, so you can ignore unlicensed accounts. - Sign-in columns (last interactive, last non-interactive, last successful) come from
signInActivity, which also needs P1/P2. If Graph refuses it the script warns and leaves those columns empty;-SkipSignInActivityskips it up front. - Admin flag: in report mode it is Microsoft’s
isAdmin. In per-user mode the script lists members of active directory roles, which does not include PIM-eligible assignments. - Filters: members, guests or both, disabled accounts in or out, only admins, only users who are not MFA capable.
- Output to the screen, CSV, HTML or the pipeline. Nothing in the tenant is changed.
Requirements
- Windows PowerShell 5.1 or PowerShell 7, and the
Microsoft.Graph.Authenticationmodule (the script only usesConnect-MgGraphandInvoke-MgGraphRequest, so the full Graph SDK is not needed). - Delegated scopes the script requests:
User.Read.AllandAuditLog.Read.All; in Auto and PerUser mode alsoUserAuthenticationMethod.Read.AllandRoleManagement.Read.Directory. An admin has to consent once. - Entra role for the person running it: Microsoft lists Reports Reader, Security Reader, Security Administrator or Global Reader for the registration report, and Global Reader, Authentication Administrator or Privileged Authentication Administrator for reading other users’ methods. Global Reader covers both.
- For the report and
signInActivity: a Microsoft Entra ID P1 or P2 licence in the tenant (included in Microsoft 365 Business Premium and E3/E5).
Download and first run
- Copy the script from the box on this page (or use the download button) and save it as
C:\Scripts\Get-M365MfaReport.ps1. - If you downloaded the file, clear the “downloaded from the internet” mark so the execution policy lets it run:
Unblock-File C:\Scripts\Get-M365MfaReport.ps1. - Install the module once, for your user:
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser. - Read the built-in help, then run it once interactively and look at the result on screen before you export or schedule anything.
cd C:\Scripts
Get-Help .\Get-M365MfaReport.ps1 -Full
.\Get-M365MfaReport.ps1
.\Get-M365MfaReport.ps1 -AdminsOnly -NotMfaCapableOnly
The first run opens a browser sign-in. Sign in with an account that has one of the roles above and accept the permission prompt (or have a Global Administrator consent for the organisation).
Options
| Parameter | What it does | Default |
|---|---|---|
-Source | Auto, Report or PerUser (see above) | Auto |
-UserPrincipalName | Only these users | All users |
-UserType | Member, Guest or All | Member |
-IncludeDisabled | Include blocked accounts (the report itself has no data for disabled users) | Off |
-NotMfaCapableOnly | Only users who are not MFA capable (report) or have no MFA method (per-user) | Off |
-AdminsOnly | Only users flagged as admins | Off |
-SkipSignInActivity | Do not request signInActivity (tenants without P1/P2) | Off |
-CsvPath / -HtmlPath | Write a CSV and/or an HTML table | Screen only |
-PassThru | Send the objects down the pipeline | Off |
-TenantId, -ClientId, -CertificateThumbprint | App-only sign-in for scheduled runs | Interactive |
Usage examples
# Whole tenant to CSV
.\Get-M365MfaReport.ps1 -CsvPath C:\Reports\mfa.csv
# Admins who cannot do MFA yet: fix these today
.\Get-M365MfaReport.ps1 -AdminsOnly -NotMfaCapableOnly -HtmlPath C:\Reports\admins-no-mfa.html
# Tenant without Entra ID P1/P2: per-user reads, no sign-in data
.\Get-M365MfaReport.ps1 -Source PerUser -SkipSignInActivity -CsvPath .\mfa.csv
# Licensed users who still only have SMS or voice
.\Get-M365MfaReport.ps1 -PassThru |
Where-Object { $_.IsLicensed -and $_.MethodsRegistered -match 'mobilePhone' -and $_.MethodsRegistered -notmatch 'microsoftAuthenticator|passKey|fido2|windowsHello' }
CSV columns
No sample output is shown because the script has not yet run against a live tenant. The CSV has these columns:
| Column | Meaning |
|---|---|
| DisplayName, UserPrincipalName, UserType, AccountEnabled | From GET /users |
| IsLicensed, LicenseCount | Whether assignedLicenses has entries, and how many |
| IsAdmin | Report: isAdmin. PerUser: member of an active directory role |
| IsMfaRegistered, IsMfaCapable | Report values. In PerUser mode IsMfaCapable is empty and IsMfaRegistered is derived from the method types |
| IsPasswordlessCapable, IsSsprRegistered | Report values (PerUser: passwordless derived from FIDO2, Windows Hello and platform credential methods) |
| DefaultMfaMethod | userPreferredMethodForSecondaryAuthentication (push, oath, sms, voiceMobile and so on) |
| SystemPreferredMethods | systemPreferredAuthenticationMethods, separated by “;” |
| MethodsRegistered | Report: methodsRegistered. PerUser: method types such as microsoftAuthenticator;phone;fido2 |
| LastSignIn, LastSuccessfulSignIn, LastNonInteractiveSignIn | From signInActivity (UTC), empty without P1/P2 |
| ReportUpdated | lastUpdatedDateTime of the report row |
| Source | Report, PerUser, or “Report (no entry)” when the user has no row in the report |
Schedule it
Scheduled runs cannot answer a sign-in prompt, so use app-only sign-in with a certificate. Microsoft’s steps are in Use app-only authentication with the Microsoft Graph PowerShell SDK; in short:
- Create an app registration in the Microsoft Entra admin center (single tenant).
- Create a certificate on the machine that will run the task, install it in the certificate store of the account that runs the task, and upload the public key (.cer) to the app registration.
- Under API permissions add these Application permissions for Microsoft Graph and grant admin consent:
User.Read.All,AuditLog.Read.All,UserAuthenticationMethod.Read.AllandRoleManagement.Read.Directory. - Note the application (client) ID, the tenant ID and the certificate thumbprint.
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -ExecutionPolicy Bypass -File C:\Scripts\Get-M365MfaReport.ps1 -CsvPath C:\Reports\mfa.csv -TenantId contoso.onmicrosoft.com -ClientId <app-id> -CertificateThumbprint <thumbprint>'
$trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At 7am
Register-ScheduledTask -TaskName 'M365 MFA report' -Action $action -Trigger $trigger -User 'CONTOSO\svc-reports' -Password '<password>'
Run the task as the account whose certificate store holds the certificate (a dedicated service account works well), and keep the private key on that machine only. The app has tenant-wide read access, so treat the certificate like an admin password and set an expiry date you will notice. The CSV is overwritten on every run; add the date to the file name in a small wrapper if you want history.
How it works
Connect-MgGraphsigns in, either interactively with the scopes listed above or app-only with the certificate.GET /users?$select=...,signInActivity&$top=500reads all users. Microsoft caps the page size at 500 whensignInActivityis selected (999 otherwise), and the script follows@odata.nextLinkuntil the end.- In report mode,
GET /reports/authenticationMethods/userRegistrationDetailsis read once and indexed by user ID. - In per-user mode,
GET /users/{id}/authentication/methodsruns for each user; the@odata.typeof each method (for example#microsoft.graph.fido2AuthenticationMethod) is shortened to a method name. Password, email and Temporary Access Pass do not count as MFA methods. - Throttling (HTTP 429) and temporary 503/504 errors are retried with a growing pause.
- Rows are filtered, sorted (admins first) and written out.
Limitations
- Report freshness. userRegistrationDetails is a report, not a live read; check the ReportUpdated column. After a user registers a new method it can take a while to appear.
- Disabled users are not covered by the report (Microsoft documents that the method does not work for disabled users), so with
-IncludeDisabledthey show “Report (no entry)”. - “Registered” is not “enforced”. A user can have Authenticator registered and still sign in with a password only if no Conditional Access policy or security default asks for MFA. Use this report together with your Conditional Access policies.
- PIM-eligible admins are not flagged in per-user mode.
- If
Connect-MgGraphfails with “Could not load file or assembly System.Text.Json”, the Graph module and your PowerShell 7 build do not match. We saw this in our check environment (PowerShell 7.4.6 with Microsoft.Graph.Authentication 2.41.1); try the current PowerShell 7 release or Windows PowerShell 5.1. - Not yet run against a live tenant (see the note at the top).
Official documentation: List userRegistrationDetails · userRegistrationDetails resource · List a user’s authentication methods · List users (signInActivity notes)
Related: Employee Offboarding Checklist: Secure AD, M365 and Workspace Steps · Microsoft 365 SPF DKIM DMARC: Secure Exchange Online Setup · Active Directory UPN Suffix: 4-Step Setup for Microsoft 365 · Secure Password Generator · Find Inactive AD Users and Computers: PowerShell Cleanup in 5 Steps
See also: Entra ID Inactive Users Report: signInActivity PowerShell Script · Windows LAPS with Intune and Entra ID: Setup and Retrieval · Intune Device Compliance Report: PowerShell Script via Graph · Microsoft 365 User Offboarding PowerShell Script (with -WhatIf)
The script
# Microsoft 365 MFA Status Report: PowerShell Script for Graph (v1.0.0) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/m365-mfa-status-report/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
<#
.SYNOPSIS
Microsoft 365 MFA status report: registered authentication methods, MFA capability, admin flag,
licence state and last sign-in for every user, exported to CSV and/or HTML.
.DESCRIPTION
Read-only. Uses Microsoft Graph through the Microsoft.Graph.Authentication module (Connect-MgGraph and
Invoke-MgGraphRequest), so only one small module is needed.
Data sources:
Report mode GET /reports/authenticationMethods/userRegistrationDetails
One call per 1,000-ish users. Gives methodsRegistered, isMfaRegistered, isMfaCapable,
isPasswordlessCapable, isSsprRegistered, isAdmin, default and system-preferred method.
Needs a Microsoft Entra ID P1 or P2 licence in the tenant. Disabled users are not included.
PerUser mode GET /users/{id}/authentication/methods for each user (slow on big tenants, no P1 needed).
MFA columns are derived from the method types; admin flag comes from active directory
role members (PIM-eligible assignments are not counted).
Auto (default) Report mode, falling back to PerUser mode if the report call is refused.
User details (type, enabled, licences, last sign-in) come from GET /users. signInActivity also needs
Entra ID P1/P2 and AuditLog.Read.All; if it is refused, the sign-in columns are left empty and a warning
is shown.
Delegated (interactive) scopes requested:
User.Read.All, AuditLog.Read.All always
UserAuthenticationMethod.Read.All, RoleManagement.Read.Directory Auto and PerUser modes
Entra role for the signed-in admin: Reports Reader, Security Reader or Global Reader for the report;
Global Reader or Authentication Administrator for per-user method reads.
App-only (certificate): grant the same permissions as Application permissions with admin consent.
.PARAMETER Source Auto (default), Report or PerUser. See DESCRIPTION.
.PARAMETER UserPrincipalName Only report these users (UPNs). Default: all users.
.PARAMETER UserType Member (default), Guest or All.
.PARAMETER IncludeDisabled Include disabled accounts (Report mode returns no method data for them).
.PARAMETER NotMfaCapableOnly Only output users who are not MFA capable/registered.
.PARAMETER AdminsOnly Only output users flagged as admins.
.PARAMETER SkipSignInActivity Do not request signInActivity (use on tenants without Entra ID P1/P2).
.PARAMETER CsvPath Write the report to this CSV file.
.PARAMETER HtmlPath Write the report to this HTML file.
.PARAMETER PassThru Output the result objects to the pipeline.
.PARAMETER TenantId Tenant ID or domain. Required for app-only sign-in.
.PARAMETER ClientId App registration (client) ID for app-only sign-in.
.PARAMETER CertificateThumbprint Thumbprint of the app's certificate (installed in the certificate store of the account running the script).
.EXAMPLE .\Get-M365MfaReport.ps1 -CsvPath .\mfa.csv
.EXAMPLE .\Get-M365MfaReport.ps1 -AdminsOnly -NotMfaCapableOnly -HtmlPath .\admins-without-mfa.html
.EXAMPLE .\Get-M365MfaReport.ps1 -UserType All -Source PerUser -SkipSignInActivity -CsvPath .\mfa.csv
.EXAMPLE .\Get-M365MfaReport.ps1 -TenantId contoso.onmicrosoft.com -ClientId <app-id> -CertificateThumbprint <thumbprint> -CsvPath C:\Reports\mfa.csv
.NOTES
Name: Get-M365MfaReport.ps1
Purpose: Per-user MFA / authentication method status for Microsoft 365 / Entra ID
Source: https://srvscripts.com/scripts/m365-mfa-status-report/
License: MIT
Version: 1.0.0
Requires: Windows PowerShell 5.1 or PowerShell 7, module Microsoft.Graph.Authentication
(Install-Module Microsoft.Graph.Authentication -Scope CurrentUser).
#>
[CmdletBinding(DefaultParameterSetName = 'Interactive')]
param(
[ValidateSet('Auto', 'Report', 'PerUser')]
[string]$Source = 'Auto',
[string[]]$UserPrincipalName,
[ValidateSet('Member', 'Guest', 'All')]
[string]$UserType = 'Member',
[switch]$IncludeDisabled,
[switch]$NotMfaCapableOnly,
[switch]$AdminsOnly,
[switch]$SkipSignInActivity,
[string]$CsvPath,
[string]$HtmlPath,
[switch]$PassThru,
[Parameter(ParameterSetName = 'Interactive')]
[Parameter(ParameterSetName = 'AppOnly', Mandatory = $true)]
[string]$TenantId,
[Parameter(ParameterSetName = 'AppOnly', Mandatory = $true)]
[ValidatePattern('^[0-9a-fA-F-]{36}$')]
[string]$ClientId,
[Parameter(ParameterSetName = 'AppOnly', Mandatory = $true)]
[ValidatePattern('^[0-9a-fA-F]{40}$')]
[string]$CertificateThumbprint
)
Set-StrictMode -Version 2
$ErrorActionPreference = 'Stop'
$Graph = 'https://graph.microsoft.com/v1.0'
function Write-Status([string]$Message) { Write-Information $Message -InformationAction Continue }
function Invoke-GraphWithRetry {
param([string]$Uri, [string]$Method = 'GET', [hashtable]$Headers)
$attempt = 0
while ($true) {
$attempt++
try {
$p = @{ Method = $Method; Uri = $Uri; OutputType = 'HashTable'; ErrorAction = 'Stop' }
if ($Headers) { $p.Headers = $Headers }
return Invoke-MgGraphRequest @p
} catch {
$msg = $_.Exception.Message
if ($attempt -lt 5 -and $msg -match '429|TooManyRequests|503|ServiceUnavailable|504|GatewayTimeout') {
$wait = [math]::Pow(2, $attempt) * 5
Write-Verbose "Graph throttled or busy, waiting $wait s (attempt $attempt)."
Start-Sleep -Seconds $wait
continue
}
throw
}
}
}
function Get-GraphCollection {
param([string]$Uri, [hashtable]$Headers)
$next = $Uri
while ($next) {
$r = Invoke-GraphWithRetry -Uri $next -Headers $Headers
if ($r['value']) { foreach ($i in $r['value']) { $i } }
$next = $r['@odata.nextLink']
}
}
function Get-Value($Hash, [string]$Key) {
if ($null -ne $Hash -and $Hash.ContainsKey($Key)) { return $Hash[$Key] }
return $null
}
# ---- Connect --------------------------------------------------------------------------------------------
if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) {
throw 'Module Microsoft.Graph.Authentication is not installed. Run: Install-Module Microsoft.Graph.Authentication -Scope CurrentUser'
}
Import-Module Microsoft.Graph.Authentication
$scopes = @('User.Read.All', 'AuditLog.Read.All')
if ($Source -ne 'Report') { $scopes += 'UserAuthenticationMethod.Read.All', 'RoleManagement.Read.Directory' }
if ($PSCmdlet.ParameterSetName -eq 'AppOnly') {
Connect-MgGraph -TenantId $TenantId -ClientId $ClientId -CertificateThumbprint $CertificateThumbprint -NoWelcome
} else {
$cp = @{ Scopes = $scopes; NoWelcome = $true }
if ($TenantId) { $cp.TenantId = $TenantId }
Connect-MgGraph @cp
}
$ctx = Get-MgContext
if (-not $ctx) { throw 'Not connected to Microsoft Graph.' }
Write-Status ("Connected to tenant {0} as {1}" -f $ctx.TenantId, $(if ($ctx.Account) { $ctx.Account } else { "app $($ctx.ClientId)" }))
# ---- Users ----------------------------------------------------------------------------------------------
$select = 'id,displayName,userPrincipalName,userType,accountEnabled,assignedLicenses'
$withSignIn = -not $SkipSignInActivity
$users = @()
if ($UserPrincipalName) {
foreach ($upn in $UserPrincipalName) {
$sel = if ($withSignIn) { "$select,signInActivity" } else { $select }
try {
$users += Invoke-GraphWithRetry -Uri ("$Graph/users/{0}?`$select={1}" -f [uri]::EscapeDataString($upn), $sel)
} catch {
if ($withSignIn -and $_.Exception.Message -match 'Forbidden|403|premium|license') {
Write-Warning 'signInActivity was refused (needs Entra ID P1/P2 and AuditLog.Read.All). Sign-in columns will be empty.'
$withSignIn = $false
$users += Invoke-GraphWithRetry -Uri ("$Graph/users/{0}?`$select={1}" -f [uri]::EscapeDataString($upn), $select)
} elseif ($_.Exception.Message -match 'NotFound|404|does not exist') {
Write-Warning "User not found: $upn"
} else { throw }
}
}
} else {
try {
$sel = if ($withSignIn) { "$select,signInActivity" } else { $select }
$top = if ($withSignIn) { 500 } else { 999 }
$users = @(Get-GraphCollection -Uri "$Graph/users?`$select=$sel&`$top=$top")
} catch {
if ($withSignIn -and $_.Exception.Message -match 'Forbidden|403|premium|license') {
Write-Warning 'signInActivity was refused (needs Entra ID P1/P2 and AuditLog.Read.All). Sign-in columns will be empty.'
$withSignIn = $false
$users = @(Get-GraphCollection -Uri "$Graph/users?`$select=$select&`$top=999")
} else { throw }
}
}
if ($UserType -ne 'All') { $users = @($users | Where-Object { (Get-Value $_ 'userType') -eq $UserType }) }
if (-not $IncludeDisabled) { $users = @($users | Where-Object { (Get-Value $_ 'accountEnabled') -ne $false }) }
Write-Status ("{0} user(s) in scope." -f $users.Count)
if (-not $users.Count) { return }
# ---- Registration data ------------------------------------------------------------------------------------
$reg = @{}
$mode = $Source
if ($Source -in 'Auto', 'Report') {
try {
foreach ($r in (Get-GraphCollection -Uri "$Graph/reports/authenticationMethods/userRegistrationDetails")) { $reg[$r['id']] = $r }
$mode = 'Report'
} catch {
if ($Source -eq 'Report') {
throw "userRegistrationDetails failed: $($_.Exception.Message). It needs Entra ID P1/P2, AuditLog.Read.All and a Reports Reader / Security Reader / Global Reader role."
}
Write-Warning "userRegistrationDetails is not available ($($_.Exception.Message)). Falling back to per-user method reads (slower)."
$mode = 'PerUser'
}
}
$admins = @{}
if ($mode -eq 'PerUser') {
foreach ($role in (Get-GraphCollection -Uri "$Graph/directoryRoles?`$select=id,displayName")) {
foreach ($m in (Get-GraphCollection -Uri ("$Graph/directoryRoles/{0}/members?`$select=id" -f $role['id']))) {
$admins[$m['id']] = $true
}
}
}
# Method types that count as a second factor in PerUser mode (password, email and TAP do not).
$mfaTypes = 'microsoftAuthenticator', 'phone', 'fido2', 'softwareOath', 'windowsHelloForBusiness', 'platformCredential', 'external'
$passwordlessTypes = 'fido2', 'windowsHelloForBusiness', 'platformCredential'
# ---- Build rows -------------------------------------------------------------------------------------------
$rows = [System.Collections.Generic.List[object]]::new()
$i = 0
foreach ($u in $users) {
$i++
if ($mode -eq 'PerUser') { Write-Progress -Activity 'Reading authentication methods' -Status (Get-Value $u 'userPrincipalName') -PercentComplete ($i * 100 / $users.Count) }
$sia = Get-Value $u 'signInActivity'
$lic = @(Get-Value $u 'assignedLicenses')
$licCount = @($lic | Where-Object { $_ }).Count
$row = [ordered]@{
DisplayName = Get-Value $u 'displayName'
UserPrincipalName = Get-Value $u 'userPrincipalName'
UserType = Get-Value $u 'userType'
AccountEnabled = Get-Value $u 'accountEnabled'
IsLicensed = ($licCount -gt 0)
LicenseCount = $licCount
IsAdmin = $null
IsMfaRegistered = $null
IsMfaCapable = $null
IsPasswordlessCapable = $null
IsSsprRegistered = $null
DefaultMfaMethod = ''
SystemPreferredMethods = ''
MethodsRegistered = ''
LastSignIn = Get-Value $sia 'lastSignInDateTime'
LastSuccessfulSignIn = Get-Value $sia 'lastSuccessfulSignInDateTime'
LastNonInteractiveSignIn = Get-Value $sia 'lastNonInteractiveSignInDateTime'
ReportUpdated = $null
Source = $mode
}
if ($mode -eq 'Report') {
$r = $reg[(Get-Value $u 'id')]
if ($r) {
$row.IsAdmin = Get-Value $r 'isAdmin'
$row.IsMfaRegistered = Get-Value $r 'isMfaRegistered'
$row.IsMfaCapable = Get-Value $r 'isMfaCapable'
$row.IsPasswordlessCapable = Get-Value $r 'isPasswordlessCapable'
$row.IsSsprRegistered = Get-Value $r 'isSsprRegistered'
$row.DefaultMfaMethod = [string](Get-Value $r 'userPreferredMethodForSecondaryAuthentication')
$row.SystemPreferredMethods = (@(Get-Value $r 'systemPreferredAuthenticationMethods') | Where-Object { $_ }) -join ';'
$row.MethodsRegistered = (@(Get-Value $r 'methodsRegistered') | Where-Object { $_ }) -join ';'
$row.ReportUpdated = Get-Value $r 'lastUpdatedDateTime'
} else {
$row.Source = 'Report (no entry)'
}
} else {
$row.IsAdmin = $admins.ContainsKey((Get-Value $u 'id'))
try {
$methods = @(Get-GraphCollection -Uri ("$Graph/users/{0}/authentication/methods" -f (Get-Value $u 'id')))
$types = @($methods | ForEach-Object { ([string]$_['@odata.type']) -replace '^#microsoft\.graph\.', '' -replace 'AuthenticationMethod$', '' } | Sort-Object -Unique)
$row.MethodsRegistered = $types -join ';'
$row.IsMfaRegistered = [bool]@($types | Where-Object { $mfaTypes -contains $_ }).Count
$row.IsPasswordlessCapable = [bool]@($types | Where-Object { $passwordlessTypes -contains $_ }).Count
} catch {
$row.MethodsRegistered = "ERROR: $($_.Exception.Message)"
}
}
$rows.Add((New-Object psobject -Property $row))
}
if ($mode -eq 'PerUser') { Write-Progress -Activity 'Reading authentication methods' -Completed }
# ---- Filters and output -------------------------------------------------------------------------------------
$out = @($rows)
if ($AdminsOnly) { $out = @($out | Where-Object { $_.IsAdmin -eq $true }) }
if ($NotMfaCapableOnly) {
$out = @($out | Where-Object { if ($mode -eq 'Report') { $_.IsMfaCapable -ne $true } else { $_.IsMfaRegistered -ne $true } })
}
$out = @($out | Sort-Object @{ e = { $_.IsAdmin -eq $true }; Descending = $true }, UserPrincipalName)
if ($CsvPath) {
$out | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8
Write-Status ("CSV written: {0} ({1} rows)" -f $CsvPath, $out.Count)
}
if ($HtmlPath) {
$css = '<style>body{font-family:Segoe UI,Arial,sans-serif;font-size:13px}table{border-collapse:collapse}th,td{border:1px solid #ccc;padding:4px 6px;text-align:left}th{background:#eee}</style>'
$pre = "<h2>Microsoft 365 MFA status</h2><p>Tenant $($ctx.TenantId). Generated $(Get-Date -Format 'yyyy-MM-dd HH:mm'). Source: $mode. Users: $($out.Count).</p>"
$out | ConvertTo-Html -Head $css -PreContent $pre | Out-File -FilePath $HtmlPath -Encoding UTF8
Write-Status "HTML written: $HtmlPath"
}
$total = $out.Count
$noMfa = @($out | Where-Object { $_.IsMfaRegistered -ne $true }).Count
$adminNoMfa = @($out | Where-Object { $_.IsAdmin -eq $true -and $_.IsMfaRegistered -ne $true }).Count
Write-Status ("Users reported: {0}. Without a registered MFA method: {1}. Admins without MFA: {2}." -f $total, $noMfa, $adminNoMfa)
if ($PassThru) { return $out }
if (-not $CsvPath -and -not $HtmlPath) {
$out | Select-Object DisplayName, UserPrincipalName, IsAdmin, IsLicensed, IsMfaRegistered, IsMfaCapable, DefaultMfaMethod, LastSuccessfulSignIn |
Format-Table -AutoSize
}
8d1fe7b7281caec5bee6a47976da1e978cfc8eae88aa25e950463d445ad63023curl -fsSL -o Get-M365MfaReport.ps1 https://scr.srvscripts.com/m365-mfa-status-report/Get-M365MfaReport.ps1 && curl -fsSL https://scr.srvscripts.com/m365-mfa-status-report/Get-M365MfaReport.ps1.sha256 | sha256sum -cInvoke-WebRequest -Uri 'https://scr.srvscripts.com/m365-mfa-status-report/Get-M365MfaReport.ps1' -OutFile 'Get-M365MfaReport.ps1'; if ((Get-FileHash 'Get-M365MfaReport.ps1' -Algorithm SHA256).Hash -eq '8D1FE7B7281CAEC5BEE6A47976DA1E978CFC8EAE88AA25E950463D445AD63023') { 'OK: the file is intact' } else { 'MISMATCH: do not run this file' }Copy the whole line. In Windows PowerShell, curl and sha256sum are not the Linux tools, so use the PowerShell line there.Also on GitHub: github.com/srvscripts/scripts
Frequently asked questions
How do I check MFA status for all Microsoft 365 users with PowerShell?
Use Microsoft Graph. The userRegistrationDetails report gives isMfaRegistered and isMfaCapable for every enabled user in one paged call; this script reads it and adds licence and sign-in data.
What is the difference between MFA registered and MFA capable?
Registered means the user has registered a strong authentication method. Capable means the user has registered a method that the tenant’s authentication methods policy allows for MFA.
Do I need Entra ID P1 for an MFA report?
For the userRegistrationDetails report and for signInActivity, yes. Without P1 or P2 run the script with -Source PerUser -SkipSignInActivity, which reads each user’s methods directly.
Which role do I need to run it?
Global Reader covers both the report and per-user method reads. Reports Reader or Security Reader is enough for the report alone.
Does the script change anything?
No. It only sends GET requests to Microsoft Graph.
Why are disabled users missing from the report?
Microsoft documents that userRegistrationDetails does not work for disabled users. Use -IncludeDisabled to list them anyway; their method columns stay empty.