Files
EntraID/Remove-UserAuthenticationMethod.ps1
T

542 lines
22 KiB
PowerShell

<#
.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