Identity Breach Response

SecOps/IR Playbook — You're already compromised. What you do in the next 15 minutes matters more than what you spent on tooling last year.

60–90 min
an issued access token outlives a session revocation (Microsoft identity platform docs, 2026)
28 h
longest a CAE client's token lives without a revocation event (Microsoft Entra CAE docs, 2026)
90 days
refresh-token inactivity window: sliding, not a cap, not configurable (Microsoft identity platform docs, 2026)
30 days
Entra sign-in and audit log retention on P1/P2, 7 on Free (Microsoft Entra data retention docs, 2026)
This is an incident response playbook, not a hardening guide. If you're not currently in an incident: start with BEC Defense or Supply Chain Security. If you ARE in an incident — skip the intro, jump to the first 15 minutes.
Why this playbook exists: Most identity-IR runbooks say "reset the password and revoke MFA." That's not enough. A password reset revokes refresh tokens from password sign-ins; what else it touches depends on which portal did the reset (Microsoft's refresh-token revocation table), and it never touches a registered device's Primary Refresh Token, a consented app's grant, an inbox rule or a forwarding address. A refresh token that keeps being used does not expire; its only limit is 90 days of inactivity. Identity remediation is seven distinct actions, in order, against a clock.

How Far Has It Got?

Ask these at triage, top to bottom, and again at every update. The furthest stage you can show evidence for is how far the incident has got — write it in the case's status line. A stage you cannot answer yet is a scoping gap: say when it will be known. Why: the stage reached decides what containment has to cover beyond the one account, and whether this is still a user incident or already a tenant incident.
#StageATT&CKQuestionWhere to look
1First sign-inT1078.004When and how did the attacker first sign in: protocol, app, IP?Entra sign-in logs, interactive and non-interactive: 30 days on P1/P2, 7 on Free, longer only in Log Analytics
2MFAT1539 T1550.004Did they pass MFA, and how: AiTM cookie, device code, a replayed PRT, an added method?Sign-in authentication details, IncomingTokenType, SessionId; Identity Protection risk detections
3PersistenceT1098.005 T1564.008Did they register a device, add an MFA method or TAP, consent an app, add app credentials, or create inbox rules, forwarding or delegation?Entra audit log; Get-MgUserRegisteredDevice; OAuth grants and app-role assignments; Get-InboxRule -IncludeHidden; mailbox audit UpdateInboxRules
4Data accessT1114.002What did they read, search, download or delete: mail, OneDrive, SharePoint, Teams?Unified audit log, 180 days Standard or 1 year Premium (MailItemsAccessed with MailAccessType and IsThrottled, FileDownloaded)
5OutboundT1534Did they send mail or share files from the account, and who clicked?Message trace (90 days, 10 per query); EmailEvents and UrlClickEvents; SharePoint sharing audit
6PrivilegeT1098.003Does the account hold an admin role, or did they gain one, directly, through a role-assignable group or in Azure RBAC?Entra audit RoleManagement and GroupManagement; PIM audit; Azure Activity role writes
7Other accountsT1078.004 T1110.003Did other users sign in from the same IP, or click an internal phish from this account?Sign-in logs by IP and user agent; risk detections on the same IP; UrlClickEvents and message-trace recipients
8Tenant changesT1556.009 T1484.002Did they change Conditional Access, federation, partners, consent policy, create users or apps, add credentials, change transport rules or switch off auditing?Entra audit log over the window, any actor; Exchange admin audit
9Still activeT1550.001Is any token still in use after containment?Interactive and non-interactive sign-ins, and Graph activity, after the revocation time
T+0 — T+15 min  •  CONTAIN

First 15 Minutes: Stop the Bleeding

You suspect or have confirmed a compromised account. The attacker is either (a) actively in the tenant, or (b) holding tokens that will re-mint access until you revoke them. Goal in this phase: cut active sessions and remove every channel they'd use to re-enter — in one pass. Deep investigation waits; scoping doesn't.
Contain at least as wide as the attacker could be. Revoking one user's sessions while a consented app, a registered device or a second compromised account stays live doesn't evict the attacker — it tells them they've been found while they still have a way in. Hit all the channels below together, not one per hour. If you can't yet rule out spread beyond this account, go broad (last step below) rather than surgical.
Do not start with a password reset, and do not revoke before you disable. A reset revokes refresh tokens from password sign-ins and nothing else that matters here: a registered device's PRT, a consented app's grant, an inbox rule and a forwarding address all survive it. Revoking while the password is still valid and the account still enabled leaves a window in which the attacker signs in again with the password they already hold. Order: disable → revoke → remove persistence → export → reset → re-enable → revoke again.
T+0–2 minDisable the account
Blocks every new token issuance and is a CAE critical event, so CAE-capable clients (Office, Teams, OneDrive, Edge) lose access within minutes instead of at token expiry. The user is locked out; that is the point. Hybrid: Entra refuses AccountEnabled on a synced user. Disable in AD and force a delta sync. With password hash sync the attacker holds the on-premises password too, so the AD reset at Hour 4–24 is not optional; with pass-through authentication the AD disable is the only disable there is.
Update-MgUser -UserId <upn> -AccountEnabled:$false
# Synced user: in AD, then on the Entra Connect server
Disable-ADAccount -Identity <sAMAccountName>
Start-ADSyncSyncCycle -PolicyType Delta
T+2–4 minRevoke all sessions, then confirm the user compromised
Invalidates every refresh token and session cookie issued to the identity, for public and confidential clients alike. It does not recall access tokens already issued: those live 60–90 minutes by default, and up to 28 hours on a CAE client that has not received the critical event. "Confirm compromised" sets user risk to high so risk-based Conditional Access fires on any sign-in that gets through; against residential-proxy sources an IP block cannot keep up with, this is the control that holds.
Revoke-MgUserSignInSession -UserId <upn>
Confirm-MgRiskyUserCompromised -UserIds (Get-MgUser -UserId <upn>).Id
T+4–6 minRecord, then remove, attacker-added MFA methods and Temporary Access Passes
The attacker may have enrolled their own authenticator, phone or a TAP. List every method with its id, export the list, then remove with the per-type cmdlet; there is no generic remove. Remove what was added inside the exposure window (date it from the "User registered security info" and "Admin registered security info" audit events); the user's original methods stay until the Hour 4–24 re-enrolment.
Get-MgUserAuthenticationMethod -UserId <upn> | Select-Object Id, AdditionalProperties | Export-Csv .\mfa-<upn>.csv -NoTypeInformation
Remove-MgUserAuthenticationPhoneMethod -UserId <upn> -PhoneAuthenticationMethodId <id>
Remove-MgUserAuthenticationMicrosoftAuthenticatorMethod -UserId <upn> -MicrosoftAuthenticatorAuthenticationMethodId <id>
Remove-MgUserAuthenticationSoftwareOathMethod -UserId <upn> -SoftwareOathAuthenticationMethodId <id>
Remove-MgUserAuthenticationEmailMethod -UserId <upn> -EmailAuthenticationMethodId <id>
Remove-MgUserAuthenticationFido2Method -UserId <upn> -Fido2AuthenticationMethodId <id>
Remove-MgUserAuthenticationTemporaryAccessPassMethod -UserId <upn> -TemporaryAccessPassAuthenticationMethodId <id>
T+6–8 minDisable attacker device registrations (PRT persistence)
A registered device produces a Primary Refresh Token for silent SSO across the tenant; a session revocation does not unregister it. Any device registered or joined during the exposure window is suspect. Use registered devices, not owned ones: the attacker's device can list the user as a registered user without being owned. Disable now, snapshot the objects (T+12–15), delete after the case closes.
$compromiseStart = Get-Date "<ISO time, UTC>"
Get-MgUserRegisteredDevice -UserId <upn> -All |
  ForEach-Object { Get-MgDevice -DeviceId $_.Id } |
  Where-Object { $_.RegistrationDateTime -gt $compromiseStart } |
  ForEach-Object { Update-MgDevice -DeviceId $_.Id -AccountEnabled:$false }
T+8–10 minRevoke OAuth grants and app-role assignments made in the exposure window; disable the service principal first
A consented app keeps access independent of the user's password, sessions and devices. Graph ignores oAuth2PermissionGrant.startTime (it is normally 0001-01-01), so a date filter on the grant object returns nothing: date the consent from the audit log. Export first. Disable the service principal before deleting anything: removing the grant alone leaves the app's existing refresh token working until it next needs a new one, and deleting the SP destroys the grant records you have not exported. The app registration itself lives in the attacker's tenant; what you control is the service principal in yours. If the app id is in the FOCI family, one refresh token also serves Office, Teams, Outlook, OneDrive, Azure CLI and Azure PowerShell: hunt those app ids from the attacker IP (Stage 9, Defender tab).
$start = $compromiseStart.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
# Which apps were consented or assigned during the window, by whom, from where (Entra audit log: 30 d on P1/P2, 7 d Free)
Get-MgAuditLogDirectoryAudit -All -Filter "activityDateTime ge $start and (activityDisplayName eq 'Consent to application' or activityDisplayName eq 'Add delegated permission grant' or activityDisplayName eq 'Add app role assignment grant to user')" |
  Where-Object { $_.InitiatedBy.User.UserPrincipalName -eq '<upn>' -or $_.TargetResources.UserPrincipalName -contains '<upn>' } |
  Export-Csv .\consents-<upn>.csv -NoTypeInformation

# Export everything the user holds, then act on the apps the audit log named
Get-MgUserOauth2PermissionGrant -UserId <upn> -All | Export-Csv .\grants-<upn>.csv -NoTypeInformation
Get-MgUserAppRoleAssignment -UserId <upn> -All | Export-Csv .\approles-<upn>.csv -NoTypeInformation
$sp = Get-MgServicePrincipal -Filter "appId eq '<app id from the audit log>'"
Update-MgServicePrincipal -ServicePrincipalId $sp.Id -AccountEnabled:$false        # no new tokens for anyone, now
Get-MgUserOauth2PermissionGrant -UserId <upn> -All | Where-Object ClientId -eq $sp.Id |
  ForEach-Object { Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $_.Id }
Get-MgUserAppRoleAssignment -UserId <upn> -All | Where-Object ResourceId -eq $sp.Id |
  ForEach-Object { Remove-MgUserAppRoleAssignment -UserId <upn> -AppRoleAssignmentId $_.Id }
# Everyone else who consented the same app (the Hour 1-4 tenant-wide list)
Get-MgServicePrincipalOauth2PermissionGrant -ServicePrincipalId $sp.Id -All | Select-Object PrincipalId, ConsentType, Scope
T+10–12 minExport, then purge inbox rules, forwarding and delegation
The persistence that survives everything above. Rules created in Outlook or OWA are logged as UpdateInboxRules in the mailbox audit, not as New-InboxRule; hunt both. Export before removing: the rule names and conditions are the signature for the tenant-wide hunt, and the export is evidence. Check Full Access and Send As delegation on the mailbox, and transport rules at tenant level, in the same pass.
Get-InboxRule -Mailbox <upn> -IncludeHidden | Export-Csv .\rules-<upn>.csv -NoTypeInformation
Get-InboxRule -Mailbox <upn> -IncludeHidden | Remove-InboxRule -Confirm:$false
Get-Mailbox -Identity <upn> | Format-List ForwardingSmtpAddress, ForwardingAddress, DeliverToMailboxAndForward
Set-Mailbox -Identity <upn> -ForwardingSmtpAddress $null -ForwardingAddress $null -DeliverToMailboxAndForward $false
Get-MailboxPermission -Identity <upn> | Where-Object { $_.User -notlike 'NT AUTHORITY\*' -and -not $_.IsInherited }
Get-RecipientPermission -Identity <upn> | Where-Object { $_.Trustee -notlike 'NT AUTHORITY\*' }
# Tenant level, any actor: transport rules and connectors changed in the window (Stage 8)
Get-TransportRule | Where-Object { $_.WhenChanged -gt $compromiseStart } | Format-List Name, State, RedirectMessageTo, BlindCopyTo, WhenChanged
Get-InboundConnector | Where-Object { $_.WhenChanged -gt $compromiseStart } | Format-List Name, Enabled, SenderDomains, WhenChanged
T+12–15 minExport first: sign-in, audit and unified audit logs, message trace, mailbox hold, device objects
Retention is shorter than the case. Entra sign-in and audit logs: 30 days on P1/P2, 7 on Free, unless the diagnostic setting ships them to Log Analytics. Unified audit log: 180 days (Audit Standard) or one year (Premium). Microsoft Graph activity: nothing is kept unless the MicrosoftGraphActivityLogs diagnostic setting is on. Message trace: 90 days, 10 days per query. Put the mailbox on litigation hold before any purge so deleted phish and rule-created folders stay recoverable, and snapshot the device objects before anyone deletes them.
# Entra sign-ins (v1.0: interactive; beta: non-interactive) and the tenant audit log, every page
Get-MgAuditLogSignIn -All -Filter "userPrincipalName eq '<upn>'" | Export-Csv .\signins-<upn>.csv -NoTypeInformation
Get-MgBetaAuditLogSignIn -All -Filter "userPrincipalName eq '<upn>' and signInEventTypes/any(t: t eq 'nonInteractiveUser')" | Export-Csv .\signins-ni-<upn>.csv -NoTypeInformation
Get-MgAuditLogDirectoryAudit -All -Filter "activityDateTime ge $start" | Export-Csv .\audit-tenant.csv -NoTypeInformation

# Unified audit log: ReturnLargeSet pages to 50,000; -ResultSize 5000 on its own stops at 5,000 without saying so
$sid = "IR-<n>-<upn>"
do {
  $page = Search-UnifiedAuditLog -StartDate $compromiseStart -EndDate (Get-Date) -UserIds <upn> -SessionId $sid -SessionCommand ReturnLargeSet -ResultSize 5000
  $page | Export-Csv .\ual-<upn>.csv -NoTypeInformation -Append
} while ($page)

# Message trace: 10 days per query, 90 days back; slice a longer window, or Start-HistoricalSearch for a report
Get-MessageTraceV2 -SenderAddress <upn> -StartDate $compromiseStart -EndDate (Get-Date) -ResultSize 5000 | Export-Csv .\trace-<upn>.csv -NoTypeInformation

# Hold and snapshot
Set-Mailbox -Identity <upn> -LitigationHoldEnabled $true -LitigationHoldDuration 365 -Comment "IR case #<n>"
Get-MgUserRegisteredDevice -UserId <upn> -All | ForEach-Object { Get-MgDevice -DeviceId $_.Id } | Export-Csv .\devices-<upn>.csv -NoTypeInformation
T+0–15 min, in parallelCan you rule out spread? If not, go broad
Three quick checks decide whether one account is the whole scope: (a) did this account send internal mail during the access window, (b) does it hold or is it eligible for an admin role, (c) are other accounts signing in from the attacker's IP or user agent? If any answer is yes or unknown, contain at the wider level now, not after the investigation: disable and revoke every internal recipient who clicked (Stage 5), disable and revoke all privileged accounts if a role was in reach, and block the attacker's source in Conditional Access tenant-wide with the break-glass accounts excluded. Broad containment costs some user friction; narrow containment against a wider attacker costs the eviction.
# Other accounts seen from the attacker's source during the window, interactive and non-interactive
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > datetime(<compromise_start>)
| where IPAddress in (<attacker_ips>) and ResultType == "0"
| summarize FirstSeen=min(TimeGenerated), Apps=make_set(AppDisplayName) by UserPrincipalName
Stop here. Don't reset the password yet; that is Hour 4–24, and it is followed by a second revocation once the account is re-enabled. Don't re-enable the account yet. Don't notify the user yet. You've cut every channel you know about — that is not yet proof the attacker is out. The next phase proves it.
T+1 hr — T+4 hr  •  VALIDATE & SCOPE

Hour 1–4: Confirm Scope, Find Other Compromised Accounts

The known channels are cut. Now answer: did the containment hold, what did the attacker actually do, and is anyone else compromised? Most identity attacks involve internal lateral movement — once one mailbox is in, the attacker phishes adjacent users from a trusted internal address.
Verify the containment held
Any successful sign-in, token use or Graph activity for this identity after the revocation time means a channel was missed — usually a device registration or an app grant you haven't found. Allow the first 90 minutes for access tokens already issued (28 hours for a CAE client that never received the revocation); anything later is still-active compromise. Widen the containment before continuing.
union SigninLogs, AADNonInteractiveUserSignInLogs
| where UserPrincipalName =~ "<upn>"
| where TimeGenerated > datetime(<revocation_time>)
| where ResultType == "0"
| project TimeGenerated, Type, IPAddress, AppDisplayName, IncomingTokenType, DeviceDetail
// Graph calls after the revocation (only if the MicrosoftGraphActivityLogs diagnostic setting is on)
MicrosoftGraphActivityLogs
| where UserId == "<object id>" and TimeGenerated > datetime(<revocation_time>)
| summarize Calls=count(), Last=max(TimeGenerated) by IPAddress, AppId, RequestMethod, RequestUri
Pull sign-in logs for the whole retention window for the compromised user
30 days on P1/P2, 7 on Free; longer only if the diagnostic setting has been shipping them to Log Analytics. Look for: (a) the initial successful malicious sign-in (timestamp, IP, app), (b) subsequent activity from the same IP or user agent, (c) the authentication protocol and incoming token type: device code, refresh token, PRT. The non-interactive table holds the token replay that follows a phish; without it the timeline starts late.
# In Sentinel or Log Analytics
union SigninLogs, AADNonInteractiveUserSignInLogs
| where UserPrincipalName =~ "<upn>"
| where TimeGenerated > ago(30d)
| project TimeGenerated, Type, IPAddress, AppDisplayName, ResultType, AuthenticationProtocol,
          IncomingTokenType, SessionId, ConditionalAccessStatus, Status
| order by TimeGenerated desc
# Outside Sentinel: the Get-MgAuditLogSignIn / Get-MgBetaAuditLogSignIn exports from T+12-15
Read the unified audit log export for resources accessed
What did the attacker actually touch? Mailbox items, OneDrive files, SharePoint sites, Teams chats. The attacker usually runs a bulk mail pull within minutes of initial access: in MailItemsAccessed, MailAccessType Sync means whole folders, IsThrottled True means the log stopped counting at 1,000 in 24 hours and under-states the read, ClientInfoString names the tool. Graph API reads are not in the UAL as such; they are in MicrosoftGraphActivityLogs if the diagnostic setting was on, and nowhere otherwise.
# From the T+12-15 export (ReturnLargeSet); the same with Search-UnifiedAuditLog -Operations MailItemsAccessed,FileDownloaded
Import-Csv .\ual-<upn>.csv | ForEach-Object { $_.AuditData | ConvertFrom-Json } |
  Where-Object { $_.ClientIPAddress -in @('<ip1>','<ip2>') } |
  Select-Object CreationTime, Operation, ClientIPAddress, ClientInfoString,
    @{n='MailAccessType';e={ ($_.OperationProperties | Where-Object Name -eq 'MailAccessType').Value }},
    @{n='IsThrottled';e={ ($_.OperationProperties | Where-Object Name -eq 'IsThrottled').Value }},
    @{n='Folders';e={ ($_.Folders.Path) -join ';' }} |
  Export-Csv .\access-by-attacker-ip.csv -NoTypeInformation
Audit OAuth grants and app credentials beyond the one user
The user's grants to the attacker's app were revoked and the service principal disabled at T+8. Now widen: every other principal that consented the same app id, admin consents, service principals created in the window, and credentials added to existing privileged apps (a secret on an app that already holds Mail.Read for the tenant is a quieter door than a new app). The Entra audit log is 30 days on P1/P2; after that the only record is the SP's own credential list.
# Every principal holding a grant for the attacker's app, and every app-role assignment to it
Get-MgServicePrincipalOauth2PermissionGrant -ServicePrincipalId $sp.Id -All | Select-Object PrincipalId, ConsentType, Scope
Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $sp.Id -All
# Admin-consented (AllPrincipals) grants anywhere in the tenant
Get-MgOauth2PermissionGrant -All -Filter "consentType eq 'AllPrincipals'" | Select-Object ClientId, ResourceId, Scope
# Service principals and app credentials created in the window, tenant-wide, any actor
Get-MgAuditLogDirectoryAudit -All -Filter "activityDateTime ge $start and (activityDisplayName eq 'Add service principal' or activityDisplayName eq 'Add service principal credentials' or activityDisplayName eq 'Update application – Certificates and secrets management')" |
  Select-Object ActivityDateTime, ActivityDisplayName, @{n='Actor';e={$_.InitiatedBy.User.UserPrincipalName}}, @{n='Target';e={$_.TargetResources[0].DisplayName}}
# Secrets and certificates on every SP, newest first: anything added in the window on a privileged app
Get-MgServicePrincipal -All -Property Id,DisplayName,AppId,PasswordCredentials,KeyCredentials |
  ForEach-Object { $n=$_.DisplayName; $_.PasswordCredentials + $_.KeyCredentials | ForEach-Object { [pscustomobject]@{App=$n; Added=$_.StartDateTime; Ends=$_.EndDateTime; Name=$_.DisplayName} } } |
  Where-Object { $_.Added -gt $compromiseStart } | Sort-Object Added -Descending
Look for adjacent compromise (lateral phishing)
Did the attacker send internal phishing from this account, and who clicked? With Defender for Office 365, join the account's intra-org mail to Safe Links clicks; otherwise, message trace for the recipients and the Stage 7 sign-in hunt for the ones that went on to sign in from the attacker IP. Every internal recipient who clicked gets the T+0–15 treatment, not a warning e-mail.
EmailEvents
| where Timestamp > datetime(<compromise_start>) and SenderFromAddress =~ "<upn>" and EmailDirection == "Intra-org"
| join kind=inner (UrlClickEvents | where Timestamp > datetime(<compromise_start>)) on NetworkMessageId
| project ClickTime=Timestamp1, Clicker=AccountUpn, Url, ActionType, IsClickedThrough, Subject
| order by ClickTime asc
# Without Defender for Office 365: recipients from message trace (10 days per query, 90 days back)
Get-MessageTraceV2 -SenderAddress <upn> -StartDate $compromiseStart -EndDate (Get-Date) -ResultSize 5000 |
  Group-Object RecipientAddress | Select-Object Name, Count | Sort-Object Count -Descending
Check for privileged role escalation
If the compromised account had any admin role — or was eligible via PIM — the blast radius is larger. Pull role assignment changes during the window, including membership of role-assignable groups and Azure RBAC writes; the first page of results is not the answer, so page through all of them.
# Role and group changes in the window, every page
Get-MgAuditLogDirectoryAudit -All -Filter "activityDateTime ge $start and (category eq 'RoleManagement' or category eq 'GroupManagement')" |
  Where-Object { $_.ActivityDisplayName -like 'Add member to role*' -or $_.ActivityDisplayName -like 'Add eligible member to role*' -or $_.ActivityDisplayName -eq 'Add member to group' } |
  Select-Object ActivityDateTime, ActivityDisplayName, @{n='Actor';e={$_.InitiatedBy.User.UserPrincipalName}}, @{n='Targets';e={$_.TargetResources.DisplayName -join ';'}}
# Is the group role-assignable?
Get-MgGroup -GroupId <group id> -Property DisplayName,IsAssignableToRole
# What the account holds right now, directly and via groups
Get-MgUserTransitiveMemberOf -UserId <upn> -All | ForEach-Object { $_.AdditionalProperties.displayName }
# Azure RBAC role writes by or to the account (Sentinel, Azure Activity connector)
AzureActivity
| where TimeGenerated > datetime(<compromise_start>) and OperationNameValue =~ "Microsoft.Authorization/roleAssignments/write"
| project TimeGenerated, Caller, CallerIpAddress, SubscriptionId, Properties
Notify finance immediately if mailbox access confirmed
If the compromised user has invoice / wire / payment-flow visibility, finance needs to validate any open payment threads out-of-band via known-good phone numbers — not numbers in email signatures (which may have been tampered with). Payment in flight: BEC Response. Finance-side controls: BEC Defense.
T+4 hr — T+24 hr  •  ERADICATE & HARDEN

Hour 4–24: Evict the Attacker, Prevent Re-Entry

Scope is known. Now you make sure the attacker cannot return through the same door. The temptation to re-enable accounts and "move on" is strong — resist it until the attacker's full footprint is removed.
  • Reset the password with a strong temporary credential, require change at next sign-in. Only NOW — after containment and investigation. Synced user: reset in AD (password hash sync writes the new hash to Entra on the next cycle; with password writeback the Entra reset flows back). With PHS the on-premises password was the attacker's too, so a cloud-only reset leaves it in their hands.
    Update-MgUser -UserId <upn> -PasswordProfile @{ ForceChangePasswordNextSignIn = $true; Password = "<new-temp>" }
    # Synced: Set-ADAccountPassword -Identity <sAMAccountName> -Reset; Start-ADSyncSyncCycle -PolicyType Delta
  • Force MFA re-registration through a Temporary Access Pass: remove the remaining methods per type (T+4–6 cmdlets), issue a one-time TAP, hand it over by voice to a number you already had on file, and have the user enrol Authenticator or a passkey from a known-good device. A TAP issued by anyone else, or left usable more than once, is itself a persistence method.
    New-MgUserAuthenticationTemporaryAccessPassMethod -UserId <upn> -BodyParameter @{ isUsableOnce = $true; lifetimeInMinutes = 60 }
  • Block the attacker IP range via named location in Conditional Access if the source is identifiable (already tenant-wide if you went broad at T+0), break-glass accounts excluded. Residential-proxy sources rotate; the user-risk policy fed by "Confirm compromised" is the durable control. Before the IP goes into any threat intel feed, an analyst checks it isn't shared infrastructure and gives it a date, a source and an expiry.
  • Rotate any shared credentials the user could access — password manager exports, Azure Key Vault entries, service account secrets, SSH keys in scope.
  • Revoke any further OAuth grants and app credentials found in the Hour 1–4 tenant-wide audit; disable the service principals first, delete them only after the grant exports are on file. Document the app IDs for the hunt.
  • Hunt tenant-wide for the attacker's TTPs: same source IP, same device-code-flow pattern, same OAuth app ID, same inbox rule signature, same FOCI client ids from the same ASN.
  • Re-enable the account, then revoke sessions once more. Only after MFA re-registration on a verified device. The second Revoke-MgUserSignInSession kills anything that authenticated between reset and re-enable and any token minted through a channel you missed; the user's new session is a minute's inconvenience.
  • Notify the user with clear instructions: what happened, what they need to do, what you've already done. Don't make the user feel blamed.
  • Tighten tenant controls if a gap was exploited: block device-code flow if that was the vector, restrict OAuth user consent if that was the vector. Refresh-token lifetime is not a lever: those policies were retired in January 2021 and the 90-day inactivity default is fixed (Microsoft identity platform, configurable token lifetimes). The levers that exist are Conditional Access sign-in frequency, "persistent browser session" set to never persistent for the roles that matter, and Token Protection (CAE session control) so a stolen token is useless off the device it was issued to.
  • Open a forensics ticket with the exports from T+12–15 and the retention windows written next to each: Entra 30 days on P1/P2 (7 on Free), UAL 180 days or one year, message trace 90 days, Graph activity only if it was being shipped.
Common eviction failure: Forgetting that registered devices, OAuth grants, added MFA methods and inbox rules are independent persistence mechanisms. An attacker with all of them re-enters after a password reset because they never needed the password again. Eviction means removing every channel: sessions, credentials, OAuth grants and app credentials, registered devices, MFA methods and TAPs — plus inbox rules, forwarding and delegation so they don't see what you do next.
Day 1 — Day 7  •  COMMUNICATE & RECOVER

Day 1–7: Stakeholder Communication, Regulatory, Recovery

Attacker is out — confirmed by no sign-ins, token use or grant activity since the revocation time, on this account or any other in scope. Now you handle the human and regulatory side. Identity breaches almost always touch personal data — mailboxes, contact lists, files — which triggers GDPR / NIS2 / sector-specific notification clocks.
  • Internal stakeholder briefing (Day 1): legal, comms, exec sponsor, DPO. What happened, scope, contained, next steps. Written, not verbal — this becomes the timeline of record.
  • Regulatory notification clock check (Day 1): GDPR — 72 hours to DPA if personal data risk; NIS2 — 24h early warning + 72h incident notification to national CSIRT for essential/important entities; DORA — specific timelines for financial sector; sector regulators may have additional. Dutch NCSC / national CSIRT for NL.
  • Customer/partner notification decision (Day 2–3): if their data was accessed, when and how to tell them. Legal-led; security advises on technical content.
  • Insurance notification (Day 1–2): cyber policy usually requires notification within 72h of incident discovery. Late notification can invalidate coverage.
  • Lateral compromise sweep continues: any user who clicked an internal phishing link from the compromised account gets the same containment treatment. Repeat the T+0 playbook per user.
  • Forensic collection from the compromised endpoint if available — the initial infection (infostealer? AiTM landing?) may live on the user's machine. The Forensics act on your EDR's tab under Hunt & Act by Platform pushes Magnet RESPONSE for RAM and triage files and Get-PersistenceSnapshot.ps1 for a zipped persistence snapshot; hand both to the forensic vendor or internal IR with their SHA256s.
  • Exposure intelligence check: were the user's credentials in a recent infostealer log? Cross-reference SpyCloud / HIBP / vendor feeds. If yes, expand scope to any system where those credentials were reused.
  • Daily status briefings to the exec sponsor until closure. One page, written, time-stamped.
Day 7 — Day 30  •  LESSONS & POSTURE

Day 7–30: Post-Mortem and Posture Improvements

The incident is closed. The job now is to make sure the same incident, in the same shape, cannot happen again. Identity attacks repeat — same kits, same TTPs, same configuration gaps. The post-mortem produces durable changes.
  • Blameless post-mortem: timeline, decisions made, what worked, what didn't, what we'd do differently. Written, shared with the team.
  • Root cause classification: was this credential phishing, AiTM proxy, device-code phishing, OAuth consent abuse, NHI key leak, password reuse from a third-party breach? The answer drives which posture playbook applies next.
  • Hand off to posture playbooks:
  • Time-to-detect & time-to-contain metrics: from first attacker action to your first response. Baseline this. Make it visible. Make it a target for the next incident.
  • Detection rule updates: encode the attacker's signature into a permanent alert (the source IP pattern, the OAuth app ID, the device-code-flow event, the specific inbox-rule shape). Future occurrence triggers immediate response.
  • Tabletop the recurrence: 90 days post-incident, run the same scenario as a tabletop. Validate the new controls hold.
  • Board/audit report: incident, response, lessons, control improvements. Quarterly identity-posture cadence going forward.
  • Update the runbook: every IR teaches you something the previous version of the runbook didn't cover. Add it.

Hunt & Act by Platform

What to look for in your EDR or SIEM for each stage of the identity questions, and the response actions in each tool. Pick your platform.
Test these before you need them. The queries follow each vendor's documented schema, but field names depend on your data sources, versions and ingestion. Run each one in your own tenant during peacetime and fix it there — not during an incident.
KQL on the Sentinel tables from the Microsoft Entra ID and Microsoft 365 connectors (SigninLogs, AADNonInteractiveUserSignInLogs, AuditLogs, AADUserRiskEvents, OfficeActivity) and the Defender XDR connector (EmailEvents, UrlClickEvents, Device*). Windows: Entra keeps sign-in and audit logs for 30 days on P1/P2 and 7 days on Free; ago(30d) or longer only works once the diagnostic setting ships them to Log Analytics. OfficeActivity follows the workspace retention, the UAL behind it 180 days (Audit Standard) or one year (Premium). Defender XDR without Sentinel: EntraIdSignInEvents (Entra ID P2; replaces AADSignInEventsBeta on 19 October 2026) and CloudAppEvents carry the same data under other column names.

Hunt

Stage 1 · First sign-inSuccessful sign-ins for the account, interactive and non-interactive, earliest first per IP and client

Why: The first successful sign-in from an attacker IP is the start of the exposure window; every later hunt and any breach notification is dated from it. Refresh-token replay and device-code redemptions are logged as non-interactive sign-ins, so SigninLogs alone misses the token use that follows the phish.
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d)        // 30 d on P1/P2, 7 d on Free; longer only in Log Analytics
| where UserPrincipalName =~ "<upn>" and ResultType == "0"
| extend Kind = iff(Type == "SigninLogs", "interactive", "non-interactive")
| summarize FirstSeen=min(TimeGenerated), LastSeen=max(TimeGenerated), SignIns=count(), Kinds=make_set(Kind)
    by IPAddress, AutonomousSystemNumber, UserAgent, ClientAppUsed, AppDisplayName, AuthenticationProtocol
| order by FirstSeen asc
// AuthenticationProtocol == "deviceCode" on the first hit: the window starts at the code redemption, not at a password.
T1078.004 Tuning: The user's own VPN egress, a new office and Microsoft service IPs all appear as "new" sources; rank by ASN and first-seen, not by count, and compare against the 30 days before the window.

Stage 2 · MFAHow MFA was satisfied; PRT from an unmanaged device; one session on two IPs; what Identity Protection flagged

Why: A password reset does not stop a stolen session cookie, a replayed PRT or a device-code token; how MFA was passed decides whether you revoke sessions, remove devices, or also rebuild the victim's PC.
// a. How each authentication step was satisfied, from the attacker's IPs
SigninLogs
| where TimeGenerated > ago(30d)
| where UserPrincipalName =~ "<upn>" and ResultType == "0" and IPAddress in ("<ip>")
| mv-expand step = parse_json(AuthenticationDetails)
| project TimeGenerated, IPAddress, AppDisplayName, AuthenticationProtocol, AuthenticationRequirement,
          IncomingTokenType, Method=tostring(step.authenticationMethod), Detail=tostring(step.authenticationStepResultDetail)
// AuthenticationProtocol == "deviceCode": device-code phishing.
// Detail == "MFA requirement satisfied by claim in the token" from a new IP: a replayed session (AiTM or stolen cookie).
// b. A primary refresh token presented from a device Entra does not manage: stolen or forged PRT
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d) and UserPrincipalName =~ "<upn>" and IncomingTokenType == "primaryRefreshToken"
| extend dd = parse_json(DeviceDetail)
| where tostring(dd.isManaged) != "true" or isempty(tostring(dd.deviceId))
| project TimeGenerated, IPAddress, AppDisplayName, DeviceId=tostring(dd.deviceId), TrustType=tostring(dd.trustType), SessionId
// c. One session id seen from more than one IP: the cookie moved
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d) and UserPrincipalName =~ "<upn>" and isnotempty(SessionId)
| summarize IPs=make_set(IPAddress), Sources=dcount(IPAddress), First=min(TimeGenerated), Last=max(TimeGenerated) by SessionId
| where Sources > 1
// d. What Identity Protection already saw (P2)
AADUserRiskEvents
| where TimeGenerated > ago(30d) and UserPrincipalName =~ "<upn>"
| project TimeGenerated, RiskEventType, RiskLevel, RiskState, Activity, IpAddress
// e. Endpoint: Chromium started for remote debugging (cookie theft), on the victim's device
DeviceProcessEvents
| where Timestamp > ago(30d) and DeviceName =~ "<victim host>"
| where FileName in~ ("chrome.exe","msedge.exe") and ProcessCommandLine has "--remote-debugging-port"
| project Timestamp, DeviceName, AccountName, InitiatingProcessFileName, ProcessCommandLine
T1539 T1550.004 T1528 Tuning: Entra-joined devices mid-enrolment and Windows 365 or AVD session hosts can present a PRT with an empty device id; two IPs on one SessionId is normal for a phone moving between Wi-Fi and cellular within minutes. Weigh ASN and country, not the count. Remote-debugging flags are also set by browser automation and test tooling on developer machines.

Stage 3 · PersistenceMFA methods, TAPs, device registrations, app consents and credentials, inbox rules, forwarding, delegation, transport rules

Why: Each of these survives a password reset and a session revocation; anything found here has to be removed before the account is handed back. Outlook and OWA log rule changes as UpdateInboxRules (mailbox audit); only PowerShell logs New-InboxRule.
// Entra: methods, TAPs, devices, consents, app role assignments, credentials added to apps
AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName in ("User registered security info", "Admin registered security info",
    "User deleted security info", "Register device", "Add registered owner to device",
    "Add registered users to device", "Consent to application", "Add delegated permission grant",
    "Add app role assignment grant to user", "Add app role assignment to service principal",
    "Add service principal credentials")
   or OperationName has "Certificates and secrets management"
| where tostring(InitiatedBy) has "<upn>" or tostring(TargetResources) has "<upn>"
| project TimeGenerated, OperationName, Result, ResultReason, Actor=tostring(InitiatedBy.user.userPrincipalName),
          ActorIP=tostring(InitiatedBy.user.ipAddress), Target=tostring(TargetResources[0].displayName)
| order by TimeGenerated asc
// ResultReason has "temporary access pass": a TAP was issued; who issued it and from where, and was it redeemed from the attacker IP?
// Exchange: rules, forwarding, delegation, transport rules and connectors
OfficeActivity
| where TimeGenerated > ago(30d)
| where Operation in ("New-InboxRule", "Set-InboxRule", "UpdateInboxRules", "Set-Mailbox",
    "Add-MailboxPermission", "Add-MailboxFolderPermission", "Add-RecipientPermission",
    "New-TransportRule", "Set-TransportRule", "New-InboundConnector", "Set-InboundConnector")
| where UserId =~ "<upn>" or tostring(Parameters) has "<upn>"
| where Operation != "Set-Mailbox" or tostring(Parameters) has_any ("ForwardingSmtpAddress", "ForwardingAddress", "DeliverToMailboxAndForward")
| project TimeGenerated, Operation, UserId, ClientIP, Parameters
| order by TimeGenerated asc
T1556.006 T1098.005 T1098.001 T1564.008 Tuning: "User registered security info" fires on every routine enrolment and on Authenticator re-registration after a phone change; keep hits inside the exposure window and from the attacker's IP or a new country. Helpdesk-issued TAPs are normal; a TAP issued by the compromised account itself, or by an admin from the attacker IP, is not.

Stage 4 · Data accessMail read and files downloaded, by client IP, with sync-versus-bind, throttling, client string, searches and deletions

Why: What was read or downloaded from the attacker's IP decides whether this is a notifiable data breach and whose data it involves. MailAccessType == "Sync" means whole folders were pulled; IsThrottled == "True" means the log stopped counting after 1,000 accesses in 24 hours and under-states the read; ClientInfoString names the tool.
OfficeActivity
| where TimeGenerated > ago(30d) and UserId =~ "<upn>"
| where Operation in ("MailItemsAccessed", "FileDownloaded", "FileSyncDownloadedFull", "FileAccessed",
    "SearchQueryInitiatedExchange", "SearchQueryInitiatedSharePoint", "MoveToDeletedItems", "SoftDelete", "HardDelete")
| extend Props = tostring(OperationProperties)
| extend MailAccessType = extract(@'MailAccessType\W+Value\W+(\w+)', 1, Props),
         IsThrottled    = extract(@'IsThrottled\W+Value\W+(\w+)', 1, Props)
| summarize Events=count(), First=min(TimeGenerated), Last=max(TimeGenerated),
    Sync=countif(MailAccessType == "Sync"), Throttled=countif(IsThrottled == "True"),
    Clients=make_set(ClientInfoString, 5), Folders=make_set(tostring(parse_json(Folders)[0].Path), 10)
    by Operation, OfficeWorkload, ClientIP
| order by First asc
// ClientInfoString "Client=REST;;;" with an app id, or an unfamiliar MAPI/OWA/IMAP string: the attacker's tool.
// SearchQueryInitiatedExchange needs Audit (Premium) and the SearchQueryInitiated owner action; it shows what they looked for.
// Deletions after reads: cleanup of sent phish, bounce notices or rule-created folders.
// Defender XDR without Sentinel:
// CloudAppEvents | where Timestamp > ago(30d) and AccountObjectId == "<object id>"
// | where ActionType in ("MailItemsAccessed","FileDownloaded") | summarize count() by ActionType, IPAddress
// An empty MailItemsAccessed result only means nothing was logged; check Get-Mailbox <upn> | fl AuditEnabled, DefaultAuditSet.
T1114.002 T1530 T1213.002 Tuning: The owner's own clients dominate MailItemsAccessed; split by ClientIP first. Outlook in cached mode and mobile mail apps legitimately show Sync, and IsThrottled is also set by the user's own full-mailbox search or a migration. Compare the attacker IP's folder set with the user's usual one.

Stage 5 · OutboundMail sent from the account and who clicked it; files shared out of OneDrive and SharePoint

Why: Internal phish sent from a trusted mailbox is how one account becomes ten; every internal recipient who clicked is a candidate for the same containment, and every external recipient of a sharing link is a disclosure. EmailEvents and UrlClickEvents need Defender for Office 365; message trace is the fallback.

Outside Sentinel: Get-MessageTraceV2 -SenderAddress <upn> -StartDate <start> -EndDate <end> (10 days per query, 90 days back, 5,000 rows max) and Start-HistoricalSearch for a complete export.

// a. What the account sent, by direction
EmailEvents
| where Timestamp > ago(30d) and (SenderFromAddress =~ "<upn>" or SenderMailFromAddress =~ "<upn>")
| summarize Messages=count(), Recipients=dcount(RecipientEmailAddress), First=min(Timestamp), Last=max(Timestamp),
    WithUrl=countif(UrlCount > 0), WithAttachment=countif(AttachmentCount > 0), Subjects=make_set(Subject, 10)
    by EmailDirection
// b. Who clicked a link in internal mail from the account (Safe Links required)
EmailEvents
| where Timestamp > ago(30d) and SenderFromAddress =~ "<upn>" and EmailDirection == "Intra-org"
| join kind=inner (UrlClickEvents | where Timestamp > ago(30d)) on NetworkMessageId
| project ClickTime=Timestamp1, Clicker=AccountUpn, Url, ActionType, IsClickedThrough, Subject
| order by ClickTime asc
// ActionType "ClickAllowed", or any row with IsClickedThrough == true: that user entered the kit; contain them now.
// c. Sharing out of OneDrive and SharePoint
OfficeActivity
| where TimeGenerated > ago(30d) and UserId =~ "<upn>"
| where Operation in ("SharingSet", "SharingInvitationCreated", "AnonymousLinkCreated", "SecureLinkCreated",
    "AddedToSecureLink", "CompanyLinkCreated", "SharingLinkCreated")
| project TimeGenerated, Operation, OfficeWorkload, ClientIP, Site_Url, SourceFileName, TargetUserOrGroupName, TargetUserOrGroupType
| order by TimeGenerated asc
T1534 T1567 Tuning: Assistants, shared mailboxes and newsletter senders mail many internal recipients every day; compare recipient set and subject lines with the 30 days before the window, not with zero. SharingSet also fires on every permission change inside a Teams channel.

Stage 6 · PrivilegeRole assignments to or by the account, including PIM, role-assignable groups and Azure RBAC

Why: If the account holds or gained an admin role, this is a tenant incident, not a user incident: containment and notification both widen. Membership of a role-assignable group or an Azure Owner assignment is the same escalation under a different operation name.
AuditLogs
| where TimeGenerated > ago(30d)
| where (Category == "RoleManagement" and (OperationName startswith "Add member to role" or OperationName startswith "Add eligible member to role"))
   or (Category == "GroupManagement" and OperationName == "Add member to group")
| where tostring(TargetResources) has "<upn>" or tostring(InitiatedBy) has "<upn>"
| project TimeGenerated, Category, OperationName, Result, Actor=tostring(InitiatedBy.user.userPrincipalName), TargetResources
| order by TimeGenerated asc
// "Add member to group": check the group with Get-MgGroup -GroupId <id> -Property IsAssignableToRole, and whether it holds an Azure role.
// Azure RBAC writes by or to the account (AzureActivity via the Azure Activity connector)
AzureActivity
| where TimeGenerated > ago(30d)
| where OperationNameValue =~ "Microsoft.Authorization/roleAssignments/write" and ActivityStatusValue == "Success"
| where Caller =~ "<upn>" or tostring(Properties) has "<object id>"
| project TimeGenerated, Caller, CallerIpAddress, SubscriptionId, ResourceGroup, Properties
// Every directory change the account itself made:
// AuditLogs | where TimeGenerated > ago(30d) and tostring(InitiatedBy.user.userPrincipalName) =~ "<upn>" | summarize count() by OperationName
T1098.003 T1078.004 Tuning: PIM activations by the user's own admin account from the usual IP are routine; the signal is an assignment made by another actor, from the attacker IP, or to a group that was never role-assignable before the window.

Stage 7 · Other accountsOther users signing in from the attacker's IPs or user agent, and flagged by Identity Protection on the same source

Why: AiTM kits and password sprays hit many users from the same infrastructure; every other account seen here needs the same containment. The internal recipients who clicked are in Stage 5.
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d)
| where IPAddress in ("<ip1>","<ip2>")          // or: UserAgent == "<attacker user agent>"
| summarize Attempts=count(), Successes=countif(ResultType == "0"), First=min(TimeGenerated), Apps=make_set(AppDisplayName, 5)
    by UserPrincipalName
| order by First asc
// Risk detections on the same source, tenant-wide
AADUserRiskEvents
| where TimeGenerated > ago(30d) and IpAddress in ("<ip1>","<ip2>")
| summarize Detections=make_set(RiskEventType), First=min(TimeGenerated) by UserPrincipalName, RiskState
T1078.004 T1110.003 Tuning: A corporate NAT, Global Secure Access egress or a partner's VPN puts many legitimate users behind one IP; check the address against named locations before containing by IP. Residential-proxy kits rotate sources, so an empty result on the first IP does not clear the tenant.

Stage 8 · Tenant changesConditional Access, named locations, users, apps, credentials, federation, partners, transport rules and audit settings changed in the window

Why: A changed CA policy, a new federated domain, a GDAP partner or a credential on an existing privileged app is a back door that survives every per-user action on this page. A switched-off audit log is the point past which you are blind; date it.
// Entra: directory and policy changes in the window, by anyone
AuditLogs
| where TimeGenerated between (datetime(<window start>) .. datetime(<window end>))
| where OperationName has_any ("Conditional Access policy", "named location", "security defaults",
    "Add user", "Add application", "Add service principal", "Add service principal credentials",
    "Add owner to application", "Add owner to service principal", "Set federation settings on domain",
    "Set domain authentication", "Add unverified domain", "Verify domain", "Add partner to company",
    "authorization policy", "Update StsRefreshTokenValidFrom Timestamp")
   or OperationName has "Certificates and secrets management"
| project TimeGenerated, Category, OperationName, Result, Actor=tostring(InitiatedBy.user.userPrincipalName),
          App=tostring(InitiatedBy.app.displayName), ActorIP=tostring(InitiatedBy.user.ipAddress),
          Target=tostring(TargetResources[0].displayName), TargetResources
| order by TimeGenerated asc
// Exchange: transport rules, connectors, audit and auth settings
OfficeActivity
| where TimeGenerated between (datetime(<window start>) .. datetime(<window end>))
| where Operation in ("New-TransportRule", "Set-TransportRule", "Remove-TransportRule", "New-InboundConnector", "Set-InboundConnector",
    "Set-AdminAuditLogConfig", "Set-OrganizationConfig", "Set-AuthenticationPolicy", "Set-MailboxAuditBypassAssociation",
    "Add-RoleGroupMember", "New-ManagementRoleAssignment", "New-ApplicationAccessPolicy")
| project TimeGenerated, Operation, UserId, ClientIP, Parameters
// Set-AdminAuditLogConfig with UnifiedAuditLogIngestionEnabled false: the UAL was switched off; your evidence ends there.
// Set-MailboxAuditBypassAssociation: a mailbox silently excluded from auditing.
T1556.009 T1484.002 T1136.003 T1098.001 Tuning: CA-as-code pipelines and Entra Connect write policies and users on a schedule; match on actor, app and time, not on the operation alone. Entra Connect itself shows as "Sync_" actors and an app actor; an interactive user doing the same is the finding.

Stage 9 · Still activeToken use and Graph calls after the revocation time; which family clients the refresh token was minted for

Why: revokeSignInSessions invalidates refresh tokens and session cookies; access tokens already issued live until they expire, 60–90 minutes by default and up to 28 hours on CAE-capable clients unless CAE pushed the revocation (it does for account disable and password change). A success after that window means containment missed a device, an app grant or a refresh token.
let revoked = datetime(<revocation time, UTC>);
// or: toscalar(AuditLogs | where OperationName == "Update StsRefreshTokenValidFrom Timestamp" and tostring(TargetResources) has "<upn>" | summarize max(TimeGenerated))
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > revoked and UserPrincipalName =~ "<upn>" and ResultType == "0"
| summarize SignIns=count(), First=min(TimeGenerated), Last=max(TimeGenerated) by Type, IPAddress, AppDisplayName, UserAgent, IncomingTokenType
| order by First asc
// Graph calls after the revocation (needs the "MicrosoftGraphActivityLogs" diagnostic setting; nothing is kept without it)
MicrosoftGraphActivityLogs
| where TimeGenerated > revoked and UserId == "<object id>"
| summarize Calls=count(), Last=max(TimeGenerated) by IPAddress, AppId, RequestMethod, RequestUri
| order by Calls desc
// Which FOCI family clients the attacker minted tokens for before revocation: one family refresh token serves all of them
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d) and UserPrincipalName =~ "<upn>" and IPAddress in ("<ip1>","<ip2>")
| where AppId in ("d3590ed6-52b3-4102-aeff-aad2292ab01c", "04b07795-8ddb-461a-bbee-02f9e1bf7b46", "1950a258-227b-4e31-a9cf-717495945fc2",
    "1fec8e78-bce4-4aaf-ab1b-5451cc387264", "4813382a-8fa7-425e-ab75-3b753aab3abb", "27922004-5251-4030-b22d-91ecd9a37ea4",
    "ab9b8c07-8f02-4f72-87fa-80105867a763")
| summarize Apps=make_set(AppDisplayName), First=min(TimeGenerated) by IPAddress, UserAgent
T1550.001 T1078.004 Tuning: Successes inside the first 90 minutes after revocation from the user's own devices are cached access tokens expiring, not a missed channel. Office and Teams on a managed device re-authenticate silently with the PRT the moment the account is re-enabled; that is expected, from the attacker IP it is not.

Act

Disable first, revoke second, for every account in scope in one pass; verify it held before re-enabling anything. A victim's device that ran an infostealer is rebuilt, not cleaned.

T+0–15 · ContainDisable, then revoke, every account in scope at once; confirm compromised

Why: disabling blocks new tokens and is a CAE critical event; revoking kills the refresh tokens and cookies the attacker already holds. In that order, so there is no gap in which the still-valid password mints a fresh session. One account at a time warns the attacker while a second account stays live.

Portal, per user: identity page › Disable / Revoke session / Mark as compromised. From an advanced hunting result: Take actions › Disable user (the query must return AccountObjectId). Defender for Cloud Apps: Suspend user, and Revoke app for a consented OAuth app. Synced users: Entra refuses AccountEnabled on them; disable in AD and run Start-ADSyncSyncCycle -PolicyType Delta, then revoke in Entra. With password hash sync the attacker holds the on-premises password too: reset it in AD. For the whole list, script it:

$scope = Get-Content .\accounts-in-scope.txt   # one UPN per line
foreach ($u in $scope) {
  Update-MgUser -UserId $u -AccountEnabled:$false            # first: no new tokens; CAE clients drop within minutes
  Revoke-MgUserSignInSession -UserId $u                      # then: refresh tokens and session cookies gone
  Confirm-MgRiskyUserCompromised -UserIds (Get-MgUser -UserId $u).Id   # Identity Protection: risk high, risk policies fire
}

T+0–15 · ContainBlock the attacker's source tenant-wide, with the break-glass accounts excluded

Why: a blocked source stops the same infrastructure reaching any other account while you are still scoping. Against residential-proxy kits the block buys hours, not days; "Confirm compromised" plus a user-risk policy is what holds.

Entra: Conditional Access › Named locations › add the attacker IPs, then a block policy for that location, all users, with the break-glass accounts excluded: an IP block that catches your own emergency access is the outage you cannot fix. On devices: Settings › Endpoints › Indicators › IP addresses (enforcement needs network protection in block mode), or via the API:

POST https://api.security.microsoft.com/api/indicators
{"indicatorValue": "<attacker IP>", "indicatorType": "IpAddress", "action": "Block",
 "title": "IR case #<n>", "description": "Attacker source, identity breach", "expirationTime": "<ISO date>"}

T+0–15 · ContainIsolate every victim device (infostealer or cookie theft)

Why: a device that is still stealing tokens re-compromises the account the moment it is re-enabled.

Device page › Isolate device (Full); Live Response keeps working. The API isolates one device per call — run it for each device in scope:

POST https://api.security.microsoft.com/api/machines/{id}/isolate
{"Comment": "IR case #<n>", "IsolationType": "Full"}

Day 1–7 · ForensicsCollect volatile data and a persistence snapshot, then rebuild the device

Why: the infection route and the token store live in RAM and the browser profile; once they are collected, a rebuild is the only way to be sure the stealer is gone.

Device page › Collect investigation package. For RAM, pagefile and the browser's live state, push Magnet RESPONSE 1.7.2 d315c63d1ad4b89e03c7688b29169e977c2abc4559c23b9f6b2282f3c91a6c7d with Live Response: putfile it from the library, run a one-line wrapper script that starts it elevated, getfile the output. Then Get-PersistenceSnapshot.ps1 a9534865f9e8e7b1f5077b4e1cfd8c29e7e32f6e9e49a4918ce4f8fb713e64df with -Zip (read-only; -CompareTo diffs against a baseline). Rebuild from a known-good image; release isolation only after the rebuild and after the Hour 1–4 check shows nothing since the revocation time.

By Incident Type: Specific Variations

The T+0–30 timeline above is the universal flow. These are the specific deltas per incident type — what you do differently when the breach is shaped like X.

1. Standard Account Takeover (credential reuse, infostealer log, password spray)

Indicators: successful sign-in from an atypical IP with AuthenticationRequirement singleFactorAuthentication and no AiTM artefacts. No MFA prompt means MFA was not required for that app, location or user (a Conditional Access gap or exclusion), or the user approved a push; a TOTP code cannot be replayed without a prompt. Basic authentication for EWS, POP, IMAP, ActiveSync and remote PowerShell has been off in Exchange Online since 2022–23; the one legacy path still subject to a retirement timeline is SMTP AUTH client submission, so check Get-CASMailbox <upn> | fl SmtpClientAuthenticationDisabled and Get-TransportConfig | fl SmtpClientAuthenticationDisabled before blaming "legacy protocols".

Variation: Follow the universal timeline. Add: block the source IP in a Conditional Access named location (break-glass excluded) and close the MFA gap that let a password alone through. Hunt for the same IP across all users in the tenant — password spray is rarely a single-user event — and check the user's credentials against infostealer-log feeds; a stealer log means the device is in scope too.

2. AiTM Proxy Phishing (Evilginx, Tycoon 2FA, Cloudflare Worker)

Indicators: successful sign-in WITH MFA passed but from atypical device fingerprint, often from a residential proxy IP. Session cookie was harvested mid-auth.

Variation: Token revocation is the only meaningful containment — the credential itself wasn't useful to the attacker, the cookie was. After eradication, accelerate FIDO2 rollout for the affected role — origin binding makes the proxy class structurally impossible.

3. Device-Code Phishing (Storm-2372, EvilTokens, FlowerStorm)

Indicators: authenticationProtocol = "deviceCode" in Entra sign-in logs, often successful with no MFA challenge (victim's session satisfied IdP), attacker's device immediately requests tokens.

Variation: Containment is identical — revoke sessions, kill device registrations, audit OAuth grants. Critical posture fix during T+4–24h: add Conditional Access policy restricting Device Authorization Grant flow to apps/users with legitimate need. Block entirely for privileged roles. This is config-only, no licensing. Response checklist: Device Code Abuse Response (12 steps). Detection and prevention: Device Code Abuse — 8 Sentinel hunts (including IP-divergence detection that catches the attacker's token use after the victim's auth) and the step-by-step CA policy build with Report-only rollout.

4. OAuth Consent Abuse (rogue app granted permissions)

Indicators: a new Enterprise App in Entra with broad scopes (Mail.Read, Files.ReadWrite.All, offline_access) consented by a regular user; access continues despite password reset.

Variation: Revoking the user's sessions and resetting the password is insufficient — the app holds its own refresh token. The app registration lives in the attacker's tenant and you cannot delete it; what is in your tenant is the service principal (the Enterprise App). Disable it first (Update-MgServicePrincipal -AccountEnabled:$false), which stops token issuance for every user who consented, export its grants and app-role assignments, remove them (Remove-MgOauth2PermissionGrant, Remove-MgServicePrincipalAppRoleAssignedTo), then delete the SP (Remove-MgServicePrincipal). Deleting first destroys the grant records. Check whether the same app id was consented by anyone else (Get-MgServicePrincipalOauth2PermissionGrant). After eradication: restrict user consent to verified publishers and low-impact scopes, require admin consent for the rest, and alert on "Consent to application".

5. Refresh-Token / PRT Persistence (post-password-reset re-entry)

Indicators: user reports their account "keeps getting taken over" despite password resets; sign-in logs show interactive token use with no fresh authentication; unexpected device in registered devices list.

Variation: The previous IR was incomplete — the account was revoked before it was disabled, the registered device wasn't removed, or a FOCI refresh token kept minting tokens for Office, Teams and Outlook from the attacker's machine. Re-run the full T+0–15 playbook in order (disable, revoke, devices, grants, export), and run the Stage 2 and Stage 9 hunts for IncomingTokenType == "primaryRefreshToken" from an unmanaged device and for the FOCI app ids from the attacker IP. Refresh-token lifetime cannot be reduced: those policies were retired in 2021 and the 90-day inactivity window is fixed. The levers are Conditional Access sign-in frequency for the user's apps, persistent browser session off, and Token Protection so the next stolen token is bound to the device it was issued to.

6. Non-Human Identity Compromise (API key, service principal, NHI)

Indicators: API key from CI/CD, package registry, AI platform (OpenAI/Anthropic), or cloud provider (AWS/Azure/GCP) appears in an infostealer log, paste site, or public GitHub repo. Or: anomalous activity from a service principal — new ASN, off-hours, unusual data volume.

Variation: No user to "reset" and no sessions to revoke: app-only tokens are minted from the credential, not from a refresh token, so Revoke-MgUserSignInSession has nothing to act on. Identify the named owner from your NHI inventory. Disable the service principal (Update-MgServicePrincipal -AccountEnabled:$false) so no new tokens are issued, remove every secret and certificate (Remove-MgServicePrincipalPassword, Remove-MgServicePrincipalKey; on the app object Remove-MgApplicationPassword, Remove-MgApplicationKey), then issue new ones and re-enable; an access token already issued lives out its 60–90 minutes. Audit AADServicePrincipalSignInLogs and MicrosoftGraphActivityLogs for the SP's calls by IP. If no inventory exists, the key may be orphaned — coordinate with engineering to identify systems calling it before the disable breaks production. After eradication: enforce a rotation policy (≤ 12 months for SP secrets, ≤ 6 months for API keys), prefer workload identity federation or managed identities over secrets, and assign a named owner per credential.

7. Privileged Account Compromise (Global Admin, Subscription Owner)

Indicators: any sign-in to an account holding Global Administrator, Privileged Role Administrator, Application Administrator, or cloud-subscription Owner role from an unexpected context.

Variation: Tenant-wide incident, not a user incident. Assume the attacker has tenant takeover capability and has already placed a back door that survives every per-user action. Containment escalates: disable then revoke every privileged identity (break-glass accounts excluded and their passwords rotated by hand), then audit the window for the doors that outlive a user: federation (Get-MgDomain | fl Id, AuthenticationType and Get-MgDomainFederationConfiguration: a new federated domain or a changed signing certificate is a golden-SAML persistence), pass-through authentication agents (a rogue PTA agent sees every password; list them in Entra Connect health and Get-MgBetaOnPremisesPublishingProfileAgent), the Entra Connect server itself (its sync account holds directory write rights; treat the server as tier 0 and in scope), GDAP and DAP partner relationships (Entra › Delegated admin partners; "Add partner to company" in the audit log), Conditional Access as a diff, not a glance (Get-MgIdentityConditionalAccessPolicy -All | ConvertTo-Json -Depth 10 against last week's export; look for new exclusions, named locations and report-only flips), the UAL ingestion switch (Get-AdminAuditLogConfig | fl UnifiedAuditLogIngestionEnabled and "Set-AdminAuditLogConfig" in the admin audit), transport rules and inbound connectors (Get-TransportRule, Get-InboundConnector, changed in the window), newly created accounts with roles, and credentials added to existing privileged apps (Hour 1–4). Engage external IR if available. Notify the regulator: this is often a reportable major incident under NIS2.

8. Vendor / Third-Party Identity Exposure (their breach, your problem)

Indicators: a vendor whose employees have access to your tenant via guest/B2B identity is breached; or vendor employees' credentials appear in an infostealer log with shared-app access to your environment.

Variation: You don't control their identity store, so you can't revoke at the source: refresh tokens for a B2B user are not revoked in your tenant, only in their home tenant (Microsoft identity platform, refresh tokens). What you control is your side of the trust. Block the guest objects (Update-MgUser -AccountEnabled:$false works on them), then use cross-tenant access settings (Entra › External Identities › Cross-tenant access settings): add the vendor's tenant as an organisation and set inbound B2B collaboration to blocked, or stop trusting their MFA and device claims so your Conditional Access re-challenges them. Scope the exposure by their tenant, not by user: in sign-in logs HomeTenantId == "<vendor tenant id>" and CrossTenantAccessType != "none" lists every vendor identity that touched you; in Get-MgServicePrincipal, AppOwnerOrganizationId equal to their tenant id lists every app of theirs that holds grants here. Restrict their app access to read-only during validation and contact their security team with specific exposure data (not generic "improve your security"). See Vendor Breach Response for the full vendor-IR workflow.

Microsoft Graph PowerShell — Containment

# 1. Disable (no new tokens; CAE critical event). Synced user: Disable-ADAccount + delta sync instead
Update-MgUser -UserId <upn> -AccountEnabled:$false

# 2. Revoke refresh tokens and session cookies; issued access tokens live 60-90 min (CAE: up to 28 h)
Revoke-MgUserSignInSession -UserId <upn>
Confirm-MgRiskyUserCompromised -UserIds (Get-MgUser -UserId <upn>).Id

# 3. MFA methods: list with ids, remove per type (no generic remove)
Get-MgUserAuthenticationMethod -UserId <upn> | Select-Object Id, AdditionalProperties
Remove-MgUserAuthenticationPhoneMethod -UserId <upn> -PhoneAuthenticationMethodId <id>
Remove-MgUserAuthenticationMicrosoftAuthenticatorMethod -UserId <upn> -MicrosoftAuthenticatorAuthenticationMethodId <id>
Remove-MgUserAuthenticationTemporaryAccessPassMethod -UserId <upn> -TemporaryAccessPassAuthenticationMethodId <id>

# 4. Registered devices added in the window: disable now, delete after the case
Get-MgUserRegisteredDevice -UserId <upn> -All |
  ForEach-Object { Get-MgDevice -DeviceId $_.Id } |
  Where-Object { $_.RegistrationDateTime -gt $compromiseStart } |
  ForEach-Object { Update-MgDevice -DeviceId $_.Id -AccountEnabled:$false }

# 5. OAuth: date consents from the audit log (startTime on the grant is ignored by Graph),
#    disable the SP, then remove grants and app-role assignments
$sp = Get-MgServicePrincipal -Filter "appId eq '<app id>'"
Update-MgServicePrincipal -ServicePrincipalId $sp.Id -AccountEnabled:$false
Get-MgUserOauth2PermissionGrant -UserId <upn> -All | Where-Object ClientId -eq $sp.Id |
  ForEach-Object { Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $_.Id }
Get-MgUserAppRoleAssignment -UserId <upn> -All | Where-Object ResourceId -eq $sp.Id |
  ForEach-Object { Remove-MgUserAppRoleAssignment -UserId <upn> -AppRoleAssignmentId $_.Id }

# 6. Reset (Hour 4-24), then re-enrol via a one-time TAP, re-enable, revoke again
Update-MgUser -UserId <upn> -PasswordProfile @{ ForceChangePasswordNextSignIn = $true; Password = "<new-temp>" }
New-MgUserAuthenticationTemporaryAccessPassMethod -UserId <upn> -BodyParameter @{ isUsableOnce = $true; lifetimeInMinutes = 60 }
Update-MgUser -UserId <upn> -AccountEnabled:$true
Revoke-MgUserSignInSession -UserId <upn>

Exchange / Sign-in Hunting

# Inbox rules across all mailboxes, hidden ones included (BEC persistence)
Get-Mailbox -ResultSize Unlimited |
  Get-InboxRule -IncludeHidden | Where-Object {
    $_.ForwardTo -or $_.RedirectTo -or $_.ForwardAsAttachmentTo -or $_.DeleteMessage -or $_.MoveToFolder
  }

# Forwarding (separate persistence channel)
Get-Mailbox -ResultSize Unlimited |
  Where-Object { $_.ForwardingSmtpAddress -or $_.ForwardingAddress } |
  Format-List PrimarySmtpAddress, ForwardingSmtpAddress, ForwardingAddress, DeliverToMailboxAndForward

# Unified audit log export that does not truncate (ReturnLargeSet pages to 50,000)
$sid = "IR-<n>"; do {
  $page = Search-UnifiedAuditLog -StartDate $compromiseStart -EndDate (Get-Date) -UserIds <upn> `
    -SessionId $sid -SessionCommand ReturnLargeSet -ResultSize 5000
  $page | Export-Csv .\ual-<upn>.csv -NoTypeInformation -Append
} while ($page)

# Message trace: 10 days per query, 90 days back
Get-MessageTraceV2 -SenderAddress <upn> -StartDate <start> -EndDate <end> -ResultSize 5000

# Device-code phishing hunt (KQL, Sentinel); redemptions are non-interactive
union SigninLogs, AADNonInteractiveUserSignInLogs
| where AuthenticationProtocol == "deviceCode"
| project TimeGenerated, UserPrincipalName, IPAddress, AppDisplayName, ResultType

# Tenant-wide admin consents (AllPrincipals)
Get-MgOauth2PermissionGrant -All -Filter "consentType eq 'AllPrincipals'" |
  Select-Object ClientId, ResourceId, Scope

# Role, eligible-role and group-membership changes in the window, every page
Get-MgAuditLogDirectoryAudit -All `
  -Filter "activityDateTime ge $start and (category eq 'RoleManagement' or category eq 'GroupManagement')"

Free / Open-Source Tools for Identity IR

  • CISA ScubaGear — M365 / Entra compliance baseline (run during T+24–48h hardening)
  • Cazadora — OAuth application hunting in M365
  • O365 Investigation Tooling — archived; DumpDelegatesandForwardingRules.ps1 and RemediateBreachedAccount.ps1 depend on the retired MSOnline and AzureAD modules and no longer run. Use the native Exchange Online and Graph cmdlets in the containment reference above, and keep that script tested in your own tenant instead
  • Check by CyberDrain — AiTM proxy detection browser extension (post-IR rollout)

Engagement Options

Three ways to use this playbook depending on where you are in the incident lifecycle.
When Engagement Duration Investment
In an active incident Active IR assist — remote hands on the T+0–72h timeline. Direct work on containment, scope, eradication. Hand-off to internal team for posture work. 2–5 days €5,000 – 12,500
Post-incident Post-mortem + runbook codification + detection-rule updates + tabletop. Make sure the same incident shape cannot recur. 3–5 days €3,750 – 6,250
Pre-incident Identity-IR readiness: scripts pre-staged, named owners, tabletop, runbook delivered. Test Revoke-MgUserSignInSession against a real tenant before you need it at 3 AM. 2–3 days €2,500 – 3,750
Pre-incident readiness is the cheapest engagement and the one that pays off most. The first time you run Revoke-MgUserSignInSession should not be at 3 AM during a real incident. Run it on a test account during business hours first. Pre-stage the scripts. Document the named owners. Then when the alert fires, the response is muscle memory, not improvisation.