Issue a Temporary Access Pass, or remove a user's MFA methods so they register again

Build a workflow action that issues a Temporary Access Pass (a short-lived, single-use passcode a user signs in with to set up MFA), or removes a user's MFA methods so they must register again, so MSPs and IT teams can handle lost-phone and new-phone requests by asking SuperIT.

Runs in Cloud sandbox (PowerShell)
Uses Microsoft Graph credential for security actions
Suggested approval Require approval from superit:admins for every run
Category Security

Test it with SuperIT before you rely on it

This is an example, not a ready-made action, and its script hasn't been tested end to end in a SuperIT tenant. Changing authentication methods through a delegated credential hasn't been tested with SuperIT either. Before you use it, work through it with SuperIT: ask SuperIT to review the script against your environment and requirements, adjust it together, then test it on a test account or device. Test in the action editor runs the script for real.

Why use an action instead of asking SuperIT

SuperIT can't read a user's authentication methods through the Microsoft 365 connection, let alone change them, and the connection is read-only. Issuing a Temporary Access Pass or removing MFA methods needs an action with a delegated Microsoft Graph credential that has the authentication-method scope. Keep it for engineers: end users who request a run never see the pass.

To check the account itself, such as whether it's enabled or which admin roles it holds, just ask SuperIT in chat.

When to use it

  • "Jane got a new phone and can't approve sign-ins any more."
  • "Give Tom a Temporary Access Pass so he can set up the Authenticator app."
  • "Sam's account was compromised, remove any MFA methods the attacker added."

What the action does

  1. Checks the inputs and that the user exists.
  2. Refuses protected accounts: the admin account behind the credential, any account on the script's $protectedUpns list, and accounts that hold a Microsoft Entra admin role directly or through a role-assignable group. It doesn't see roles that are only eligible through Privileged Identity Management.
  3. To issue a pass: creates a single-use Temporary Access Pass valid for the number of minutes you choose, from 10 to 60 (default 60), and prints it.
  4. To reset MFA: lists the user's authentication methods and removes each one except the password: Microsoft Authenticator, phone, software tokens, FIDO2 security keys, email and Windows Hello for Business. Then it revokes sign-in sessions.
  5. Prints what it removed and anything it couldn't remove.

What it doesn't do: it doesn't act on admin accounts, verify the user's identity (do that before you request it), change Conditional Access or the tenant's MFA policies, or register a new method for the user. Microsoft may refuse to remove the user's default method while other methods remain; run it again to remove the rest.

Before you begin

  • A Microsoft Graph credential for security actions, named M365 Security, with the scopes UserAuthenticationMethod.ReadWrite.All User.RevokeSessions.All User.Read.All GroupMember.Read.All. The admin account that signed in needs Authentication Administrator. Keep this credential separate from the M365 Graph user-administration credential and link it only to security actions. If you also build Revoke sessions for a compromised account on it, add that guide's scopes and roles too. The script reads it as CREDENTIAL_M365_SECURITY_TOKEN. See Automate Microsoft 365 with workflows.
  • To issue passes, the Temporary Access Pass method must be turned on in the tenant's Microsoft Entra authentication methods policy, and its lifetime settings must allow the minutes you ask for. If the policy's minimum lifetime is longer than pass_minutes, or its maximum is shorter, Microsoft refuses the pass.
  • Your protected accounts. Edit $protectedUpns at the top of the script to list your emergency access (break-glass) accounts and any other account this action must never change.
  • An authorization policy with one rule: Specific groups superit:engineers, superit:admins → Require approval from superit:admins. Anyone with a pass or a reset can register their own MFA on the account. Don't add an Anyone rule: the script doesn't receive the requester's identity, so an end user could take over someone else's account.
  • The Engineer or Admin role to build the action in Workflows → Actions. Only admins can create actions in chat.

Build it with SuperIT

Create a workflow action called "Reset MFA or issue Temporary Access Pass".
Runtime: PowerShell in the cloud sandbox. Credential: M365 Security.
Inputs:
- user_upn (email, required)
- operation (select, required, choices: issue_pass, reset_mfa)
- pass_minutes (string, optional, default 60) - 10 to 60
- identity_checked (boolean, required) - label "I have verified this user's identity"
It should refuse the credential's own account, a list of protected accounts and
admin role holders.
For issue_pass, create a single-use temporaryAccessPassMethod (isUsableOnce true)
and print the pass.
For reset_mfa, delete every authentication method except the password, continue
past failures, then revoke sessions, and print what was removed.
Authorization policy: engineers and admins, every run approved by superit:admins.

SuperIT repeats the brief back, drafts the script, checks it, and creates the action as a draft. Review the script in Workflows → Actions, test it, then publish it.

Or build it yourself

Name Type Required Example
user_upn email Yes [email protected]
operation select (choices issue_pass, reset_mfa) Yes issue_pass
pass_minutes string No 60
identity_checked boolean Yes on
# Goal: for one non-admin user, either issue a single-use Temporary Access Pass of at most 60 minutes
# and print it, or remove every authentication method except the password, revoke sessions and
# print what was removed.
$token = $env:CREDENTIAL_M365_SECURITY_TOKEN
$headers = @{ Authorization = "Bearer $token" }
$baseUrl = 'https://graph.microsoft.com/v1.0'
# Emergency access (break-glass) and other accounts this action must never change. List yours.
$protectedUpns = @('[email protected]', '[email protected]')

function Invoke-MfaRecovery {
    param([string]$Upn, [string]$Operation, [string]$PassMinutes, [string]$IdentityChecked)

    $Upn = $Upn.Trim()
    $Operation = $Operation.Trim().ToLower()
    Assert-Inputs -Upn $Upn -Operation $Operation -PassMinutes $PassMinutes -IdentityChecked $IdentityChecked
    $user = Get-TargetUser -Upn $Upn
    Assert-NotProtectedUser -User $user -Upn $Upn

    if ($Operation -eq 'issue_pass') {
        $minutes = if ($PassMinutes) { [int]$PassMinutes } else { 60 }
        $pass = New-TemporaryAccessPass -UserId $user.id -Upn $Upn -Minutes $minutes
        Write-PassSummary -User $user -Upn $Upn -Pass $pass -Minutes $minutes
    } else {
        $outcome = Remove-AuthenticationMethods -UserId $user.id
        Revoke-UserSessions -UserId $user.id -Upn $Upn
        Write-ResetSummary -User $user -Upn $Upn -Outcome $outcome
    }
}

function Assert-Inputs {
    param([string]$Upn, [string]$Operation, [string]$PassMinutes, [string]$IdentityChecked)

    if (-not $token) { throw "Link the M365 Security credential to this action." }
    if ($Upn -notmatch '^[^@\s]+@[^@\s]+$') { throw "user_upn must be a sign-in address." }
    if ($Operation -notin 'issue_pass', 'reset_mfa') { throw "operation must be issue_pass or reset_mfa." }
    if ($IdentityChecked -ne 'true') { throw "identity_checked must be switched on." }
    if ($Operation -eq 'issue_pass' -and $PassMinutes) {
        $minutes = 0
        if (-not [int]::TryParse($PassMinutes, [ref]$minutes)) { throw "pass_minutes must be a number." }
        if ($minutes -lt 10 -or $minutes -gt 60) { throw "pass_minutes must be between 10 and 60." }
    }
}

function Get-TargetUser {
    param([string]$Upn)

    try {
        Invoke-RestMethod -Uri "$baseUrl/users/$([uri]::EscapeDataString($Upn))?`$select=id,displayName,userPrincipalName" -Headers $headers
    } catch {
        Write-Error "Failed to find user '$Upn': $_"
        exit 1
    }
}

function Assert-NotProtectedUser {
    param($User, [string]$Upn)

    if ($protectedUpns -contains $Upn -or $protectedUpns -contains $User.userPrincipalName) {
        throw "$Upn is on this action's protected list. Nothing was changed."
    }
    try {
        $me = Invoke-RestMethod -Uri "$baseUrl/me?`$select=id" -Headers $headers
        $memberOf = @(Get-GraphCollection -Uri "$baseUrl/users/$($User.id)/transitiveMemberOf?`$select=id,isAssignableToRole")
    } catch {
        Write-Error "Failed to check whether '$Upn' is a protected account: $_"
        exit 1
    }
    if ($me.id -eq $User.id) { throw "$Upn is the account behind this action's credential. Nothing was changed." }
    # Without a group-read scope Graph returns groups with only id and type, which would hide a role held through a role-assignable group.
    $unreadable = @($memberOf | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.group' -and -not $_.PSObject.Properties['isAssignableToRole'] })
    if ($unreadable.Count -gt 0) {
        throw "Can't read $Upn's groups to check for admin roles. Add GroupMember.Read.All to the credential and re-authorize it. Nothing was changed."
    }
    $adminRoles = @($memberOf | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.directoryRole' -or $_.isAssignableToRole })
    if ($adminRoles.Count -gt 0) {
        throw "$Upn holds a Microsoft Entra admin role. Manage admin MFA in the Microsoft Entra admin center. Nothing was changed."
    }
}

function New-TemporaryAccessPass {
    param([string]$UserId, [string]$Upn, [int]$Minutes)

    # Single use, so a pass copied from run history or chat can't be replayed after the user signs in.
    $body = @{ lifetimeInMinutes = $Minutes; isUsableOnce = $true } | ConvertTo-Json
    try {
        Invoke-RestMethod -Method Post -Uri "$baseUrl/users/$UserId/authentication/temporaryAccessPassMethods" `
            -Headers $headers -ContentType 'application/json' -Body $body
    } catch {
        Write-Error "Failed to issue a Temporary Access Pass for '$Upn': $_"
        exit 1
    }
}

function Remove-AuthenticationMethods {
    param([string]$UserId)

    # Graph deletes each method type at its own endpoint.
    $paths = @{
        '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod'  = 'microsoftAuthenticatorMethods'
        '#microsoft.graph.phoneAuthenticationMethod'                   = 'phoneMethods'
        '#microsoft.graph.softwareOathAuthenticationMethod'            = 'softwareOathMethods'
        '#microsoft.graph.fido2AuthenticationMethod'                   = 'fido2Methods'
        '#microsoft.graph.emailAuthenticationMethod'                   = 'emailMethods'
        '#microsoft.graph.windowsHelloForBusinessAuthenticationMethod' = 'windowsHelloForBusinessMethods'
        '#microsoft.graph.temporaryAccessPassAuthenticationMethod'     = 'temporaryAccessPassMethods'
    }
    $outcome = @{ removed = @(); failed = @() }
    foreach ($m in Get-AuthenticationMethods -UserId $UserId) {
        $type = $m.'@odata.type'
        if ($type -eq '#microsoft.graph.passwordAuthenticationMethod') { continue }
        if (-not $paths.ContainsKey($type)) { $outcome.failed += "$($type): not handled by this action"; continue }
        try {
            Invoke-RestMethod -Method Delete -Uri "$baseUrl/users/$UserId/authentication/$($paths[$type])/$($m.id)" -Headers $headers | Out-Null
            $outcome.removed += $type.Split('.')[-1]
        } catch {
            $outcome.failed += "$($type.Split('.')[-1]): $($_.ErrorDetails.Message ?? $_.Exception.Message)"
        }
    }
    $outcome
}

function Get-AuthenticationMethods {
    param([string]$UserId)

    Get-GraphCollection -Uri "$baseUrl/users/$UserId/authentication/methods"
}

function Get-GraphCollection {
    param([string]$Uri)

    $items = @()
    while ($Uri) {
        $page = Invoke-RestMethod -Uri $Uri -Headers $headers
        $items += $page.value
        $Uri = $page.'@odata.nextLink'
    }
    $items
}

function Revoke-UserSessions {
    param([string]$UserId, [string]$Upn)

    try {
        Invoke-RestMethod -Method Post -Uri "$baseUrl/users/$UserId/revokeSignInSessions" -Headers $headers | Out-Null
    } catch {
        Write-Error "Failed to revoke sign-in sessions for '$Upn': $_"
        exit 1
    }
}

function Write-PassSummary {
    param([object]$User, [string]$Upn, [object]$Pass, [int]$Minutes)

    Write-Output "=== TEMPORARY ACCESS PASS ISSUED ==="
    Write-Output "User        : $($User.displayName) ($Upn)"
    Write-Output "Pass        : $($Pass.temporaryAccessPass)"
    Write-Output "Valid from  : $($Pass.startDateTime)"
    Write-Output "Valid for   : $Minutes minutes"
    Write-Output "Single use  : $($Pass.isUsableOnce)"
    # $script: so the runner's wrapper sees it; a plain assignment stays local to this function.
    $script:result = @{ user = $Upn; operation = 'issue_pass'; minutes = $Minutes }
}

function Write-ResetSummary {
    param([object]$User, [string]$Upn, [hashtable]$Outcome)

    Write-Output "=== MFA RESET ==="
    Write-Output "User     : $($User.displayName) ($Upn)"
    Write-Output "Sessions : revoked"
    Write-Output "Removed ($($Outcome.removed.Count)) : $($Outcome.removed -join ', ')"
    if ($Outcome.failed) { Write-Output "Not removed : $($Outcome.failed -join '; ')" }
    # $script: so the runner's wrapper sees it; a plain assignment stays local to this function.
    $script:result = @{ user = $Upn; operation = 'reset_mfa'; removed = $Outcome.removed; failed = $Outcome.failed }
}

Invoke-MfaRecovery -Upn $ctx.inputs.user_upn -Operation $ctx.inputs.operation `
    -PassMinutes $ctx.inputs.pass_minutes -IdentityChecked $ctx.inputs.identity_checked

The pass stays in run history

The script prints the pass to Stdout, which is stored with the run. Every engineer and admin in the team, and the approver, can see it in the run's details, and engineers see it in their chat with SuperIT. That's why the script makes it single-use and valid for at most 60 minutes. If the user doesn't use the pass, or you think it's been seen by someone else, remove it in the Microsoft Entra admin center or run reset_mfa, which also removes Temporary Access Passes.

Test it

Test runs for real

Test skips authorization and approval, uses the real credential, and really removes MFA methods or issues a pass that can be used to sign in. Only test on a test user.

The script is untested, so treat your first test runs as debugging, and don't publish the action until a test run does exactly what you expect.

  1. Register the Authenticator app on a test user.
  2. On the action, select Test. Enter the test user, issue_pass, and switch on identity_checked.
  3. Read the This is not a dry run. warning, tick "I understand this is a real execution that may change real systems." and select Start test run.
  4. Check Stdout for the pass and Single use : True, sign in once with it, and check a second sign-in with the same pass fails.
  5. Test again with reset_mfa and check the methods are gone in the Microsoft Entra admin center.

Use it

Publish the action. Engineers ask SuperIT, for example "Jane has a new phone, she's verified, give her a Temporary Access Pass". The run waits for an admin to approve, then SuperIT shows the pass to the engineer to read out to the user straight away, before it expires.

End users who request a run don't see its output, so keep this action for engineers.

For MSPs, publish to managed teams from the Publish dialog; an engineer or admin in each team authorizes its own credential.

Variations

  • If a user needs several sign-ins to finish setting up, such as on a shared device, make a separate copy with isUsableOnce = $false and a longer limit. Keep the limit as short as you can, because a multi-use pass in run history works until it expires.
  • Remove only one kind of method, such as phone numbers, with a select input.
  • Run it from Revoke sessions for a compromised account incidents, after the password reset.