<#PSScriptInfo .VERSION 1.0.0 .GUID 3f6c2b9e-8d41-4f7a-9b3e-2c5d7a1e6f08 .AUTHOR mailpassed.com .COMPANYNAME mailpassed.com .COPYRIGHT (c) 2026 mailpassed.com. MIT License. .TAGS Entra EntraID AzureAD MFA SMS Voice Passkey FIDO2 Microsoft365 Office365 MicrosoftGraph Report .LICENSEURI https://github.com/Donrulezz/Find-SmsOnlyUsers/blob/main/LICENSE .PROJECTURI https://mailpassed.com/entra-sms-voice-retirement .ICONURI .EXTERNALMODULEDEPENDENCIES Microsoft.Graph.Authentication .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES 1.0.0 (27 Oct 2026): first release. Per-user list of who still depends on Microsoft-provided SMS or voice MFA, with the retirement date that applies to each user and a suggested replacement. #> <# .SYNOPSIS Lists every user in Microsoft Entra ID whose only MFA method is SMS or a voice call, and the date on which Microsoft stops delivering those codes for them (February 1, 2027, or July 1, 2027 for Global Administrators and external users). .DESCRIPTION READ-ONLY: this script changes nothing in your tenant. It only sends Microsoft Graph GET requests. Microsoft retires its own SMS and voice delivery for Entra ID MFA. After the applicable date, users whose only available MFA method is SMS or voice must register a passkey at sign-in before they can continue (a blocking prompt, no opt-out). Microsoft's entra-sms-voice-usage-analyzer shows policy scope; this script lists the people. It reads: 1. User registration details (Graph reports/authenticationMethods/userRegistrationDetails): the methods each user has registered, their default method, member or guest. Needs Entra ID P1 or P2 and AuditLog.Read.All. 2. Members of the Global Administrator role (active assignments), who get the later July 1, 2027 date. 3. Whether the SMS and Voice call methods are enabled in the authentication methods policy. Verdict per user: SMS/VOICE ONLY only phone methods (email and security questions don't count): blocking passkey prompt from the user's retirement date DEFAULT IS SMS has another method but SMS or voice is the default: make the other method the default HAS ANOTHER METHOD can be removed from the SMS and Voice policy after testing the other method NO MFA METHOD no MFA method registered at all (not part of this retirement, but worth a look) Not affected no phone method It writes SmsOnlyUsers-.csv and SmsOnlyUsers-.html. .EXAMPLE .\Find-SmsOnlyUsers.ps1 Signs in to Microsoft Graph (AuditLog.Read.All, RoleManagement.Read.Directory, Policy.Read.All) and writes the report. .EXAMPLE .\Find-SmsOnlyUsers.ps1 -SkipConnect -OutputFolder C:\Reports Uses an existing Connect-MgGraph session. .NOTES Version 1.0, 27 Oct 2026. Free to use and share (MIT license). Requires: Microsoft.Graph.Authentication (Install-Module Microsoft.Graph.Authentication -Scope CurrentUser), Entra ID P1 or P2 for user registration details (data can be up to 36 hours old), and a role such as Global Reader, Reports Reader or Security Reader. Guests: an account with #EXT# in its user principal name is treated as an external (B2B) user (July 1, 2027); other guest accounts as internal guests (February 1, 2027). Eligible (PIM) Global Administrators that are not active are not seen. Source: https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sms-voice-retirement Guides per situation: https://mailpassed.com/entra-sms-voice-retirement #> [CmdletBinding()] param( [string]$OutputFolder = (Get-Location).Path, [switch]$SkipConnect ) $GuideUrl = 'https://mailpassed.com/entra-sms-voice-retirement' $GlobalAdminRoleTemplate = '62e90394-69f5-4237-9190-012177145e10' $FebDate = [datetime]'2027-02-01' $JulDate = [datetime]'2027-07-01' $PhonePattern = 'Phone$' $NotMfaPattern = 'Phone$|^email$|securityQuestion' $SmsDefaults = @('sms', 'voiceMobile', 'voiceAlternateMobile', 'voiceOffice') function Get-UserGroup($Upn, $UserType, [bool]$IsGlobalAdmin) { if ($IsGlobalAdmin) { return 'Global Administrator' } if ("$UserType" -eq 'guest') { if ("$Upn" -match '#EXT#') { return 'External user (B2B)' } return 'Internal guest' } return 'Member' } function Get-RetirementDate($Group) { if ($Group -eq 'Global Administrator' -or $Group -eq 'External user (B2B)') { return $JulDate } return $FebDate } function Get-SmsVerdict($Methods, $DefaultMethod) { $m = @($Methods | Where-Object { "$_" }) $phone = @($m | Where-Object { $_ -match $PhonePattern }) $strong = @($m | Where-Object { $_ -notmatch $NotMfaPattern }) if ($phone.Count -and -not $strong.Count) { return 'SMS/VOICE ONLY' } if ($phone.Count -and ($SmsDefaults -contains "$DefaultMethod")) { return 'DEFAULT IS SMS' } if ($phone.Count) { return 'HAS ANOTHER METHOD' } if (-not $strong.Count) { return 'NO MFA METHOD' } return 'Not affected' } function Get-VerdictRank([string]$Verdict) { switch ($Verdict) { 'SMS/VOICE ONLY' { 0 } 'DEFAULT IS SMS' { 1 } 'HAS ANOTHER METHOD' { 2 } 'NO MFA METHOD' { 3 } default { 4 } } } function Get-Suggestion($Verdict, $Group) { if ($Verdict -eq 'Not affected') { return '' } if ($Verdict -eq 'NO MFA METHOD') { return 'Not part of the SMS retirement. Check that your MFA policies cover this account.' } if ($Verdict -eq 'HAS ANOTHER METHOD') { return 'Test the other method, then remove the user from the SMS and Voice call policy scope to stop the passkey nudges.' } if ($Verdict -eq 'DEFAULT IS SMS') { return 'Make the other method the default (or turn on system-preferred MFA), then remove SMS and voice.' } switch ($Group) { 'Global Administrator' { return 'Register a phishing-resistant method (passkey or FIDO2 security key) and review your emergency access accounts.' } 'External user (B2B)' { return 'Microsoft plans passkey support for B2B users by the end of 2026. Plan with the guest how they will sign in after July 1, 2027.' } default { return 'Passkey in Microsoft Authenticator; a FIDO2 security key or Windows Hello for users without a smartphone. TOTP codes avoid the blocking prompt but are not phishing-resistant.' } } } function ConvertTo-SmsRows($Details, $GlobalAdminIds, [datetime]$Today = (Get-Date)) { $ga = @{} foreach ($id in @($GlobalAdminIds)) { if ($id) { $ga["$id"] = $true } } $rows = foreach ($d in @($Details)) { $group = Get-UserGroup $d.userPrincipalName $d.userType ($ga.ContainsKey("$($d.id)")) $verdict = Get-SmsVerdict $d.methodsRegistered $d.userPreferredMethodForSecondaryAuthentication $date = Get-RetirementDate $group $affected = $verdict -in @('SMS/VOICE ONLY', 'DEFAULT IS SMS', 'HAS ANOTHER METHOD') [pscustomobject]@{ UserPrincipalName = "$($d.userPrincipalName)" DisplayName = "$($d.userDisplayName)" Group = $group Methods = (@($d.methodsRegistered) -join ' ') DefaultMethod = "$($d.userPreferredMethodForSecondaryAuthentication)" Verdict = $verdict RetirementDate = $(if ($affected) { $date.ToString('yyyy-MM-dd') } else { '' }) DaysLeft = $(if ($affected) { [math]::Max(0, [int][math]::Ceiling(($date - $Today.Date).TotalDays)) } else { '' }) Suggestion = Get-Suggestion $verdict $group } } return @($rows | Sort-Object @{ Expression = { Get-VerdictRank $_.Verdict } }, RetirementDate, UserPrincipalName) } function Get-SmsSummary($Rows) { $only = @($Rows | Where-Object { $_.Verdict -eq 'SMS/VOICE ONLY' }) return [pscustomobject]@{ Users = @($Rows).Count SmsOnlyFeb = @($only | Where-Object { $_.RetirementDate -eq $FebDate.ToString('yyyy-MM-dd') }).Count SmsOnlyJul = @($only | Where-Object { $_.RetirementDate -eq $JulDate.ToString('yyyy-MM-dd') }).Count DefaultSms = @($Rows | Where-Object { $_.Verdict -eq 'DEFAULT IS SMS' }).Count OtherMethod = @($Rows | Where-Object { $_.Verdict -eq 'HAS ANOTHER METHOD' }).Count NoMfa = @($Rows | Where-Object { $_.Verdict -eq 'NO MFA METHOD' }).Count } } function ConvertTo-SmsHtmlReport($Rows, $Summary, $PolicyNotes, $Sources) { $enc = { param($s) [System.Net.WebUtility]::HtmlEncode("$s") } $date = (Get-Date).ToString('yyyy-MM-dd HH:mm') $trs = foreach ($r in $Rows) { if ($r.Verdict -eq 'Not affected') { continue } $cls = '' if ($r.Verdict -eq 'SMS/VOICE ONLY') { $cls = 'bad' } elseif ($r.Verdict -eq 'DEFAULT IS SMS') { $cls = 'warn' } "$(& $enc $r.UserPrincipalName)
$(& $enc $r.DisplayName)$(& $enc $r.Group)" + "$(& $enc $r.Verdict)$(& $enc $r.RetirementDate)$(& $enc $r.Methods)$(& $enc $r.Suggestion)" } $notes = ($PolicyNotes | ForEach-Object { "
  • $(& $enc $_)
  • " }) -join "`n" return @" SMS and voice MFA users - $date

    Who still depends on SMS or voice MFA

    $date · sources: $(& $enc ($Sources -join ', ')) · read-only scan

    $($Summary.SmsOnlyFeb) user(s) have only SMS or voice and get the blocking passkey prompt from February 1, 2027; $($Summary.SmsOnlyJul) from July 1, 2027 (Global Administrators, external users). $($Summary.DefaultSms) more have SMS or voice as their default method, $($Summary.OtherMethod) have another method as well, $($Summary.NoMfa) have no MFA method. $($Summary.Users) users checked.
    $($trs -join "`n")
    UserGroupVerdictDateMethods registeredSuggested next step

    Guides per situation (no smartphone, shared PCs, guests, admins): $GuideUrl

    "@ } function Get-AllPages($Uri) { $items = New-Object System.Collections.Generic.List[object] while ($Uri) { $page = Invoke-MgGraphRequest -Method GET -Uri $Uri -OutputType PSObject foreach ($v in @($page.value)) { $items.Add($v) } $Uri = $page.'@odata.nextLink' } return $items } function Invoke-Main { if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) { throw 'Install the Microsoft Graph module first: Install-Module Microsoft.Graph.Authentication -Scope CurrentUser' } Import-Module Microsoft.Graph.Authentication if (-not $SkipConnect) { Connect-MgGraph -Scopes 'AuditLog.Read.All', 'RoleManagement.Read.Directory', 'Policy.Read.All' -NoWelcome } $sources = New-Object System.Collections.Generic.List[string] Write-Host 'Reading user registration details...' try { $details = Get-AllPages 'v1.0/reports/authenticationMethods/userRegistrationDetails' } catch { throw ('Could not read user registration details (' + $_.Exception.Message + '). This report needs Entra ID P1 or P2, ' + 'the AuditLog.Read.All permission and a role such as Global Reader, Reports Reader or Security Reader.') } $sources.Add("user registration details ($($details.Count) users)") $gaIds = @() try { $uri = "v1.0/directoryRoles(roleTemplateId='$GlobalAdminRoleTemplate')/members?`$select=id" $gaIds = @(Get-AllPages $uri | ForEach-Object { $_.id }) $sources.Add("Global Administrators ($($gaIds.Count) active)") } catch { Write-Warning ('Could not read Global Administrator members (' + $_.Exception.Message + '); everyone gets the February date.') } $notes = New-Object System.Collections.Generic.List[string] foreach ($m in 'Sms', 'Voice') { try { $cfg = Invoke-MgGraphRequest -Method GET -Uri "v1.0/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/$m" -OutputType PSObject $notes.Add("Authentication methods policy: $m is $($cfg.state).") } catch { $notes.Add("Authentication methods policy: $m state not read ($($_.Exception.Message)).") } } $notes.Add('Users moved to a telephony provider configured in Microsoft Security Store are not blocked; this script cannot see that setting.') $rows = ConvertTo-SmsRows $details $gaIds $summary = Get-SmsSummary $rows $stamp = (Get-Date).ToString('yyyy-MM-dd') $csv = Join-Path $OutputFolder "SmsOnlyUsers-$stamp.csv" $html = Join-Path $OutputFolder "SmsOnlyUsers-$stamp.html" $rows | Export-Csv -Path $csv -NoTypeInformation -Encoding UTF8 ConvertTo-SmsHtmlReport $rows $summary $notes $sources | Out-File -FilePath $html -Encoding utf8 Write-Host '' Write-Host "$($summary.SmsOnlyFeb) user(s) are SMS/voice only with the February 1, 2027 date, $($summary.SmsOnlyJul) with July 1, 2027." Write-Host "$($summary.DefaultSms) more have SMS or voice as their default method. Report: $html" Write-Host "Guides per situation: $GuideUrl" } # Dot-sourcing (". .\Find-SmsOnlyUsers.ps1") only loads the functions, for testing. if ($MyInvocation.InvocationName -ne '.') { Invoke-Main }