<# .SYNOPSIS Removes a chosen type of registered authentication method (phone, Microsoft Authenticator, FIDO2, etc.) from Entra ID users in bulk. .DESCRIPTION Connects to Microsoft Graph, lets the caller pick which authentication method type to remove (interactively from a menu, or via -MethodType for unattended runs), then removes every registered entry of that type for each in-scope user. Safety: for MFA-capable method types, if the selected type is a user's only remaining MFA-capable method, removing it would leave them unable to complete MFA at all. This script never does that - such users are automatically skipped (never modified) and reported separately so an admin can register them another method first. Temporary Access Pass does not count as a substitute method for this check, since it is a one-time/short-lived bootstrap credential, not an ongoing MFA method. Email is not an MFA method at all (SSPR only), so removing it is never blocked by this check. .PARAMETER TenantId Optional tenant ID or verified domain name to sign in to. If omitted, Connect-MgGraph uses the interactive account picker / default tenant. .PARAMETER MethodType Which authentication method type to remove: Phone, MicrosoftAuthenticator, Fido2, WindowsHelloForBusiness, SoftwareOath, TemporaryAccessPass, PlatformCredential, or Email. Omit to choose from an interactive menu instead. .PARAMETER UserPrincipalName Optional. Limit the run to these specific users instead of the whole tenant. .PARAMETER IncludeGuests Include guest (B2B) accounts. By default only enabled Member users are processed. .PARAMETER OutputFolder Folder to write the results CSV to. Defaults to the current working directory. .EXAMPLE .\Remove-UserAuthenticationMethod.ps1 -WhatIf Signs in, prompts for which method type to remove, lists every enabled Member user in the tenant, and shows what would be removed (and what would be skipped to avoid leaving a user with no MFA method) without changing anything. .EXAMPLE .\Remove-UserAuthenticationMethod.ps1 -MethodType Phone -TenantId 'contoso.onmicrosoft.com' Signs in to the specified tenant and, after a single confirmation prompt, removes every eligible user's phone authentication method(s). .EXAMPLE .\Remove-UserAuthenticationMethod.ps1 -MethodType Phone -UserPrincipalName 'alice@contoso.com','bob@contoso.com' -Confirm:$false Removes phone authentication methods for just the two named users, without even the single upfront confirmation prompt - use only once a prior -WhatIf run against the same scope has already been reviewed. .NOTES Author: Petr Stepan Created: 2026-07-29 Version: 1.1.0 Changelog: 1.0.0 - Initial version (phone/SMS-Voice authentication methods only) 1.1.0 - Generalized to any removable authentication method type, selected interactively from a menu or via the new -MethodType parameter #> #Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Identity.SignIns, Microsoft.Graph.Users [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter(Mandatory = $false, HelpMessage = 'Tenant ID or verified domain name to connect to.')] [ValidateNotNullOrEmpty()] [string] $TenantId, [Parameter(Mandatory = $false, HelpMessage = 'Authentication method type to remove. Omit to choose interactively from a menu.')] [ValidateSet('Phone', 'MicrosoftAuthenticator', 'Fido2', 'WindowsHelloForBusiness', 'SoftwareOath', 'TemporaryAccessPass', 'PlatformCredential', 'Email')] [string] $MethodType, [Parameter(Mandatory = $false, HelpMessage = 'Limit the run to these specific users instead of the whole tenant.')] [string[]] $UserPrincipalName, [Parameter(Mandatory = $false)] [switch] $IncludeGuests, [Parameter(Mandatory = $false, HelpMessage = 'Folder to write the results CSV to.')] [ValidateNotNullOrEmpty()] [string] $OutputFolder = (Get-Location).Path ) # --- 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. $ErrorActionPreference = 'Stop' # Graph scopes needed: read users, and read/remove their authentication methods. $script:RequiredGraphScopes = @( 'User.Read.All' 'UserAuthenticationMethod.ReadWrite.All' ) # Every authentication method type this script knows how to remove, and how to # remove it: the Graph @odata.type value(s) that identify an entry of this type, # the dedicated Remove-Mg cmdlet for it (each method type has its own - there is no # single generic "remove any method" cmdlet), and that cmdlet's ID parameter name. # IsMfaCapable marks whether an entry of this type counts towards the "does this # user have another MFA method left" safety check below - Temporary Access Pass # (one-time/short-lived bootstrap credential) and Email (SSPR only, not MFA) do not. $script:MethodTypeCatalog = @( [PSCustomObject]@{ Name = 'Phone' DisplayName = 'Phone (SMS / Voice call)' OdataTypes = @('#microsoft.graph.phoneAuthenticationMethod') RemoveCmdlet = 'Remove-MgUserAuthenticationPhoneMethod' IdParameter = 'PhoneAuthenticationMethodId' IsMfaCapable = $true } [PSCustomObject]@{ Name = 'MicrosoftAuthenticator' DisplayName = 'Microsoft Authenticator (push / passwordless)' # Both the classic push method and the passwordless/passkey-in-Authenticator # variant are registrations of the same underlying device and are removed # through the same cmdlet - there is no separate "passwordless" delete API. OdataTypes = @( '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod' '#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod' ) RemoveCmdlet = 'Remove-MgUserAuthenticationMicrosoftAuthenticatorMethod' IdParameter = 'MicrosoftAuthenticatorAuthenticationMethodId' IsMfaCapable = $true } [PSCustomObject]@{ Name = 'Fido2' DisplayName = 'FIDO2 / Passkey' OdataTypes = @('#microsoft.graph.fido2AuthenticationMethod') RemoveCmdlet = 'Remove-MgUserAuthenticationFido2Method' IdParameter = 'Fido2AuthenticationMethodId' IsMfaCapable = $true } [PSCustomObject]@{ Name = 'WindowsHelloForBusiness' DisplayName = 'Windows Hello for Business' OdataTypes = @('#microsoft.graph.windowsHelloForBusinessAuthenticationMethod') RemoveCmdlet = 'Remove-MgUserAuthenticationWindowsHelloForBusinessMethod' IdParameter = 'WindowsHelloForBusinessAuthenticationMethodId' IsMfaCapable = $true } [PSCustomObject]@{ Name = 'SoftwareOath' DisplayName = 'OATH software token' OdataTypes = @('#microsoft.graph.softwareOathAuthenticationMethod') RemoveCmdlet = 'Remove-MgUserAuthenticationSoftwareOathMethod' IdParameter = 'SoftwareOathAuthenticationMethodId' IsMfaCapable = $true } [PSCustomObject]@{ Name = 'TemporaryAccessPass' DisplayName = 'Temporary Access Pass' OdataTypes = @('#microsoft.graph.temporaryAccessPassAuthenticationMethod') RemoveCmdlet = 'Remove-MgUserAuthenticationTemporaryAccessPassMethod' IdParameter = 'TemporaryAccessPassAuthenticationMethodId' IsMfaCapable = $false } [PSCustomObject]@{ Name = 'PlatformCredential' DisplayName = 'Platform credential (e.g. Mac)' OdataTypes = @('#microsoft.graph.platformCredentialAuthenticationMethod') RemoveCmdlet = 'Remove-MgUserAuthenticationPlatformCredentialMethod' IdParameter = 'PlatformCredentialAuthenticationMethodId' IsMfaCapable = $true } [PSCustomObject]@{ Name = 'Email' DisplayName = 'Email (SSPR only)' OdataTypes = @('#microsoft.graph.emailAuthenticationMethod') RemoveCmdlet = 'Remove-MgUserAuthenticationEmailMethod' IdParameter = 'EmailAuthenticationMethodId' IsMfaCapable = $false } ) # Flat list of every @odata.type that counts as an ongoing MFA-capable method, # derived from the catalog above so the two can never drift out of sync. $script:MfaCapableOdataTypes = @($script:MethodTypeCatalog | Where-Object { $_.IsMfaCapable } | ForEach-Object { $_.OdataTypes }) # --- Functions --------------------------------------------------------- function Connect-TenantGraph { <# .SYNOPSIS Ensures an authenticated Microsoft Graph session with the required scopes. .DESCRIPTION Read-only from the script's perspective (it only inspects/creates an auth session), so it does not need ShouldProcess. Reuses an existing connection if it already grants every required scope, to avoid re-prompting unnecessarily. .PARAMETER TenantId Optional tenant ID or verified domain name to connect to. .EXAMPLE Connect-TenantGraph -TenantId 'contoso.onmicrosoft.com' #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [string] $TenantId ) $context = Get-MgContext $hasAllScopes = $context -and (-not (Compare-Object -ReferenceObject $script:RequiredGraphScopes -DifferenceObject $context.Scopes | Where-Object { $_.SideIndicator -eq '<=' })) if (-not $hasAllScopes) { $connectParams = @{ Scopes = $script:RequiredGraphScopes NoWelcome = $true } if ($TenantId) { $connectParams['TenantId'] = $TenantId } Connect-MgGraph @connectParams } else { Write-Verbose 'Reusing existing Microsoft Graph connection with sufficient scopes.' } } function Select-AuthenticationMethodType { <# .SYNOPSIS Resolves which authentication method type this run should remove. .DESCRIPTION Interactive/read-only - does not modify anything, so it does not need ShouldProcess. Returns the matching catalog entry for -MethodType if one was given; otherwise prints a numbered menu and prompts until a valid choice is made. .PARAMETER MethodType Optional method type name already chosen by the caller (skips the prompt). .EXAMPLE Select-AuthenticationMethodType -MethodType 'Phone' .EXAMPLE Select-AuthenticationMethodType #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [string] $MethodType ) if ($MethodType) { $selected = $script:MethodTypeCatalog | Where-Object { $_.Name -eq $MethodType } if (-not $selected) { throw "Unknown method type '$MethodType'." } return $selected } Write-Host "`nWhich authentication method type should be removed?" -ForegroundColor Cyan for ($index = 0; $index -lt $script:MethodTypeCatalog.Count; $index++) { Write-Host (" {0}. {1}" -f ($index + 1), $script:MethodTypeCatalog[$index].DisplayName) } $chosenIndex = -1 do { $choice = Read-Host "`nEnter a number (1-$($script:MethodTypeCatalog.Count))" $parsedNumber = 0 $isValidNumber = [int]::TryParse($choice, [ref]$parsedNumber) if ($isValidNumber -and $parsedNumber -ge 1 -and $parsedNumber -le $script:MethodTypeCatalog.Count) { $chosenIndex = $parsedNumber - 1 } else { Write-Warning 'Invalid choice - please enter a number from the list.' } } while ($chosenIndex -lt 0) return $script:MethodTypeCatalog[$chosenIndex] } function Get-TenantUserInventory { <# .SYNOPSIS Returns the users this run should process. .DESCRIPTION Read-only; does not modify anything, so it does not need ShouldProcess. Returns either the specific users named via -UserPrincipalName, or every enabled Member user in the tenant (guests included only when -IncludeGuests is set). .PARAMETER UserPrincipalName Optional. Specific users to return instead of the whole tenant. .PARAMETER IncludeGuests Include guest (B2B) accounts when returning the whole tenant. .EXAMPLE Get-TenantUserInventory .EXAMPLE Get-TenantUserInventory -UserPrincipalName 'alice@contoso.com' #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [string[]] $UserPrincipalName, [Parameter(Mandatory = $false)] [switch] $IncludeGuests ) if ($UserPrincipalName) { $users = [System.Collections.Generic.List[object]]::new() foreach ($upn in $UserPrincipalName) { try { $users.Add((Get-MgUser -UserId $upn -Property Id, DisplayName, UserPrincipalName -ErrorAction Stop)) } catch { Write-Warning "Could not find user '$upn': $_" } } return @($users) } $userFilter = "accountEnabled eq true" if (-not $IncludeGuests) { $userFilter += " and userType eq 'Member'" } try { return @(Get-MgUser -Filter $userFilter -ConsistencyLevel eventual -CountVariable usersFound ` -Property Id, DisplayName, UserPrincipalName -All -PageSize 500 -Sort DisplayName -ErrorAction Stop) } catch { Write-Error "Failed to retrieve users from Entra ID: $_" throw } } function Remove-UserAuthenticationMethodEntry { <# .SYNOPSIS Removes every registered authentication method of the selected type for one user, unless doing so would leave them with no MFA-capable method at all. .DESCRIPTION Each matching entry is removed independently, gated behind ShouldProcess, so -WhatIf reports exactly what would be removed and a failure removing one entry does not prevent the others from being attempted. .PARAMETER UserId The Entra ID object ID of the user. .PARAMETER UserPrincipalName Used only for readable ShouldProcess prompts/log messages. .PARAMETER MethodTypeInfo The catalog entry (an item from $script:MethodTypeCatalog) describing which method type to remove and which Graph cmdlet/parameter to use. .EXAMPLE Remove-UserAuthenticationMethodEntry -UserId $id -UserPrincipalName 'alice@contoso.com' -MethodTypeInfo $methodTypeInfo #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $UserId, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $UserPrincipalName, [Parameter(Mandatory = $true)] [ValidateNotNull()] [PSCustomObject] $MethodTypeInfo ) [array]$allMethods = @(Get-MgUserAuthenticationMethod -UserId $UserId -ErrorAction Stop) [array]$matchingMethods = @($allMethods | Where-Object { $_.AdditionalProperties['@odata.type'] -in $MethodTypeInfo.OdataTypes }) if ($matchingMethods.Count -eq 0) { return [PSCustomObject]@{ Status = 'NoMatchingMethod' RemovedCount = 0 Errors = [System.Collections.Generic.List[string]]::new() } } if ($MethodTypeInfo.IsMfaCapable) { [array]$remainingMfaMethods = @($allMethods | Where-Object { $_.AdditionalProperties['@odata.type'] -in $script:MfaCapableOdataTypes -and $_.AdditionalProperties['@odata.type'] -notin $MethodTypeInfo.OdataTypes }) if ($remainingMfaMethods.Count -eq 0) { # Removing this would leave the user with zero MFA-capable methods - # never do that; flag for manual remediation instead. return [PSCustomObject]@{ Status = 'Skipped' RemovedCount = 0 Errors = [System.Collections.Generic.List[string]]::new() } } } $errors = [System.Collections.Generic.List[string]]::new() $removedCount = 0 foreach ($method in $matchingMethods) { $target = "$UserPrincipalName ($($MethodTypeInfo.DisplayName))" if ($PSCmdlet.ShouldProcess($target, "Remove $($MethodTypeInfo.DisplayName) authentication method")) { try { $removeParams = @{ UserId = $UserId ErrorAction = 'Stop' } $removeParams[$MethodTypeInfo.IdParameter] = $method.Id & $MethodTypeInfo.RemoveCmdlet @removeParams $removedCount++ } catch { $errors.Add("Failed to remove entry: $_") Write-Warning "Failed to remove $($MethodTypeInfo.DisplayName) method for '$UserPrincipalName': $_" } } } $status = if ($errors.Count -gt 0) { 'Failed' } else { 'Removed' } return [PSCustomObject]@{ Status = $status RemovedCount = $removedCount Errors = $errors } } # --- Main -------------------------------------------------------------- $results = [System.Collections.Generic.List[PSCustomObject]]::new() $hasFailures = $false try { Write-Verbose 'Connecting to Microsoft Graph.' Connect-TenantGraph -TenantId $TenantId $methodTypeInfo = Select-AuthenticationMethodType -MethodType $MethodType Write-Host "Selected method type: $($methodTypeInfo.DisplayName)" -ForegroundColor Cyan # One upfront confirmation for the whole run, instead of a separate interactive # prompt per user. Skipped entirely under -WhatIf (nothing to confirm for a # preview) and honored if the caller explicitly passed -Confirm:$false. Every # individual removal below then runs with -Confirm:$false, since the decision to # proceed has already been made here. if (-not $WhatIfPreference) { $confirmExplicitlySuppressed = $PSBoundParameters.ContainsKey('Confirm') -and -not $PSBoundParameters['Confirm'] $mfaWarning = if ($methodTypeInfo.IsMfaCapable) { 'Users for whom this is their only remaining MFA-capable method are skipped automatically. ' } else { '' } if (-not $confirmExplicitlySuppressed -and -not $PSCmdlet.ShouldContinue( "This will permanently remove '$($methodTypeInfo.DisplayName)' authentication methods from every eligible user in scope. $mfaWarning" + 'This cannot be undone by this script - continue?', 'Confirm authentication method removal')) { Write-Warning 'Aborted by user - no authentication methods were removed.' exit 0 } } Write-Verbose 'Retrieving user accounts.' $users = @(Get-TenantUserInventory -UserPrincipalName $UserPrincipalName -IncludeGuests:$IncludeGuests) Write-Host "Found $($users.Count) user account(s) to process." -ForegroundColor Cyan [int]$i = 0 foreach ($user in $users) { $i++ Write-Progress -Activity "Removing $($methodTypeInfo.DisplayName) authentication methods" -Status "$($user.DisplayName) ($i of $($users.Count))" -PercentComplete (($i / $users.Count) * 100) $resultRow = [PSCustomObject]@{ DisplayName = $user.DisplayName UserPrincipalName = $user.UserPrincipalName Status = 'Unknown' RemovedCount = 0 ErrorDetail = $null } try { # Forward -WhatIf explicitly rather than relying on $WhatIfPreference to # cascade implicitly - keeps the dry-run behavior obvious and independent # of scope-inheritance quirks. -Confirm:$false because the single upfront # confirmation above already covers this whole run. $removalResult = Remove-UserAuthenticationMethodEntry -UserId $user.Id -UserPrincipalName $user.UserPrincipalName ` -MethodTypeInfo $methodTypeInfo -WhatIf:$WhatIfPreference -Confirm:$false $resultRow.Status = $removalResult.Status $resultRow.RemovedCount = $removalResult.RemovedCount if ($removalResult.Errors.Count -gt 0) { $resultRow.ErrorDetail = $removalResult.Errors -join '; ' } } catch { # Do not let one user's failure abort the whole tenant run. $resultRow.Status = 'Failed' $resultRow.ErrorDetail = "$_" Write-Warning "Skipping '$($user.UserPrincipalName)' after failure: $_" } if ($resultRow.Status -eq 'Failed') { $hasFailures = $true } $results.Add($resultRow) } Write-Progress -Activity "Removing $($methodTypeInfo.DisplayName) authentication methods" -Completed if (-not (Test-Path -Path $OutputFolder)) { New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null } $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' $csvPath = Join-Path $OutputFolder "RemoveUserAuthMethod_$($methodTypeInfo.Name)_$timestamp.csv" # Only surface users the script acted on (or would act on) or flagged for manual # review - users with no matching method registered at all are deliberately # omitted to keep the report focused on what actually needs attention. $reportableResults = @($results | Where-Object { $_.Status -ne 'NoMatchingMethod' }) $reportableResults | Export-Csv -Path $csvPath -NoTypeInformation -Encoding utf8 $removedUserCount = @($results | Where-Object { $_.Status -eq 'Removed' }).Count $skippedUserCount = @($results | Where-Object { $_.Status -eq 'Skipped' }).Count $failedUserCount = @($results | Where-Object { $_.Status -eq 'Failed' }).Count $totalRemovedMethods = ($results | Measure-Object -Property RemovedCount -Sum).Sum if (-not $totalRemovedMethods) { $totalRemovedMethods = 0 } Write-Host "`n===== SUMMARY =====" -ForegroundColor Magenta Write-Host "Method type: $($methodTypeInfo.DisplayName)" Write-Host "Users processed: $($results.Count)" Write-Host "Users with method(s) removed: $removedUserCount" -ForegroundColor Green Write-Host "Methods removed in total: $totalRemovedMethods" -ForegroundColor Green Write-Host "Users skipped (only remaining MFA method): $skippedUserCount" -ForegroundColor Yellow Write-Host "Users failed: $failedUserCount" -ForegroundColor Red Write-Host "Results CSV: $csvPath" } catch { Write-Error "Script failed: $_" exit 1 } finally { # No persistent sessions to release - Connect-MgGraph tokens are cached by the SDK. } if ($hasFailures) { exit 1 } exit 0