Block sign-in, replace the password and revoke all sessions of a compromised Microsoft 365 account

Build a workflow action that optionally blocks a compromised Microsoft 365 account and replaces its password, then signs it out everywhere, so MSPs and IT teams can contain an account takeover in one quick step 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. 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 reads Microsoft 365 through your Microsoft 365 connection, but that connection is read-only, so SuperIT can't change anything in the tenant itself. Revoking sessions, blocking sign-in and resetting a password are writes, which only an action with a delegated Microsoft Graph credential can make. This action is the incident-response lockdown in one step. SuperIT can spot a suspicious inbox rule while it investigates, but only an action can act on it: see Remove a suspicious inbox rule.

To check the account's groups, admin roles and inbox rules, just ask SuperIT in chat.

When to use it

  • "Jane clicked a phishing link and entered her password, lock her account down."
  • "Sam's mailbox is sending spam, kill his sessions and reset his password."
  • "Revoke all sessions for [email protected]."

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. If block_sign_in is on, blocks sign-in.
  4. If reset_password is on, replaces the password with a long random one that nobody is told, so the attacker's copy stops working. Accounts synced from on-premises Active Directory are skipped for the block and password steps, with a message.
  5. Revokes all sign-in sessions and refresh tokens, so every app and device must sign in again. This comes last, as Microsoft recommends: revoking first would let an attacker who knows the password sign straight back in.
  6. Prints a summary. It never prints a password.

Revoking sessions alone doesn't stop an attacker who knows the password from signing in again, so switch on reset_password (and usually block_sign_in) for a real compromise.

When the account is clean, give the user a temporary password with Reset a Microsoft 365 password and unblock them with Block or unblock sign-in.

What it doesn't do: it doesn't act on admin accounts (contain those in the Microsoft Entra admin center), remove malicious inbox rules (Remove a suspicious inbox rule does) or mail forwarding, remove MFA methods an attacker added, check sign-in logs, or scan devices. Do those as follow-up steps (see Use it).

Before you begin

  • A Microsoft Graph credential for security actions, named M365 Security, with the scopes User.RevokeSessions.All User.EnableDisableAccount.All User-PasswordProfile.ReadWrite.All User.Read.All GroupMember.Read.All. The admin account that signed in needs User Administrator. Keep this credential separate from the M365 Graph user-administration credential and link it only to security actions. The script reads it as CREDENTIAL_M365_SECURITY_TOKEN. See Automate Microsoft 365 with workflows.
  • These scopes come from Microsoft's documentation and haven't been tested with SuperIT. If a step fails with Authorization_RequestDenied or 403, see If something goes wrong.
  • 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. Approval adds a short wait, so make sure several admins can approve. Don't add an Anyone rule: the script doesn't receive the requester's identity, so an end user could lock someone else out.
  • 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 "Lock down compromised Microsoft 365 account".
Runtime: PowerShell in the cloud sandbox. Credential: M365 Security.
Inputs:
- user_upn (email, required)
- block_sign_in (boolean, optional)
- reset_password (boolean, optional)
It should refuse the credential's own account, a list of protected accounts and
admin role holders. Then block sign-in and replace the password with a random one
it doesn't print, if those are on, skipping those two steps for on-premises synced
accounts. Revoke all sign-in sessions last, and print a summary without any password.
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]
block_sign_in boolean No on
reset_password boolean No on

The main function at the top lists every step in order; each function below it does one of them, and the last line runs it with the action's inputs.

# Goal: contain a compromised Microsoft 365 account: optionally block sign-in and set an unknown
# password first, then revoke every sign-in session, and output the steps taken. Prints no secrets.
$token = $env:CREDENTIAL_M365_SECURITY_TOKEN
if (-not $token) { throw "Link the M365 Security credential to this action." }
$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-AccountLockdown {
    param([string]$UserUpn, [string]$BlockSignIn, [string]$ResetPassword)
    $upn = $UserUpn.Trim()
    Assert-Inputs -Upn $upn

    $user = Get-TargetUser -Upn $upn
    Assert-NotProtectedUser -User $user -Upn $upn
    $steps = @()

    if ($BlockSignIn -eq 'true') {
        if ($user.onPremisesSyncEnabled) {
            $steps += 'Sign-in NOT blocked: account is synced from on-premises AD, block it there'
        } else {
            Disable-UserSignIn -UserId $user.id -Upn $upn
            $steps += 'Sign-in blocked'
        }
    }

    if ($ResetPassword -eq 'true') {
        if ($user.onPremisesSyncEnabled) {
            $steps += 'Password NOT reset: account is synced from on-premises AD, reset it there'
        } else {
            Set-UnknownPassword -UserId $user.id -Upn $upn
            $steps += 'Password replaced with one nobody knows'
        }
    }

    # Revoke last: revoking first would let an attacker who has the password sign straight back in.
    Revoke-UserSessions -UserId $user.id -Upn $upn
    $steps += 'Sessions revoked'

    Write-LockdownSummary -User $user -Upn $upn -Steps $steps
    # $script: because the runner returns the script-level $result, not a function's local copy.
    $script:result = @{ user = $upn; steps = $steps }
}

function Assert-Inputs {
    param([string]$Upn)
    if ($Upn -notmatch '^[^@\s]+@[^@\s]+$') { throw "user_upn must be a sign-in address." }
}

function Get-TargetUser {
    param([string]$Upn)
    try {
        Invoke-RestMethod -Uri "$baseUrl/users/$([uri]::EscapeDataString($Upn))?`$select=id,displayName,userPrincipalName,onPremisesSyncEnabled" `
            -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. Contain admin accounts in the Microsoft Entra admin center. Nothing was changed."
    }
}

function Disable-UserSignIn {
    param([string]$UserId, [string]$Upn)
    try {
        Invoke-RestMethod -Method Patch -Uri "$baseUrl/users/$UserId" -Headers $headers -ContentType 'application/json' `
            -Body (@{ accountEnabled = $false } | ConvertTo-Json) | Out-Null
    } catch {
        Write-Error "Failed to block sign-in for '$Upn': $_"
        exit 1
    }
}

function Set-UnknownPassword {
    param([string]$UserId, [string]$Upn)
    $password = [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(24)) + 'a1!'
    $body = @{ passwordProfile = @{ password = $password; forceChangePasswordNextSignIn = $true } } | ConvertTo-Json
    try {
        Invoke-RestMethod -Method Patch -Uri "$baseUrl/users/$UserId" -Headers $headers `
            -ContentType 'application/json' -Body $body | Out-Null
    } catch {
        Write-Error "Failed to reset the password for '$Upn': $_"
        exit 1
    }
}

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 sessions for '$Upn': $_"
        exit 1
    }
}

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 Write-LockdownSummary {
    param($User, [string]$Upn, [string[]]$Steps)
    Write-Output "=== ACCOUNT LOCKED DOWN ==="
    Write-Output "User : $($User.displayName) ($Upn)"
    $Steps | ForEach-Object { Write-Output "Step : $_" }
}

Invoke-AccountLockdown -UserUpn $ctx.inputs.user_upn -BlockSignIn $ctx.inputs.block_sign_in `
    -ResetPassword $ctx.inputs.reset_password

Test it

Test runs for real

Test skips authorization and approval, uses the real M365 Security credential, and really blocks the account, changes its password and signs it out. 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. Sign in to Outlook on the web as a test user.
  2. On the action, select Test, enter the test user's address and switch on both options.
  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 lists the steps in order: block, password, then sessions revoked. Within a few minutes the test user's Outlook session should ask them to sign in again, and their old password should no longer work.

Use it

Publish the action. In an incident, an engineer asks SuperIT "Jane's account is compromised, block sign-in, reset her password and revoke her sessions". The run waits for an admin to approve it, then runs straight away. If no approver is available, an admin can block the account and revoke its sessions directly in the Microsoft Entra admin center.

Then ask SuperIT to investigate what it can read, for example "Check Jane's inbox rules and mailbox forwarding for anything suspicious" and "Which groups and admin roles does Jane have?". SuperIT can't read sign-in logs or MFA methods through the Microsoft 365 connection; check those in the Microsoft Entra admin center. See What SuperIT can do in Microsoft 365.

Variations