Device Code Abuse Response

Device-code phishing confirmed or suspected — the containment checklist

Preparing, not responding? Readiness, detection and hardening are on Device Code Abuse. This page is for when it is happening.

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 whether revoking the user's sessions is enough, or a device, an app consent and other users must be contained too.
#StageATT&CKQuestionWhere to look
1LureT1566.002 T1566.003Who received the device-code lure, when, and through which channel?EmailUrlInfo with a devicelogin or deviceauth URL; UrlClickEvents; Teams MessageEvents from external threads; failed deviceCode sign-ins (the code expired or the CA block caught it); user reports
2Code enteredT1528Which users completed a device-code sign-in?SigninLogs where AuthenticationProtocol == "deviceCode" and ResultType == "0" (a string)
3Token useT1550.001Did the attacker use the tokens from another IP, and under which family apps?Interactive and non-interactive sign-ins for the same user from a different IP and ASN within 48 h, grouped by AppId (the refresh token is FOCI-scoped: Office, Azure CLI, Broker, VS Code, Teams)
4Device registrationT1098.005Did they register a device to get a Primary Refresh Token?Entra audit log: Register device, Add device, Add registered owner to device; deviceCode sign-ins with the Authentication Broker AppId; primaryRefreshToken sign-ins from an unknown device id
5ConsentT1528 T1098.001Did they consent an OAuth app, or add credentials to one?Entra audit log: Consent to application, Add delegated permission grant, Add service principal, Add service principal credentials; Cazadora
6Data accessT1114.002 T1213.002What mail and files did they read?Unified Audit Log (MailItemsAccessed, FileDownloaded, SearchQueryInitiated), by ClientIP and ClientAppId; MicrosoftGraphActivityLogs joined on the sign-in's UniqueTokenIdentifier
7OutboundT1534Did they phish others from the account?EmailEvents with the account as sender; Send, SendAs and Teams MessageSent audit events by ClientIP; message trace
8SpreadT1528Did other users complete the same flow, or sign in from the attacker's range?Successful deviceCode sign-ins across the tenant; any sign-in from the attacker's ASN (not only the IP)
9Still activeT1550.001Is any token still in use after containment?Refused refreshes from the attacker's ASN (50057 user disabled, 50173 token revoked) prove containment; any success, Graph call or mailbox read after the revocation time plus one hour is a live channel
Incident response

If Device Code abuse is suspected

Containment is identical to other identity compromises: revoke sessions, remove registered devices, revoke OAuth grants. The full T+0–30 day timeline lives in Identity Breach Response — this section is the Device-Code-specific checklist.
Do not start with a password reset. An admin reset revokes password-based refresh tokens for a cloud-managed user, but a token issued through SSO, passwordless or a federated IdP survives it, and the device-code token is one of those. A refresh token lives on a sliding 90-day inactivity window, not a 90-day cap: every use extends it. Disable the account, revoke sessions, then reset. Issued access tokens stay valid until they expire, about one hour; CAE shortens that to minutes only for CAE-capable clients (Office, Teams, Outlook, Edge), and the attacker's tooling (roadtx, TokenTactics, the kit's own poller) is not one.
Contain at least as wide as the attacker could be. Steps 1–7 are one containment action, done together in minutes — not revoke now, find the device and the consented app later. A registered device or a consented app left live while sessions are revoked tells the attacker they've been found and leaves them a way back in. Device-code campaigns rarely hit one user, so step 7 goes broad by default.
0. Export the logs for the window before anything is revoked
Containment writes new rows and the sources age out on their own: Entra keeps sign-in and audit logs 30 days on P1/P2 and 7 days on the free tier; the Unified Audit Log 180 days on Audit Standard, one year on Premium; mailbox audit 90 days by default. MicrosoftGraphActivityLogs exists only in a Sentinel workspace with the diagnostic setting on. Pull sign-ins (interactive and non-interactive), audit logs, the UAL and the Graph activity for the window, hash the files, and record the hashes in the case. Why: the stage 9 verdict and any notification decision are made on these rows weeks later, when the live tenant no longer has them. Cmdlets are in the Defender tab's "Preserve" step.
Get-MgAuditLogSignIn -Filter "userPrincipalName eq '<upn>' and createdDateTime ge <start>Z" -All | Export-Csv signins.csv
Get-MgBetaAuditLogSignIn -Filter "userPrincipalName eq '<upn>' and signInEventTypes/any(t: t eq 'nonInteractiveUser')" -All | Export-Csv noninteractive.csv
Get-MgAuditLogDirectoryAudit -Filter "activityDateTime ge <start>Z" -All | Export-Csv auditlogs.csv
Search-UnifiedAuditLog -StartDate <start> -EndDate <end> -UserIds <upn> -SessionCommand ReturnLargeSet -ResultSize 5000 | Export-Csv ual.csv
1. Disable the account, then revoke sessions and refresh tokens
Disable first: the attacker's next refresh fails with 50057. Then revoke: anything that survived the disable fails with 50173, and session cookies die with it. Those two failure codes from the attacker's ASN are what step 8 looks for. Mark the user compromised as well, so risk-based Conditional Access and Identity Protection react. Re-authentication is forced at the next access-token expiry, about one hour for the attacker's non-CAE tooling. Why: revoking first leaves a window in which a refresh from the still-enabled account can succeed before the disable lands.
Update-MgUser -UserId <upn> -AccountEnabled:$false
Revoke-MgUserSignInSession -UserId <upn>
Confirm-MgRiskyUserCompromised -UserIds @((Get-MgUser -UserId <upn>).Id)
2. Keep the account disabled; reset the password only after investigation
Buys investigation time without giving the attacker a reset notification window. Re-enable only after step 8 shows refused refreshes and no success since the revocation time, and after MFA re-registration from a Temporary Access Pass. The Defender portal actions (Disable user, Revoke session, Mark user as compromised) run in Entra and need an Entra role; the Entra SOC Identity Responder built-in role (introduced July 2026) carries exactly these without User Administrator.
3. Export the MFA methods, remove what the user did not enrol, issue a TAP
The attacker may have enrolled a second Authenticator or phone with the stolen session. Export the full method list first, remove anything the user did not enrol (the "User registered security info" audit event dates each one), then issue a one-time Temporary Access Pass for re-registration rather than trusting a method the attacker could also hold.
Get-MgUserAuthenticationMethod -UserId <upn> | Export-Csv mfa-<upn>.csv
Remove-MgUserAuthenticationMicrosoftAuthenticatorMethod -UserId <upn> -MicrosoftAuthenticatorAuthenticationMethodId <id>
Remove-MgUserAuthenticationPhoneMethod -UserId <upn> -PhoneAuthenticationMethodId <id>
New-MgUserAuthenticationTemporaryAccessPassMethod -UserId <upn> -BodyParameter @{ lifetimeInMinutes = 60; isUsableOnce = $true }
4. Export, then disable, the devices registered in the access window
A registered device produces a Primary Refresh Token enabling silent SSO across the tenant. Since early 2025 the kits request the device code as the Microsoft Authentication Broker, so the device and the PRT arrive without a second lure (Microsoft Threat Intelligence, Storm-2372, February 2025). Export the device object first — registrationDateTime, trustType, operatingSystem and the OS build are the attribution evidence — then disable it. The PRT renews about every four hours, so the disable is not instant; the stage 4 PRT hunt confirms when it bit. Disable only the devices from the window; disabling all of them locks out the user's own machines.
Get-MgDevice -DeviceId <device object id> -Property Id,DeviceId,DisplayName,RegistrationDateTime,TrustType,OperatingSystem,OperatingSystemVersion,IsCompliant,IsManaged | Export-Csv device.csv
Update-MgDevice -DeviceId <device object id> -AccountEnabled:$false
5. Disable any consented app and revoke its grants from the access window
A consented app keeps its own access to mail and files after the user's sessions are revoked. A stolen refresh token alone does not get the attacker there: a user without an admin role cannot create a permission grant through Graph, so consent usually needs a second, consent-phishing lure, or a tenant that allows user consent. Export the grants, then disable the app's service principal (one action that stops its token path) and remove the grants in the same pass as steps 1–4. Get-MgUserOauth2PermissionGrant lists only this user's own consents; an admin-consented grant on the same app does not appear there, so query the grant by client id too, and check the app's own application permissions.
Get-MgOauth2PermissionGrant -Filter "clientId eq '<app service principal id>'" -All
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId <app service principal id> -All
Update-MgServicePrincipal -ServicePrincipalId <app service principal id> -AccountEnabled:$false
Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId <grant id>
6. Litigation hold, export, then purge inbox rules and SMTP forwarding
The #1 BEC persistence mechanism. Attacker creates rules redirecting finance / IT / security replies into hidden folders so the legitimate user never sees them. Also check ForwardingSmtpAddress and ForwardingAddress. Put the mailbox on litigation hold first (needs Exchange Online Plan 2 or the Archiving add-on), export the rules, then remove them — a purged rule is destroyed evidence, and its shape drives the tenant-wide hunt.
Set-Mailbox -Identity <upn> -LitigationHoldEnabled $true -LitigationHoldDuration 365
Get-InboxRule -Mailbox <upn> -IncludeHidden | Export-Clixml inboxrules-<upn>.xml
7. Go broad: block Device Code flow tenant-wide, contain every account seen from the attacker
If the tenant doesn't already block Device Code flow, block it now in Conditional Access for everyone except the exception group — that single policy cuts the campaign's entry path for every user it may have reached. Build the exception group first, from 30 days of successful deviceCode sign-ins by app and user (the Defender tab's "Scope the exception group" query); without it, expect Azure CLI on headless hosts and CI runners, kubelogin, Azure Arc onboarding, Teams Rooms and Surface Hub, Android phones in Teams and Outlook, and printers that mail to M365 to break the moment the policy is enabled. Then check for other accounts with successful device-code sign-ins or any sign-in from the attacker's ASN (not only the IP) since the campaign started, and give each one steps 1–6. Broad containment costs some friction for the few legitimate device-code users; narrow containment against a multi-user campaign costs the eviction.
8. Verify the containment held
The account is disabled, so "no successes since revocation" is empty by construction and proves nothing. Run the Stage 9 · Still active hunts and look for two things: refused refreshes from the attacker's ASN (50057 user disabled, 50173 token revoked, carrying a refreshToken or primaryRefreshToken), which show the attacker tried and failed; and any success, Graph call or mailbox read more than an hour after the revocation time, for any account in scope, which means a channel was missed — usually a device object or an app grant. Widen the containment before moving on. The attacker is out when the refusals are there and the successes are not.
9. Review recent downloads from SharePoint / OneDrive
If the harvested tokens had Files.Read.All, the attacker likely ran a bulk download sweep within minutes. Cross-reference the FileDownloaded, FileSyncDownloadedFull and SearchQueryInitiatedSharePoint audit events with the access window, by ClientIP and ClientAppId; the stage 6 Graph-activity hunt lists the exact calls per stolen token.
10. Review Sent Items and mailbox-access events
Lateral phishing from the compromised account to adjacent users is the most common follow-on. MailItemsAccessed and Send have been in Audit Standard since late 2023, so every tenant has them; do not wait for an E5 licence to run the stage 6 and 7 hunts. Exchange stops writing MailItemsAccessed for 24 hours after 1,000 records from one mailbox (IsThrottled), so a throttled row means more than you can see. Any internal recipient who interacted gets steps 1–6, not a later triage.
11. Pull Entra audit logs for app consent / role changes
Did the attacker grant themselves a role, add a credential to a service principal, or consent to an app beyond the one revoked in step 5? Key on Add member to role, Add service principal, Add service principal credentials, Add delegated permission grant and Update application – Certificates and secrets management for the window; the stage 5 hunt in each tab runs this. These are the persistence mechanisms that survive everything else — anything found here goes back through step 8.
12. Hunt tenant-wide for the same TTP
Re-run Alerts 5 (IP divergence), 6 (multi-user campaign), and 7 (mailbox actions) across the past 30 days, scoped to the source IP and AppId from this incident. The attacker rarely targets just one user.
13. Turn the attacker's signature into tested detections
OAuth AppId, inbox rule shape, Device Code success signature, source IP pattern — convert each into a detection that fires on recurrence. Test each against a week of known-good sign-ins before it goes live, and give it an owner and an expiry: attacker IPs rotate within days, so an IP rule without an expiry becomes noise. Anything shared beyond your tenant goes through an analyst first, with a date, a source and a confidence level.
14. Close the policy gap that allowed it
If the user's Device Code flow was not blocked at the CA layer, this is the gap — turn the step 7 emergency block into the permanent policy. Re-run the policy build from Mitigation with the affected user in scope. If the user was in the exception group without a documented business case, that's the second gap.

Hunt & Act by Platform

What to look for in your EDR or SIEM for each stage of the device-code 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 fed by the Microsoft Entra ID and Microsoft 365 connectors (SigninLogs, AADNonInteractiveUserSignInLogs, AuditLogs, OfficeActivity, MicrosoftGraphActivityLogs) and the Defender XDR tables (EmailEvents, EmailUrlInfo, UrlClickEvents, MessageEvents). Windows: a Sentinel workspace keeps these for its retention setting (90 days free); without one, Entra itself keeps sign-ins and audit logs 30 days on P1/P2 and 7 days on the free tier, and the Unified Audit Log keeps 180 days on Audit Standard, one year on Premium. MicrosoftGraphActivityLogs exists only in a workspace, and only once the diagnostic setting is on. ResultType is a string: compare with "0", not 0.

Hunt

Stage 1 · LureLure delivery and clicks (mail)

Why: The lure links to Microsoft's own sign-in page, so URL reputation never fires; the signal is a devicelogin or deviceauth URL arriving by mail at all. Every recipient is a candidate for stage 2, clicked or not, because the code can be typed from the message body.
let lure = dynamic(["microsoft.com/devicelogin", "oauth2/deviceauth", "/oauth2/v2.0/devicecode"]);
EmailUrlInfo
| where Timestamp > ago(30d) and Url has_any (lure)
| join kind=inner (EmailEvents
    | project NetworkMessageId, RecipientEmailAddress, SenderFromAddress, SenderMailFromDomain, Subject, DeliveryAction)
    on NetworkMessageId
| join kind=leftouter (UrlClickEvents
    | where Timestamp > ago(30d)
    | project NetworkMessageId, ClickTime = Timestamp, ClickedBy = AccountUpn, ActionType)
    on NetworkMessageId
| project Timestamp, RecipientEmailAddress, SenderFromAddress, SenderMailFromDomain, Subject, Url, DeliveryAction, ClickTime, ClickedBy, ActionType
| order by Timestamp asc
T1566.002 Tuning: IT and developer teams mail each other devicelogin links for Azure CLI and VS Code set-up. Keep hits from external senders, newly registered domains and mail that also carries a nine-character code in the body; drop internal sender-to-self mail.

Stage 1 · LureLure delivery in Teams external chats

Why: Storm-2372 and its copycats send the lure as a Teams invitation from a look-alike tenant; the mail hunt never sees it. MessageEvents and MessageUrlInfo need Defender for Office 365 Teams protection enabled.
MessageEvents
| where Timestamp > ago(30d) and IsExternalThread == true
| join kind=inner (MessageUrlInfo
    | where Url has_any ("devicelogin", "deviceauth", "devicecode"))
    on TeamsMessageId
| project Timestamp, SenderEmailAddress, SenderDisplayName, RecipientDetails, ThreadType, Url
| order by Timestamp asc
T1566.003 Tuning: Federated partners and MSPs legitimately share Azure CLI instructions in external chats; compare the sender tenant with your allowed federation list before you treat it as a lure.

Stage 1 · LureDevice-code attempts that did not complete (reach of the lure)

Why: A code that expired before it was entered, or a user the Conditional Access block caught, still shows the lure reached them. These rows are the campaign's target list beyond the successes in stage 2; each user on it gets a check for a second attempt.
SigninLogs
| where TimeGenerated > ago(30d)
| where AuthenticationProtocol =~ "deviceCode" and ResultType != "0"
| summarize Attempts = count(), First = min(TimeGenerated), Last = max(TimeGenerated),
            Codes = make_set(ResultType), Reasons = make_set(ResultDescription, 5)
    by UserPrincipalName, AppDisplayName, AppId
| order by First asc
// 53003 = blocked by Conditional Access (the step 7 policy working); 50058 = code entered without a
// completed sign-in; 70016/70020 = the client polled past the code's lifetime.
T1528 Tuning: After step 7 is live, every legitimate Azure CLI and kubelogin user produces 53003 rows; filter the exception group's apps out and read the rest as reach.

Stage 2 · Code enteredSuccessful device-code sign-ins

Why: Every user on this list may have handed over tokens: this is the containment scope. The IP on these events is the victim's browser, not the attacker's; the polling client's user agent (often python-requests or a bare Go client) is the first attacker artefact.
SigninLogs
| where TimeGenerated > ago(30d)
| where AuthenticationProtocol =~ "deviceCode" and ResultType == "0"
| project TimeGenerated, UserPrincipalName, AppDisplayName, AppId, ResourceDisplayName,
          IPAddress, AutonomousSystemNumber, UserAgent, SessionId, UniqueTokenIdentifier, CorrelationId
| order by TimeGenerated asc
T1528 Tuning: Azure CLI on a headless host, kubelogin, Arc onboarding, Teams Rooms and printers sign in with device code every day. Build the exception list from 30 days of these rows by AppId and user, then treat anything outside it, or any new AppId for a known user, as a hit.

Stage 3 · Token useToken use from another IP after the device-code sign-in, including FOCI app switches

Why: The IP that redeems and refreshes the tokens is the attacker's: it is the indicator for every later hunt and for the tenant-wide spread search. The refresh token is family-scoped (FOCI), so the attacker swaps it to Office, Azure CLI, Azure PowerShell, the Authentication Broker, VS Code or Teams and the non-interactive rows carry a different AppId. Join on the user, not the app. CAE-capable tokens can run 24 hours before the first refresh, so the window is 48 hours, not six.
let foci = dynamic(["d3590ed6-52b3-4102-aeff-aad2292ab01c",   // Microsoft Office
                    "04b07795-8ddb-461a-bbee-02f9e1bf7b46",   // Azure CLI
                    "1950a258-227b-4e31-a9cf-717495945fc2",   // Azure PowerShell
                    "29d9ed98-a469-4536-ade2-f981bc1d605e",   // Microsoft Authentication Broker
                    "aebc6443-996d-45c2-90f0-388ff96faa56",   // Visual Studio Code
                    "1fec8e78-bce4-4aaf-ab1b-5451cc387264"]); // Microsoft Teams
let dc = SigninLogs
  | where TimeGenerated > ago(30d)
  | where AuthenticationProtocol =~ "deviceCode" and ResultType == "0"
  | project UserPrincipalName, DC_Time = TimeGenerated, DC_IP = IPAddress, DC_App = AppId, DC_Session = SessionId;
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d) and ResultType == "0"
| join kind=inner dc on UserPrincipalName
| where TimeGenerated between (DC_Time .. DC_Time + 48h) and IPAddress != DC_IP
| extend Family = iff(AppId in (foci), "FOCI", "other"), SameSession = SessionId == DC_Session
| summarize FirstUse = min(TimeGenerated), Uses = count(), Resources = make_set(ResourceDisplayName, 10),
            TokenTypes = make_set(IncomingTokenType), SameSession = max(SameSession)
    by UserPrincipalName, DC_IP, AttackerIP = IPAddress, AttackerASN = AutonomousSystemNumber, AppId, AppDisplayName, Family
| order by UserPrincipalName asc, FirstUse asc
T1550.001 Tuning: The user's own phone moving from Wi-Fi to cellular, a VPN reconnect and Microsoft service IPs all show as a second IP within minutes, usually on the same SessionId and a mobile ASN. Rank by ASN (hosting, VPS and residential-proxy ranges first) and by apps the user never used before, not by count.

Stage 4 · Device registrationDevice objects registered by the victim account in the window

Why: A device the attacker registers lets them request a Primary Refresh Token for silent sign-in; it has to be disabled in Entra, separately from revoking the user's sessions. Registration writes three audit activities, not one; key on all of them.
AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName in ("Register device", "Add device", "Add registered owner to device", "Add registered users to device")
| extend Actor = coalesce(tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName)),
         ActorIP = tostring(InitiatedBy.user.ipAddress),
         Device = tostring(TargetResources[0].displayName), DeviceObjectId = tostring(TargetResources[0].id),
         Changes = tostring(TargetResources[0].modifiedProperties)
| where Actor =~ "<upn>" or Changes has "<upn>"
| project TimeGenerated, OperationName, Result, Actor, ActorIP, Device, DeviceObjectId, Changes
| order by TimeGenerated asc
// Changes carries the OS build and trust type; a Windows 10.0.19045 build with no MDM and a hostname that
// does not match your naming standard is the phishing-kit default.
T1098.005 Tuning: Intune autopilot and the user's own new laptop produce the same three activities; the attacker's device has no MDM enrolment, an ActorIP from the attacker ASN and a registration minutes after the device-code event.

Stage 4 · Device registrationDevice code requested directly as the Authentication Broker (PRT path)

Why: Since early 2025 the kits request the code with the Microsoft Authentication Broker client (29d9ed98) for the Device Registration Service, so the stolen token registers a device and mints a PRT without any further lure (Microsoft Threat Intelligence, Storm-2372, February 2025). A deviceCode sign-in to the broker is never a normal user action.
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d)
| where AppId == "29d9ed98-a469-4536-ade2-f981bc1d605e"
| where AuthenticationProtocol =~ "deviceCode" or ResourceDisplayName == "Device Registration Service"
| project TimeGenerated, Type, UserPrincipalName, AuthenticationProtocol, ResourceDisplayName, ResultType,
          IPAddress, AutonomousSystemNumber, IncomingTokenType, UserAgent, SessionId
| order by TimeGenerated asc
T1098.005 T1528 Tuning: Windows Hello for Business enrolment and a genuine Entra join also call the Device Registration Service from the broker, from the corporate network with a Windows user agent. The deviceCode protocol on the broker AppId has no legitimate source.

Stage 4 · Device registrationPRT use from a device registered in the window

Why: Once the attacker's device holds a PRT it is "compliant enough" for device-based Conditional Access and shows up as primaryRefreshToken sign-ins from a device id you have never seen. The PRT renews roughly every four hours, so disabling the device object is not instant: this hunt is also the check that the disable bit.
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d) and UserPrincipalName =~ "<upn>"
| where IncomingTokenType == "primaryRefreshToken"
| extend DeviceId = tostring(DeviceDetail.deviceId), DeviceName = tostring(DeviceDetail.displayName),
         OS = tostring(DeviceDetail.operatingSystem), TrustType = tostring(DeviceDetail.trustType),
         IsCompliant = tostring(DeviceDetail.isCompliant), IsManaged = tostring(DeviceDetail.isManaged)
| summarize First = min(TimeGenerated), Last = max(TimeGenerated), SignIns = count(), Apps = make_set(AppDisplayName, 10)
    by DeviceId, DeviceName, OS, TrustType, IsCompliant, IsManaged, IPAddress, AutonomousSystemNumber
| order by First asc
T1098.005 T1550.001 Tuning: Every PRT sign-in from the user's real Entra-joined laptop lands here; compare DeviceId with the device objects from the audit hunt and with Intune's inventory, and keep the ones registered inside the window.

Stage 5 · ConsentConsent, permission grants and service-principal changes by the victim account

Why: A consented app keeps its own access to mail and files after the user's sessions are revoked. A refresh token alone does not get the attacker there: a user without an admin role cannot create a permission grant through Graph, so consent usually needs a second, consent-phishing lure, or the tenant allows user consent and the attacker drives the prompt with the stolen session. The same query answers step 11: roles added, credentials added to an existing service principal, new service principals.
AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName in ("Consent to application", "Add delegated permission grant", "Add app role assignment grant to user",
                          "Add service principal", "Add service principal credentials", "Add member to role",
                          "Add eligible member to role")
    or OperationName has "Certificates and secrets management"
| extend Actor = coalesce(tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName)),
         ActorIP = tostring(InitiatedBy.user.ipAddress),
         Target = tostring(TargetResources[0].displayName), TargetId = tostring(TargetResources[0].id),
         Changes = tostring(TargetResources[0].modifiedProperties)
| where Actor =~ "<upn>" or Changes has "<upn>"
| project TimeGenerated, OperationName, Result, Actor, ActorIP, Target, TargetId, Changes
| order by TimeGenerated asc
// ConsentAction.Permissions inside Changes lists the scopes; offline_access plus Mail.Read or Files.Read.All
// from an unverified publisher is the pattern.
T1528 T1098.001 Tuning: Admin consent by your own identity team and "Add delegated permission grant" for Microsoft first-party apps fire constantly; keep rows where the actor is the victim, the ActorIP is in the attacker ASN, or the target app has no verified publisher.

Stage 6 · Data accessMail, file, search and Teams reads by the victim account, by client and source IP

Why: Access from the attacker's IP is the evidence legal and privacy need to decide whether this is a notifiable breach. MailItemsAccessed is in Audit Standard since late 2023, so every tenant has it; SearchQueryInitiated and the Teams read operations still need Audit Premium. The client columns separate the attacker's Graph or EWS tooling from Outlook.
OfficeActivity
| where TimeGenerated > ago(30d) and UserId =~ "<upn>"
| where Operation in ("MailItemsAccessed", "FileDownloaded", "FileAccessed", "FileSyncDownloadedFull",
                      "SearchQueryInitiatedExchange", "SearchQueryInitiatedSharePoint",
                      "MessagesListed", "ChatRetrieved", "MessageRead")
| extend Props = tostring(OperationProperties),
         ClientApp = coalesce(ClientAppId, AppId, ClientInfoString)
| extend AccessType = extract(@'"Name":"MailAccessType","Value":"(\w+)"', 1, Props),
         Throttled = Props contains '"Name":"IsThrottled","Value":"True"'
| summarize Events = count(), First = min(TimeGenerated), Last = max(TimeGenerated),
            Folders = make_set(Folders, 10)
    by OfficeWorkload, Operation, ClientIP, ClientApp, AccessType, Throttled
| order by First asc
// ClientIP on Exchange rows can be "[ip]:port"; Client_IPAddress is the bare form.
T1114.002 T1213.002 Tuning: The owner's own clients dominate MailItemsAccessed; split by ClientIP and ClientApp first. Outlook in cached mode and mobile mail legitimately show Sync. Exchange stops writing MailItemsAccessed for 24 hours after 1,000 records from one mailbox (IsThrottled = True), so a throttled row means "more than you can see", and the user's own full-mailbox search trips the same throttle.

Stage 6 · Data accessGraph API calls made with the stolen tokens

Why: Attacker tooling reads mail and files through Microsoft Graph; those calls never appear in sign-in logs once the access token is issued. SignInActivityId equals the UniqueTokenIdentifier of the sign-in that minted the token, so you can list exactly what each stolen token was used for. UserId here is the object id, not the UPN.
let tokens = union SigninLogs, AADNonInteractiveUserSignInLogs
  | where TimeGenerated > ago(30d) and UserPrincipalName =~ "<upn>"
  | where IPAddress in ("<attacker ip>") or AutonomousSystemNumber in (<attacker asn>)
  | distinct UniqueTokenIdentifier;
MicrosoftGraphActivityLogs
| where TimeGenerated > ago(30d)
| where SignInActivityId in (tokens) or IPAddress in ("<attacker ip>")
| extend Path = tostring(parse_url(RequestUri).Path)
| summarize Calls = count(), First = min(TimeGenerated), Last = max(TimeGenerated),
            Status = make_set(ResponseStatusCode), Scopes = make_set(Scopes, 5)
    by RequestMethod, Path, AppId, IPAddress, UserAgent
| order by First asc
T1114.002 T1213.002 T1550.001 Tuning: Nothing legitimate shares a token id with an attacker sign-in; the only noise is Microsoft's own first-party calls on the IP filter when the "attacker" IP turns out to be a shared egress. Confirm the ASN before you pivot on IP alone.

Stage 7 · OutboundMail sent from the account (Defender for Office 365)

Why: Lateral phishing from the compromised mailbox is the most common follow-on. Recipient count per hour and the subject set show whether the account became a lure sender; every internal recipient who clicked gets the same containment.
EmailEvents
| where Timestamp > ago(30d)
| where SenderFromAddress =~ "<upn>" or SenderMailFromAddress =~ "<upn>"
| where EmailDirection in ("Outbound", "Intra-org")
| summarize Messages = count(), Recipients = dcount(RecipientEmailAddress),
            Sample = make_set(RecipientEmailAddress, 20), Subjects = make_set(Subject, 10)
    by bin(Timestamp, 1h), EmailDirection
| order by Timestamp asc
T1534 Tuning: Assistants, shared mailboxes and newsletter senders mail many recipients every day; compare the recipient set and subjects with the 30 days before the window, not with zero.

Stage 7 · OutboundSend, SendAs and Teams messages by client and source IP (Unified Audit Log)

Why: The audit log ties each send to the client and IP that made it, which the message trace cannot; a Send from the attacker's ASN through a Graph client is the proof, and a Teams MessageSent burst to external threads is the lure moving on. Send moved into Audit Standard with MailItemsAccessed.
OfficeActivity
| where TimeGenerated > ago(30d) and UserId =~ "<upn>"
| where Operation in ("Send", "SendAs", "SendOnBehalf", "MessageSent")
| summarize Events = count(), First = min(TimeGenerated), Last = max(TimeGenerated), Threads = dcount(ChatThreadId)
    by OfficeWorkload, Operation, ClientIP, ClientInfoString, CommunicationType
| order by First asc
T1534 Tuning: Delegates with SendAs on shared mailboxes produce SendAs rows from the office egress all day; the row that matters has the attacker's ClientIP or a client string you have never seen on this mailbox.

Stage 8 · SpreadOther users: successful device-code sign-ins across the tenant

Why: Campaigns target many users at once; every account found here needs the same containment. A new AppId or a first-ever device-code sign-in for a user is the signal, not the sign-in itself.
SigninLogs
| where TimeGenerated > ago(30d)
| where AuthenticationProtocol =~ "deviceCode" and ResultType == "0"
| summarize First = min(TimeGenerated), Last = max(TimeGenerated), SignIns = count(),
            Apps = make_set(AppDisplayName), ASNs = make_set(AutonomousSystemNumber), Agents = make_set(UserAgent, 5)
    by UserPrincipalName
| order by First asc
T1528 Tuning: Exclude the step 7 exception group's users and apps; a user whose only device-code sign-ins are Azure CLI from the corporate ASN over months is the baseline, not the campaign.

Stage 8 · SpreadAnyone else seen from the attacker's ASN

Why: Kits rotate IPs inside one hosting or residential-proxy range within days, so a single-IP search clears nobody. Pivot on the ASN from stage 3, then narrow to the IP once you have the list.
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d)
| where AutonomousSystemNumber in (<attacker asn>) or IPAddress in ("<attacker ip>")
| summarize Users = make_set(UserPrincipalName), First = min(TimeGenerated), Last = max(TimeGenerated),
            Apps = make_set(AppDisplayName, 10), Outcomes = make_set(ResultType), TokenTypes = make_set(IncomingTokenType)
    by AutonomousSystemNumber, IPAddress
| order by First asc
T1550.001 T1078.004 Tuning: A large cloud or carrier ASN (Microsoft 8075, AWS 16509, a national mobile network) puts legitimate users behind the same number; pivot on ASN only for hosting, VPS and proxy ranges, otherwise on the /24.

Stage 9 · Still activeRefused and successful token use after the revocation time

Why: The account is disabled at step 2, so a "successes after revocation" query is empty by construction and proves nothing. The proof of containment is the attacker's refresh attempts failing: 50057 (user disabled) and 50173 (token revoked) from the attacker ASN, carrying a refreshToken or primaryRefreshToken. A ResultType "0" after the revocation time is a live channel, usually a device object or a consented app.
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > datetime(<revocation time, UTC>)
| where AutonomousSystemNumber in (<attacker asn>) or IPAddress in ("<attacker ip>")
      or UserPrincipalName in~ ("<upn1>", "<upn2>")
| where IncomingTokenType in ("refreshToken", "primaryRefreshToken") or ResultType == "0"
| extend Verdict = case(ResultType == "0", "LIVE: success after revocation",
                        ResultType in ("50057", "50173", "70008", "50133", "53003"), "contained: refused",
                        "other failure")
| summarize Attempts = count(), Last = max(TimeGenerated), Codes = make_set(ResultType),
            TokenTypes = make_set(IncomingTokenType), Apps = make_set(AppDisplayName, 10)
    by Verdict, UserPrincipalName, IPAddress, AutonomousSystemNumber
| order by Verdict asc, Last desc
// 50057 user disabled; 50173 refresh token revoked; 70008 refresh token expired; 50133 session invalid
// after password change; 53003 blocked by Conditional Access.
T1550.001 Tuning: The user's own clients also fail with 50057 and 50173 after step 2, from the user's usual ASN; those rows are expected. Access tokens issued before revocation stay valid until they expire (about one hour), so a success inside that hour from a non-CAE client is not a miss; one after it is.

Stage 9 · Still activeAccess-token use that never reaches the sign-in logs

Why: Graph calls and mailbox reads made with an access token minted before revocation show up only in MicrosoftGraphActivityLogs and OfficeActivity. Anything here more than 90 minutes after the revocation time is a channel the revocation did not cover.
union
  (MicrosoftGraphActivityLogs
    | where TimeGenerated > datetime(<revocation time, UTC>)
    | where IPAddress in ("<attacker ip>") or UserId in ("<user object id>")
    | project TimeGenerated, Source = "Graph", Who = UserId,
              What = strcat(RequestMethod, " ", tostring(parse_url(RequestUri).Path)),
              Status = tostring(ResponseStatusCode), IPAddress),
  (OfficeActivity
    | where TimeGenerated > datetime(<revocation time, UTC>)
    | where UserId in~ ("<upn1>", "<upn2>") or ClientIP has_any ("<attacker ip>")
    | project TimeGenerated, Source = OfficeWorkload, Who = UserId, What = Operation,
              Status = ResultStatus, IPAddress = ClientIP)
| order by TimeGenerated asc
T1550.001 Tuning: The user's own Outlook and OneDrive clients keep calling with their cached access token for up to an hour after revocation, then fail; a 401 or 403 in Status is the revocation working, a 200 after the hour is not.

Act

Preserve first, then broad, then surgical once the scope is known. Cut every channel in one pass, and verify it held (step 8) before re-enabling anyone.

T+0 · PreserveExport the logs for the window before anything is revoked

Why: Revocation and disablement write new rows and, in Entra's own store, the window closes on its own: sign-in and audit logs live 30 days on P1/P2 and 7 days on the free tier, the Unified Audit Log 180 days on Audit Standard and one year on Premium, mailbox audit 90 days by default. MicrosoftGraphActivityLogs exists only in the Sentinel workspace. Pull the window now; the hunts above run on the export if the live data ages out.
# Entra sign-ins (interactive) and audit, Graph v1.0; non-interactive sign-ins need the beta cmdlet
Get-MgAuditLogSignIn -Filter "userPrincipalName eq '<upn>' and createdDateTime ge <start ISO 8601>Z" -All |
  Export-Csv signins-<upn>.csv -NoTypeInformation
Get-MgBetaAuditLogSignIn -Filter "userPrincipalName eq '<upn>' and signInEventTypes/any(t: t eq 'nonInteractiveUser') and createdDateTime ge <start>Z" -All |
  Export-Csv noninteractive-<upn>.csv -NoTypeInformation
Get-MgAuditLogDirectoryAudit -Filter "activityDateTime ge <start>Z" -All | Export-Csv auditlogs.csv -NoTypeInformation
# Unified Audit Log (Exchange Online PowerShell); 5,000 rows per page, ReturnLargeSet pages to 50,000
Search-UnifiedAuditLog -StartDate <start> -EndDate <end> -UserIds <upn1>,<upn2> -SessionCommand ReturnLargeSet -ResultSize 5000 |
  Export-Csv ual-window.csv -NoTypeInformation
# Sentinel tables for the window, including MicrosoftGraphActivityLogs (workspace only)
az monitor log-analytics query -w <workspace id> -o json --analytics-query "
  union SigninLogs, AADNonInteractiveUserSignInLogs, AuditLogs, OfficeActivity, MicrosoftGraphActivityLogs
  | where TimeGenerated between (datetime(<start>) .. datetime(<end>))" > sentinel-window.json
Get-FileHash signins-<upn>.csv, noninteractive-<upn>.csv, auditlogs.csv, ual-window.csv, sentinel-window.json -Algorithm SHA256
Tuning: The query API returns at most 500,000 rows or 64 MB per call; split a busy tenant's window per table and per day. Record each file's hash in the case before you open it.

Step 7 · Scope the exception groupWho legitimately uses device code, before you flip the block

Why: The block fails every device-code sign-in in the tenant, including the ones the business depends on. Thirty days of successes by app and user is the exception group; anything not on it breaks on purpose.
SigninLogs
| where TimeGenerated > ago(30d)
| where AuthenticationProtocol =~ "deviceCode" and ResultType == "0"
| summarize SignIns = count(), Users = make_set(UserPrincipalName), Last = max(TimeGenerated),
            ASNs = make_set(AutonomousSystemNumber)
    by AppDisplayName, AppId
| order by SignIns desc
// Expected breakage without an exception: Azure CLI on headless hosts and CI runners, kubelogin, Azure Arc
// onboarding, Teams Rooms and Surface Hub, Android phones signing in to Teams and Outlook, printers and
// scanners that mail to M365.
T1528 Tuning: The victim's own sign-ins are on this list too; take them out. Service accounts that used device code once, months ago, do not earn an exception.

Step 7 · Go broadBlock Device Code flow for the whole tenant

Why: One policy cuts the campaign's entry path for every user it reached, not only the one you found. A blocked attempt lands in stage 1 as ResultType 53003, which is how you measure the campaign's reach afterwards.

Console: Entra admin center › Conditional Access › New policy › Conditions › Authentication flows › Device code flow; Grant › Block access. Exclude the exception group from the previous step and your break-glass accounts. During an incident the state is enabled, not report-only.

New-MgIdentityConditionalAccessPolicy -BodyParameter @{
  displayName   = "IR - block device code flow"
  state         = "enabled"
  conditions    = @{
    users               = @{ includeUsers = @("All"); excludeGroups = @("<exception group id>")
                            excludeUsers = @("<break-glass account id>") }
    applications        = @{ includeApplications = @("All") }
    clientAppTypes      = @("all")
    authenticationFlows = @{ transferMethods = "deviceCodeFlow" }
  }
  grantControls = @{ operator = "OR"; builtInControls = @("block") }
}

Steps 1–2 · ContainDisable, then revoke, then mark compromised, for every account seen from the attacker

Why: Disable first so the next refresh fails with 50057, then revoke so any token that survives the disable fails with 50173; revoking one victim while the campaign's others stay live tells the attacker they have been found. A password reset alone is not containment: for a cloud-managed user an admin reset does revoke password-based refresh tokens, but tokens issued through SSO, passwordless or a federated IdP survive it, and the device-code token is one of those. Access tokens already issued stay valid until they expire, about one hour; CAE shortens that to minutes only for CAE-capable clients (Office, Teams, Outlook, Edge), and roadtx, TokenTactics and the kits' own pollers are not.

Console: Defender portal › Identities › <user> › Disable user / Revoke session / Mark user as compromised. These actions run in Entra and need an Entra role; the Entra SOC Identity Responder built-in role (introduced July 2026) carries exactly these three, without User Administrator.

"<upn1>","<upn2>" | ForEach-Object {
  $u = Get-MgUser -UserId $_ -Property Id
  Update-MgUser -UserId $u.Id -AccountEnabled:$false            # next refresh fails: 50057
  Revoke-MgUserSignInSession -UserId $u.Id                       # refresh tokens and session cookies: 50173
  Confirm-MgRiskyUserCompromised -UserIds @($u.Id)               # risk = high; risk-based CA and Identity Protection react
}

Step 3 · ContainExport the MFA methods, remove what the user did not enrol, issue a TAP

Why: The attacker may have enrolled a second Authenticator or phone with the stolen session. Record the original list before you delete anything; the user re-registers from a Temporary Access Pass, not from a method the attacker could also hold.
Get-MgUserAuthenticationMethod -UserId <upn> |
  Select-Object Id, @{n='Type';e={$_.AdditionalProperties['@odata.type']}}, @{n='Detail';e={$_.AdditionalProperties | ConvertTo-Json -Compress}} |
  Export-Csv mfa-<upn>.csv -NoTypeInformation
Remove-MgUserAuthenticationMicrosoftAuthenticatorMethod -UserId <upn> -MicrosoftAuthenticatorAuthenticationMethodId <id>
Remove-MgUserAuthenticationPhoneMethod -UserId <upn> -PhoneAuthenticationMethodId <id>
Remove-MgUserAuthenticationSoftwareOathMethod -UserId <upn> -SoftwareOathAuthenticationMethodId <id>
Remove-MgUserAuthenticationFido2Method -UserId <upn> -Fido2AuthenticationMethodId <id>
Remove-MgUserAuthenticationEmailMethod -UserId <upn> -EmailAuthenticationMethodId <id>
New-MgUserAuthenticationTemporaryAccessPassMethod -UserId <upn> -BodyParameter @{ lifetimeInMinutes = 60; isUsableOnce = $true }
T1556.006 Tuning: "User registered security info" in AuditLogs dates each method; a method registered inside the window from the attacker ASN goes, one registered months ago from the office stays until the user confirms.

Steps 4–5 · ContainExport and disable the attacker's device, disable the app, remove the grants, in the same pass

Why: A registered device or a consented app keeps access after the sessions are revoked. Export the device object first; the registration time, trust type and OS build are the attribution evidence and go with the device when it is deleted later. Disabling the service principal stops the app's own token path at once; removing grants one by one does not.

Disable only the devices registered in the access window; disabling all of them locks out the user's own machines. Get-MgUserOauth2PermissionGrant lists only this user's own consents; an admin-consented (AllPrincipals) grant on the same app does not show there, so query the grant by client id as well. The PRT on the attacker's device renews about every four hours, so the disable takes effect at the next renewal; the stage 4 PRT hunt confirms it.

Get-MgDevice -DeviceId <device object id> -Property Id,DeviceId,DisplayName,RegistrationDateTime,TrustType,OperatingSystem,OperatingSystemVersion,IsCompliant,IsManaged,EnrollmentType,ApproximateLastSignInDateTime |
  Export-Csv device-<device object id>.csv -NoTypeInformation
Update-MgDevice -DeviceId <device object id> -AccountEnabled:$false            # -DeviceId takes the object id
Get-MgUserOauth2PermissionGrant -UserId <user id> -All                          # this user's own consents
Get-MgOauth2PermissionGrant -Filter "clientId eq '<app service principal id>'" -All   # every grant on the app, both consent types
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId <app service principal id> -All   # application permissions the app holds
Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId <app service principal id> -All   # users and groups assigned to it
Update-MgServicePrincipal -ServicePrincipalId <app service principal id> -AccountEnabled:$false
Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId <grant id>

Step 6 · PreserveLitigation hold before the rules and forwarding are purged

Why: Removing an inbox rule or forwarding address destroys the evidence of what the attacker diverted. A hold keeps the mailbox, the rules and the deleted items as they are; export the rules, then remove them.

Exchange Online PowerShell. Litigation hold needs an Exchange Online Plan 2 licence or the Exchange Online Archiving add-on on the mailbox.

Set-Mailbox -Identity <upn> -LitigationHoldEnabled $true -LitigationHoldDuration 365
Get-InboxRule -Mailbox <upn> -IncludeHidden | Export-Clixml inboxrules-<upn>.xml
Get-Mailbox -Identity <upn> | Select-Object ForwardingSmtpAddress, ForwardingAddress, DeliverToMailboxAndForward | Export-Csv forwarding-<upn>.csv
Get-InboxRule -Mailbox <upn> -IncludeHidden | Where-Object { $_.Name -notin @("<rules the user confirms>") } | Remove-InboxRule -Confirm:$false
Set-Mailbox -Identity <upn> -ForwardingSmtpAddress $null -ForwardingAddress $null -DeliverToMailboxAndForward $false

Once scoped · SurgicalIsolate the victim's endpoint only if a file was delivered

Why: Device-code phishing runs on Microsoft's own sign-in page and leaves no malware; isolation helps only if the lure also dropped a file. The endpoint evidence that does matter is the browser history, which fixes when the code was entered and which page sent the user there.

Live Response: getfile "C:\Users\<user>\AppData\Local\Microsoft\Edge\User Data\Default\History" and the Chrome equivalent; open the SQLite file offline.