Emergency server help: get in touch

Microsoft 365 Security Free · MIT

Microsoft 365 MFA Status Report: PowerShell Script for Graph

Free PowerShell script that reports MFA registration, MFA capable, default method, admin flag, licence and last sign-in for every Microsoft 365 user via Microsoft Graph.

Version
1.0.0
Last updated
October 6, 2026
Language
PowerShell
Tested on
Syntax-checked with PowerShell 7.4.6 and PSScriptAnalyzer 1.25 on 6 Oct 2026; not yet run against a live tenant/server.
License
MIT
Pricing
Free

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.

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:

SourceEndpointNeedsGives
Report (default)GET /reports/authenticationMethods/userRegistrationDetailsEntra ID P1 or P2 in the tenant, AuditLog.Read.AllisMfaRegistered, isMfaCapable, isPasswordlessCapable, isSsprRegistered, isAdmin, methodsRegistered, default and system-preferred method
PerUser (fallback)GET /users/{id}/authentication/methodsUserAuthenticationMethod.Read.All, no premium licenceThe 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 assignedLicenses on GET /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; -SkipSignInActivity skips 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.Authentication module (the script only uses Connect-MgGraph and Invoke-MgGraphRequest, so the full Graph SDK is not needed).
  • Delegated scopes the script requests: User.Read.All and AuditLog.Read.All; in Auto and PerUser mode also UserAuthenticationMethod.Read.All and RoleManagement.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

  1. Copy the script from the box on this page (or use the download button) and save it as C:\Scripts\Get-M365MfaReport.ps1.
  2. 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.
  3. Install the module once, for your user: Install-Module Microsoft.Graph.Authentication -Scope CurrentUser.
  4. 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

ParameterWhat it doesDefault
-SourceAuto, Report or PerUser (see above)Auto
-UserPrincipalNameOnly these usersAll users
-UserTypeMember, Guest or AllMember
-IncludeDisabledInclude blocked accounts (the report itself has no data for disabled users)Off
-NotMfaCapableOnlyOnly users who are not MFA capable (report) or have no MFA method (per-user)Off
-AdminsOnlyOnly users flagged as adminsOff
-SkipSignInActivityDo not request signInActivity (tenants without P1/P2)Off
-CsvPath / -HtmlPathWrite a CSV and/or an HTML tableScreen only
-PassThruSend the objects down the pipelineOff
-TenantId, -ClientId, -CertificateThumbprintApp-only sign-in for scheduled runsInteractive

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:

ColumnMeaning
DisplayName, UserPrincipalName, UserType, AccountEnabledFrom GET /users
IsLicensed, LicenseCountWhether assignedLicenses has entries, and how many
IsAdminReport: isAdmin. PerUser: member of an active directory role
IsMfaRegistered, IsMfaCapableReport values. In PerUser mode IsMfaCapable is empty and IsMfaRegistered is derived from the method types
IsPasswordlessCapable, IsSsprRegisteredReport values (PerUser: passwordless derived from FIDO2, Windows Hello and platform credential methods)
DefaultMfaMethoduserPreferredMethodForSecondaryAuthentication (push, oath, sms, voiceMobile and so on)
SystemPreferredMethodssystemPreferredAuthenticationMethods, separated by “;”
MethodsRegisteredReport: methodsRegistered. PerUser: method types such as microsoftAuthenticator;phone;fido2
LastSignIn, LastSuccessfulSignIn, LastNonInteractiveSignInFrom signInActivity (UTC), empty without P1/P2
ReportUpdatedlastUpdatedDateTime of the report row
SourceReport, 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:

  1. Create an app registration in the Microsoft Entra admin center (single tenant).
  2. 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.
  3. Under API permissions add these Application permissions for Microsoft Graph and grant admin consent: User.Read.All, AuditLog.Read.All, UserAuthenticationMethod.Read.All and RoleManagement.Read.Directory.
  4. 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

  1. Connect-MgGraph signs in, either interactively with the scopes listed above or app-only with the certificate.
  2. GET /users?$select=...,signInActivity&$top=500 reads all users. Microsoft caps the page size at 500 when signInActivity is selected (999 otherwise), and the script follows @odata.nextLink until the end.
  3. In report mode, GET /reports/authenticationMethods/userRegistrationDetails is read once and indexed by user ID.
  4. In per-user mode, GET /users/{id}/authentication/methods runs for each user; the @odata.type of 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.
  5. Throttling (HTTP 429) and temporary 503/504 errors are retried with a growing pause.
  6. 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 -IncludeDisabled they 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-MgGraph fails 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

Get-M365MfaReport.ps1Download
# 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
}
Version 1.0.0 · SHA-256 8d1fe7b7281caec5bee6a47976da1e978cfc8eae88aa25e950463d445ad63023
Download and verify on Linux or macOS
curl -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 -c
Download and verify in Windows PowerShell
Invoke-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.

Changelog

  • 1.0.0 — First release.

Free website test

Is your website set up right?

Check SSL, security headers, redirects, robots.txt, sitemap, llms.txt and security.txt in one test. It takes about 30 seconds.