Find-SmsOnlyUsers.ps1

<#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-<date>.csv and SmsOnlyUsers-<date>.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' }
        "<tr class='$cls'><td>$(& $enc $r.UserPrincipalName)<br><span class='muted'>$(& $enc $r.DisplayName)</span></td><td>$(& $enc $r.Group)</td>" +
        "<td>$(& $enc $r.Verdict)</td><td>$(& $enc $r.RetirementDate)</td><td>$(& $enc $r.Methods)</td><td>$(& $enc $r.Suggestion)</td></tr>"
    }
    $notes = ($PolicyNotes | ForEach-Object { "<li>$(& $enc $_)</li>" }) -join "`n"
    return @"
<!doctype html><html><head><meta charset="utf-8"><title>SMS and voice MFA users - $date</title>
<style>body{font-family:Segoe UI,Arial,sans-serif;margin:24px;color:#1f2937}table{border-collapse:collapse;width:100%;font-size:14px}
td,th{border:1px solid #d1d5db;padding:6px 8px;text-align:left;vertical-align:top}th{background:#f3f4f6}
tr.bad td{background:#fef2f2}tr.warn td{background:#fffbeb}.muted{color:#6b7280;font-size:12px}.box{background:#eff6ff;padding:10px 14px;border-radius:6px}</style></head><body>
<h1>Who still depends on SMS or voice MFA</h1>
<p class="muted">$date &middot; sources: $(& $enc ($Sources -join ', ')) &middot; read-only scan</p>
<div class="box"><b>$($Summary.SmsOnlyFeb)</b> user(s) have only SMS or voice and get the blocking passkey prompt from <b>February 1, 2027</b>;
<b>$($Summary.SmsOnlyJul)</b> from <b>July 1, 2027</b> (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.</div>
<ul>$notes</ul>
<table><tr><th>User</th><th>Group</th><th>Verdict</th><th>Date</th><th>Methods registered</th><th>Suggested next step</th></tr>
$($trs -join "`n")
</table>
<p class="muted">Guides per situation (no smartphone, shared PCs, guests, admins): $GuideUrl</p>
</body></html>
"@

}

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 }