# .SYNOPSIS Reports every user's registered MFA/authentication methods and default method, flagging accounts that rely solely on SMS/Voice (red) or whose default method is SMS/Voice even though other methods exist (yellow). .DESCRIPTION Built ahead of the Microsoft Entra ID SMS/Voice retirement (https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sms-voice-retirement). Works on both Entra ID P1/P2 tenants and Free/Business Basic tenants: - On P1/P2 tenants it uses the fast tenant-wide report (Get-MgReportAuthenticationMethodUserRegistrationDetail), one call for every user, including the reported preferred/default method. - On tenants without P1/P2 (or if that report is unavailable/unlicensed) it falls back to the per-user authentication methods API (Get-MgUserAuthenticationMethod), which does not require premium licensing, and best-effort per-user sign-in preference for the default method. The script never modifies anything in the tenant - it only reads authentication method registrations - so it does not need -WhatIf/-Confirm. Row classification in the HTML/CSV output: - RED ("SmsVoiceOnly"): the only MFA-capable method(s) registered are SMS and/or Voice call. These accounts will hit the blocking passkey prompt on Feb 2027 retirement with no fallback. - YELLOW ("SmsVoiceRegistered"): other MFA methods are registered too, but SMS or Voice is also registered and still needs to be removed from the account ahead of the retirement. - (none): no SMS/Voice exposure, or no MFA registered at all (flagged separately in the Notes column - a different problem, not this retirement). Intended to be run once per tenant. For an MSP/CSP audit across many customer tenants, call this script once per tenant (looping over your GDAP-delegated customers) and collect the per-tenant CSV outputs. .PARAMETER TenantId Target Entra ID tenant ID or domain to connect to. Optional if you are already connected via Connect-MgGraph, or if you want the interactive/ device-code sign-in to resolve the tenant itself. .PARAMETER OutputFolder Folder to write the CSV and HTML report to. Defaults to the current working directory. .PARAMETER IncludeGuests Include guest (B2B) accounts in the report. By default only Member users are reported, matching how Microsoft scopes the SMS/Voice retirement. .PARAMETER ForcePerUserLookup Skip the fast tenant-wide registration report even if the tenant has P1/P2, and use the slower per-user method lookup for every user instead. Useful for validating the two code paths against each other. .EXAMPLE PS> .\Get-UserMfaMethodReport.ps1 Connects interactively, reports on the current tenant, and writes UserMfaMethodReport.csv / .html to the current folder. .EXAMPLE PS> .\Get-UserMfaMethodReport.ps1 -TenantId "contoso.onmicrosoft.com" -OutputFolder "C:\Audits\Contoso" Reports on a specific customer tenant (e.g. via GDAP) and writes output to a per-client folder. .NOTES Author: Petr Štěpán Created: 2026-07-27 Version: 1.0.0 Changelog: 1.0.0 - Initial version #> #Requires -Version 7.0 [CmdletBinding()] param( [Parameter(Mandatory = $false, HelpMessage = 'Target tenant ID or domain name.')] [string] $TenantId, [Parameter(Mandatory = $false, HelpMessage = 'Folder to write the CSV/HTML report to.')] [ValidateNotNullOrEmpty()] [string] $OutputFolder = (Get-Location).Path, [Parameter(Mandatory = $false)] [switch] $IncludeGuests, [Parameter(Mandatory = $false)] [switch] $ForcePerUserLookup ) # --- Safety preamble ------------------------------------------------------- # Fail fast on uninitialized variables and typos in property/member names. Set-StrictMode -Version Latest # Treat all non-terminating errors as terminating so failures can't slip by silently, # except inside the per-user/per-item loops below, where we deliberately catch and # continue so one bad user doesn't abort the whole tenant run. $ErrorActionPreference = 'Stop' # Method @odata.type values that count as a genuine MFA-capable factor. # Password and Email are excluded: Password isn't a second factor, and Email # is an SSPR-only method, not an MFA method. $script:MfaMethodTypes = @( '#microsoft.graph.phoneAuthenticationMethod' '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod' '#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod' '#microsoft.graph.fido2AuthenticationMethod' '#microsoft.graph.windowsHelloForBusinessAuthenticationMethod' '#microsoft.graph.softwareOathAuthenticationMethod' '#microsoft.graph.temporaryAccessPassAuthenticationMethod' '#microsoft.graph.platformCredentialAuthenticationMethod' ) # --- Functions --------------------------------------------------------- function Test-PremiumAuthReportLicense { <# .SYNOPSIS Returns $true if the connected tenant has an Entra ID P1 or P2 service plan assigned to any subscribed SKU, which is required for the tenant-wide authentication methods registration report. #> [CmdletBinding()] param() try { [array]$skus = Get-MgSubscribedSku -All -ErrorAction Stop } catch { Write-Warning "Could not read subscribed SKUs to detect P1/P2 licensing: $_" return $false } # AAD_PREMIUM = Entra ID P1, AAD_PREMIUM_P2 = Entra ID P2. Both are also # included as service plans inside bundles like Business Premium / E3 / E5. $premiumPlanNames = @('AAD_PREMIUM', 'AAD_PREMIUM_P2') foreach ($sku in $skus) { foreach ($plan in $sku.ServicePlans) { if ($plan.ServicePlanName -in $premiumPlanNames -and $plan.ProvisioningStatus -eq 'Success') { return $true } } } return $false } function ConvertTo-FriendlyAuthMethod { <# .SYNOPSIS Maps a Graph authentication method @odata.type (plus phoneType, where relevant) to a friendly display name and a category used for red/yellow classification. .PARAMETER ODataType The '@odata.type' value of the method, e.g. '#microsoft.graph.phoneAuthenticationMethod'. .PARAMETER PhoneType For phone methods only: 'mobile', 'alternateMobile', or 'office'. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $ODataType, [Parameter(Mandatory = $false)] [string] $PhoneType ) switch ($ODataType) { '#microsoft.graph.passwordAuthenticationMethod' { return [PSCustomObject]@{ DisplayName = 'Password'; Category = 'NotMfa' } } '#microsoft.graph.phoneAuthenticationMethod' { $label = if ($PhoneType -eq 'office') { 'Voice call (office phone)' } else { 'SMS / Voice call (mobile)' } return [PSCustomObject]@{ DisplayName = $label; Category = 'Phone' } } '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod' { return [PSCustomObject]@{ DisplayName = 'Microsoft Authenticator'; Category = 'Strong' } } '#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod' { return [PSCustomObject]@{ DisplayName = 'Passwordless (Authenticator)'; Category = 'Strong' } } '#microsoft.graph.fido2AuthenticationMethod' { return [PSCustomObject]@{ DisplayName = 'FIDO2 / Passkey'; Category = 'Strong' } } '#microsoft.graph.windowsHelloForBusinessAuthenticationMethod' { return [PSCustomObject]@{ DisplayName = 'Windows Hello for Business'; Category = 'Strong' } } '#microsoft.graph.softwareOathAuthenticationMethod' { return [PSCustomObject]@{ DisplayName = 'OATH software token'; Category = 'Strong' } } '#microsoft.graph.temporaryAccessPassAuthenticationMethod' { return [PSCustomObject]@{ DisplayName = 'Temporary Access Pass'; Category = 'Other' } } '#microsoft.graph.platformCredentialAuthenticationMethod' { return [PSCustomObject]@{ DisplayName = 'Platform credential (e.g. Mac)'; Category = 'Strong' } } '#microsoft.graph.emailAuthenticationMethod' { return [PSCustomObject]@{ DisplayName = 'Email (SSPR only)'; Category = 'NotMfa' } } default { return [PSCustomObject]@{ DisplayName = "Unknown method ($ODataType)"; Category = 'Other' } } } } function Get-UserAuthMethodDetail { <# .SYNOPSIS Reads a single user's registered authentication methods and best-effort default/preferred method via the per-user Graph API. Works regardless of tenant licensing. .PARAMETER UserId The Entra ID object ID of the user to look up. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $UserId ) [array]$friendlyMethods = @() $hasStrongMethod = $false $hasPhoneMethod = $false try { [array]$methods = Get-MgUserAuthenticationMethod -UserId $UserId -ErrorAction Stop } catch { Write-Warning "Could not read authentication methods for user '$UserId': $_" return [PSCustomObject]@{ MethodsDisplay = 'Error retrieving methods' HasStrongMethod = $false HasPhoneMethod = $false DefaultMethod = $null } } foreach ($method in $methods) { $odataType = $method.AdditionalProperties['@odata.type'] $phoneType = $method.AdditionalProperties['phoneType'] $friendly = ConvertTo-FriendlyAuthMethod -ODataType $odataType -PhoneType $phoneType $friendlyMethods += $friendly.DisplayName if ($friendly.Category -eq 'Strong') { $hasStrongMethod = $true } if ($friendly.Category -eq 'Phone') { $hasPhoneMethod = $true } } # Best-effort default/preferred method. This endpoint reflects the user's # own sign-in preference and, unlike the tenant-wide usage/insights report, # is not gated behind a P1/P2 license - but treat failures as "unknown" # rather than fatal, since behavior can vary by tenant/SDK version. $defaultMethod = $null try { $preference = Get-MgBetaUserAuthenticationSignInPreference -UserId $UserId -ErrorAction Stop $defaultMethod = $preference.UserPreferredMethodForSecondaryAuthentication } catch { Write-Verbose "No sign-in preference available for user '$UserId' (falling back to heuristic): $_" } return [PSCustomObject]@{ MethodsDisplay = ($friendlyMethods -join ', ') HasStrongMethod = $hasStrongMethod HasPhoneMethod = $hasPhoneMethod DefaultMethod = $defaultMethod } } function Export-AuthenticationHtmlReport { <# .SYNOPSIS Writes the collected report rows to a colour-coded HTML file. .PARAMETER Report Array of report row objects (must include a 'RowClass' property). .PARAMETER Path Destination HTML file path. .PARAMETER TenantDisplayName Tenant name shown in the report header. .PARAMETER IsPremiumTenant Whether the tenant-wide (P1/P2) report path was used. .PARAMETER MethodPolicyStates Array of objects with Id/State properties describing the tenant's authentication methods policy (which methods are allowed/enabled), as returned by Get-MgPolicyAuthenticationMethodPolicy. Optional - omit or pass an empty array to skip that section of the report. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [array] $Report, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $Path, [Parameter(Mandatory = $true)] [string] $TenantDisplayName, [Parameter(Mandatory = $true)] [bool] $IsPremiumTenant, [Parameter(Mandatory = $false)] [array] $MethodPolicyStates = @() ) $runDate = Get-Date -Format 'dd-MMM-yyyy HH:mm:ss' $licenseNote = if ($IsPremiumTenant) { 'P1/P2 detected - tenant-wide report used' } else { 'No P1/P2 detected - per-user lookup used' } $htmlHead = @"
| Method | State |
|---|
-----------------------------------------------------------------------
Total users reported: $($Report.Count)
Red - only MFA method is SMS/Voice: $countOnly
Yellow - SMS/Voice registered alongside other methods: $countDefault
Reference: https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sms-voice-retirement
"@ ($htmlHead + $htmlBody + $htmlTail + $methodPolicyHtml) | Out-File -FilePath $Path -Encoding utf8 } # --- Main -------------------------------------------------------------- try { Write-Verbose 'Connecting to Microsoft Graph...' $requiredScopes = @( 'User.Read.All' 'UserAuthenticationMethod.Read.All' 'Reports.Read.All' 'AuditLog.Read.All' 'Organization.Read.All' 'Directory.Read.All' 'Policy.Read.All' ) $connectParams = @{ Scopes = $requiredScopes; NoWelcome = $true } if ($TenantId) { $connectParams['TenantId'] = $TenantId } Connect-MgGraph @connectParams [array]$organization = Get-MgOrganization -ErrorAction SilentlyContinue $tenantDisplayName = if ($organization) { $organization[0].DisplayName } else { 'Unknown tenant' } $isPremium = (-not $ForcePerUserLookup) -and (Test-PremiumAuthReportLicense) Write-Host "Tenant: $tenantDisplayName - Premium (P1/P2) auth report available: $isPremium" -ForegroundColor Cyan # Pull user population first; every user in scope gets a row regardless # of which data-source path is used for their methods. Write-Host 'Retrieving user accounts...' -ForegroundColor Cyan $userFilter = "accountEnabled eq true" if (-not $IncludeGuests) { $userFilter += " and userType eq 'Member'" } [array]$users = Get-MgUser -Filter $userFilter -ConsistencyLevel eventual -CountVariable usersFound ` -Property Id, DisplayName, UserPrincipalName, UserType -All -PageSize 500 -Sort DisplayName Write-Host "Found $($users.Count) enabled user account(s) to report on." -ForegroundColor Cyan # On premium tenants, try the fast tenant-wide report once; fall back to # per-user lookup for everyone if the report call fails (e.g. missing # consent, throttled tenant, or a licensing edge case the SKU check missed). $registrationLookup = $null if ($isPremium) { try { Write-Host 'Retrieving tenant-wide authentication method registration report...' -ForegroundColor Cyan [array]$registrationDetails = Get-MgReportAuthenticationMethodUserRegistrationDetail -All -PageSize 500 $registrationLookup = @{} foreach ($detail in $registrationDetails) { $registrationLookup[$detail.Id] = $detail } } catch { Write-Warning "Tenant-wide registration report failed even though P1/P2 was detected; falling back to per-user lookup. Error: $_" $registrationLookup = $null } } $report = [System.Collections.Generic.List[Object]]::new() [int]$i = 0 foreach ($user in $users) { $i++ Write-Progress -Activity 'Retrieving MFA methods' -Status "$($user.DisplayName) ($i of $($users.Count))" -PercentComplete (($i / $users.Count) * 100) Write-Verbose ("Processing {0} ({1}/{2})..." -f $user.DisplayName, $i, $users.Count) $registeredMethodsDisplay = $null $hasStrongMethod = $false $hasPhoneMethod = $false $defaultMethod = $null $regDetail = if ($registrationLookup) { $registrationLookup[$user.Id] } else { $null } if ($regDetail) { # Fast path: everything needed comes from the single tenant-wide report. $registeredMethodsDisplay = ($regDetail.MethodsRegistered -join ', ') $hasPhoneMethod = ($regDetail.MethodsRegistered -contains 'mobilePhone') -or ($regDetail.MethodsRegistered -contains 'alternateMobilePhone') -or ($regDetail.MethodsRegistered -contains 'officePhone') $strongRegistered = $regDetail.MethodsRegistered | Where-Object { $_ -notin @('mobilePhone', 'alternateMobilePhone', 'officePhone', 'email', 'password') } $hasStrongMethod = [bool]$strongRegistered $defaultMethod = $regDetail.UserPreferredMethodForSecondaryAuthentication } else { # Fallback path: per-user methods API, works without P1/P2. try { $detail = Get-UserAuthMethodDetail -UserId $user.Id $registeredMethodsDisplay = $detail.MethodsDisplay $hasStrongMethod = $detail.HasStrongMethod $hasPhoneMethod = $detail.HasPhoneMethod $defaultMethod = $detail.DefaultMethod } catch { # Do not let one user's failure abort the whole tenant run. Write-Warning "Skipping method detail for '$($user.UserPrincipalName)' after repeated failure: $_" $registeredMethodsDisplay = 'Error retrieving methods' } } # If no explicit default was reported/available, fall back to a # heuristic: if phone is the only MFA-capable method registered, # it is by definition the (only possible) default. $notes = $null if (-not $defaultMethod) { if ($hasPhoneMethod -and -not $hasStrongMethod) { $defaultMethod = 'sms/voice (inferred - only method registered)' } elseif (-not $hasPhoneMethod -and -not $hasStrongMethod) { $notes = 'No MFA method registered' } else { $defaultMethod = 'Not reported' } } $rowClass = $null if ($hasPhoneMethod -and -not $hasStrongMethod) { $rowClass = 'SmsVoiceOnly' } elseif ($hasStrongMethod -and $hasPhoneMethod) { # SMS/Voice is registered alongside other methods - not the user's # only option, but it still needs to be removed from their # account ahead of the retirement, regardless of default status. $rowClass = 'SmsVoiceRegistered' } $report.Add([PSCustomObject]@{ DisplayName = $user.DisplayName UserPrincipalName = $user.UserPrincipalName RegisteredMethods = $registeredMethodsDisplay DefaultMethod = $defaultMethod RowClass = $rowClass Notes = $notes }) } Write-Progress -Activity 'Retrieving MFA methods' -Completed # Tenant-wide authentication methods policy: which methods are allowed # (enabled) at all, regardless of who has actually registered them. # Non-fatal - this is a supplementary overview, not required to have # already produced the per-user report above. [array]$methodPolicyStates = @() try { $authMethodPolicy = Get-MgPolicyAuthenticationMethodPolicy -ErrorAction Stop [array]$methodPolicyStates = $authMethodPolicy.AuthenticationMethodConfigurations | Select-Object Id, State | Sort-Object Id } catch { Write-Warning "Could not read tenant authentication methods policy: $_" } if (-not (Test-Path -Path $OutputFolder)) { New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null } $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' # Prefer the tenant ID/domain the caller connected with (e.g. # "contoso.onmicrosoft.com") over the organization's display name, since # it's stable, unique, and matches how an MSP/CSP would identify the # customer tenant across multiple runs. Fall back to the display name # when no -TenantId was supplied (e.g. interactive sign-in). $tenantIdentifier = if ($TenantId) { $TenantId } else { $tenantDisplayName } # Strip characters that are reserved in file names on Windows (the # strictest of the platforms this cross-platform script can run on) # so the tenant identifier can be safely embedded in the file name. $safeTenantName = ($tenantIdentifier -replace '[\\/:*?"<>|]', '_').Trim() $csvPath = Join-Path $OutputFolder "UserMfaMethodReport_${safeTenantName}_$timestamp.csv" $htmlPath = Join-Path $OutputFolder "UserMfaMethodReport_${safeTenantName}_$timestamp.html" $report | Select-Object DisplayName, UserPrincipalName, RegisteredMethods, DefaultMethod, RowClass, Notes | Export-Csv -Path $csvPath -NoTypeInformation -Encoding utf8 Export-AuthenticationHtmlReport -Report $report -Path $htmlPath -TenantDisplayName $tenantDisplayName -IsPremiumTenant $isPremium -MethodPolicyStates $methodPolicyStates $countOnly = @($report | Where-Object { $_.RowClass -eq 'SmsVoiceOnly' }).Count $countDefault = @($report | Where-Object { $_.RowClass -eq 'SmsVoiceRegistered' }).Count Write-Host "`n===== SUMMARY =====" -ForegroundColor Magenta Write-Host "Tenant: $tenantDisplayName" Write-Host "Total users reported: $($report.Count)" Write-Host "Red (SMS/Voice only method): $countOnly" -ForegroundColor Red Write-Host "Yellow (SMS/Voice registered alongside other methods): $countDefault" -ForegroundColor Yellow Write-Host "CSV report: $csvPath" Write-Host "HTML report: $htmlPath" if ($methodPolicyStates.Count -gt 0) { Write-Host "`nAllowed authentication methods (tenant policy):" -ForegroundColor Magenta foreach ($methodState in $methodPolicyStates) { $isSmsVoice = $methodState.Id -in @('Sms', 'Voice') $color = if ($methodState.State -ne 'enabled') { 'DarkGray' } elseif ($isSmsVoice) { 'Yellow' } else { 'Green' } Write-Host (" {0,-32} {1}" -f $methodState.Id, $methodState.State) -ForegroundColor $color } } } catch { Write-Verbose "Full stack trace: $($_.ScriptStackTrace)" Write-Error "Script failed: $_" exit 1 } finally { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } exit 0