Incident response & forensics
Revoke sessions, isolate, snapshot, hunt persistence.
Break into a data center and you still have to move through wires, racks, and network hops to reach anything worth stealing. In the cloud there are no wires. There is a control plane (the management API layer, where API means application programming interface, one authenticated call for every create, read, or delete). A stolen token can grant itself Owner over a subscription (Owner is the top Azure role, full control of everything in it), read a Key Vault, and stand up a backdoor identity in the time it takes to read this paragraph. Cloud incident response is a race measured in token lifetimes. The winning move is never improvisation. It is a rehearsed runbook (a written, tested sequence of steps) that contains the threat without burning the evidence you need to prove what happened and to find every door the attacker left open.
Triage the blast radius before you touch anything
The first decision is not how to respond. It is what actually got compromised, because the right containment move is different for each kind of resource, and the wrong one destroys evidence you will need later. Treat it like a break-in at home. You photograph the forced window before you board it up, and you do not run a wash cycle on the dishes covered in the burglar's fingerprints. Contain along the blast radius (the full set of things one compromise can reach), then investigate, and preserve before you destroy.
Four blast radii, four different first moves. A compromised user identity needs its tokens killed. A compromised virtual machine needs to be isolated on the network and imaged, not deleted. A leaked secret or key should be treated as already replayed by the attacker and rotated at once. A compromised service principal or app registration, which is the cloud's favorite hiding place for persistence, needs the credentials that were added to it stripped off. A deleted VM and an un-revoked token leave you equally blind: one because the evidence is gone, the other because the attacker is still inside.
Contain the identity by revoking its tokens
Disabling the account feels like it should end the incident. It does not, and the reason is how sign-in works. When someone signs in, Entra ID (Microsoft's cloud identity service, renamed from Azure Active Directory, or Azure AD) hands their app two things, and the difference between them is the whole game. The first is a refresh token, long-lived (the default sliding window keeps it usable for up to 90 days of activity), whose only job is to quietly mint fresh access tokens in the background. The second is an access token, short-lived (about an hour by default, up to roughly 90 minutes), and it is the wristband the resource actually checks on every call. Reading a blob, listing a role assignment, opening a secret: each call flashes its access token, and the resource serves it without phoning home.
So look at what --account-enabled false really does. It stops the account from authenticating fresh. It does nothing to the tokens already in the attacker's pocket. The stolen refresh token still redeems for new access tokens, the current access token keeps working, and your 'disabled' account keeps making API calls until you take the next step. The command that actually contains is revokeSignInSessions, a Microsoft Graph action that invalidates every refresh token the user holds and stamps the account with a revocation time (under the hood it resets a property called signInSessionsValidFromDateTime to now). No valid refresh token means no new access tokens can be minted.
Whether the current access token dies right away depends on the resource, and this is where people get tripped up. A small set of workloads support Continuous Access Evaluation (CAE, a standing back-and-forth between the token issuer and the app, so Entra can tell the app to stop trusting a token in the middle of its lifetime). On CAE workloads, which today means Exchange Online, SharePoint Online, Teams, and Microsoft Graph, revocation lands in near real time, usually within a few minutes. The rest of Azure, including the Azure Resource Manager (ARM) control plane that the az command uses to touch resources, does not subscribe to CAE. There, the attacker's already-issued access token is accepted until it expires of old age. That is why you disable, revoke, and then verify from the sign-in logs, rather than trusting that one command finished the job.
# 1. Disable the account. Blocks NEW sign-ins; does NOT retract issued tokens.az ad user update --id [email protected] --account-enabled false# 2. Revoke sessions. Invalidates every refresh token the user holds and stamps# a revocation time on the account. This is the move that actually contains.az rest --method POST \--url "https://graph.microsoft.com/v1.0/users/[email protected]/revokeSignInSessions"# 3. VERIFY from the sign-in logs. Confirm the session is dead; don't assume it.az rest --method GET \--url "https://graph.microsoft.com/v1.0/auditLogs/signIns?\$top=3&\$filter=userPrincipalName eq '[email protected]'" \--query "value[].{time:createdDateTime, app:appDisplayName, err:status.errorCode}" -o table
# step 1 -> (no output; exit code 0 means the change applied)# step 2 -> every refresh token is now invalid{"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#Edm.Boolean","value": true}# step 3 -> the attacker's client is now forced to re-authenticateTime App Err-------------------- --------------- -----2026-07-14T09:47:02Z Azure CLI 50173 # AADSTS50173: grant revoked, sign-in required (good)2026-07-14T09:12:03Z Microsoft Graph 0 # last success before the revoke: the attacker
az command talks to. If you see control-plane calls continue for a few minutes after you revoke, that is expected behavior, not a failed command. Do not thrash re-running it. Close the gap by turning on CAE for the workloads that support it and adding a Conditional Access policy that blocks the user outright, so re-authentication itself is refused. Note too that revokeSignInSessions does nothing for guest or external users, who sign in through their own home tenant.Isolate and snapshot to preserve the evidence
A suspect virtual machine (VM, a server running in the cloud) is a crime scene, and your instinct will be to make it stop. Fight that instinct. Deleting the machine throws away the disk, which is most of your evidence. Stopping it, which in Azure is called deallocating, wipes the memory (the RAM, random-access memory), and memory is where an attacker's running implant and freshly decrypted keys live. You want the machine frozen in place, not switched off.
Do two things, in this order. Snapshot the disks first. A snapshot is a point-in-time, read-only copy of a disk, and an incremental one stores only the blocks that changed since the last snapshot, so it is cheap and quick. Then isolate the machine at the network by moving its network interface (NIC, the virtual network card that connects the VM to a subnet) onto a quarantine network security group (NSG, a stateful firewall you attach to a NIC or a subnet) that denies every packet in and out. The VM keeps running, so memory survives, but it can no longer reach its command-and-control server (C2, the attacker's remote handler) or pivot sideways into your other subnets.
Copy the snapshot into a dedicated evidence resource group protected by a resource lock (so nobody deletes it by accident) and immutable blob storage (write-once, so it cannot be altered after the fact), and record who captured it and when. That is your chain of custody, the paper trail that makes the evidence hold up later.
# Snapshot FIRST. Capture evidence before any change that could touch the disk.DISK=$(az vm show -g prod-rg -n web01 \--query storageProfile.osDisk.managedDisk.id -o tsv)az snapshot create -g ir-evidence-rg -n web01-ir-8891 \--source "$DISK" --incremental true \--query "{name:name, state:provisioningState, sizeGb:diskSizeGb}" -o table# Quarantine NSG: deny ALL traffic in and out. Priority 100 beats the default# rules that otherwise allow intra-VNet inbound and all outbound.az network nsg create -g prod-rg -n quarantine-nsg -o noneaz network nsg rule create -g prod-rg --nsg-name quarantine-nsg -n deny-all-in \--priority 100 --direction Inbound --access Deny --protocol '*' \--source-address-prefixes '*' --destination-address-prefixes '*' \--destination-port-ranges '*' -o noneaz network nsg rule create -g prod-rg --nsg-name quarantine-nsg -n deny-all-out \--priority 100 --direction Outbound --access Deny --protocol '*' \--source-address-prefixes '*' --destination-address-prefixes '*' \--destination-port-ranges '*' -o none# Swap the VM's NIC onto it. The VM keeps running (memory intact) but is cut off.NIC=$(az vm show -g prod-rg -n web01 \--query 'networkProfile.networkInterfaces[0].id' -o tsv)az network nic update --ids "$NIC" --network-security-group quarantine-nsg \--query "networkSecurityGroup.id" -o tsv
Name State SizeGb------------- ---------- --------web01-ir-8891 Succeeded 128# NSG and rules were created with -o none (no output). The NIC swap prints the# now-attached NSG id, which confirms the machine is isolated:/subscriptions/8f3.../resourceGroups/prod-rg/providers/Microsoft.Network/networkSecurityGroups/quarantine-nsg
az vm deallocate releases the underlying host and wipes volatile memory: running processes, injected code, open network connections, and any keys the malware decrypted in RAM. That is often the only place the live implant exists. Snapshot the disks of the running machine and cut it off at the network instead. Power it down only after you have captured what you need, and do not delete it until the investigation is closed.Rebuild the timeline from immutable logs
Containment buys you the time to answer the real question: what happened, and how far did it reach? Three log sources rebuild the story, and they work like three separate camera feeds of the same building. The Activity Log is the control-plane feed: every management action, who created a role assignment, who read a vault, who deleted a resource. It is the spine of your timeline. The Entra sign-in and audit logs are the front-door feed: the authentication story, impossible-travel logins (the same account appearing in two places too far apart to fly between in the time elapsed), whether multi-factor authentication (MFA, the second proof-of-identity step like a phone prompt) was satisfied or skipped, and any new consent grants. The resource diagnostic logs (Key Vault access, storage, NSG flow logs) are the feed inside each room: the actual data-plane access.
One thing makes or breaks this, and it has to be true before the incident starts. Every one of those logs must already be shipped to a central, immutable Log Analytics workspace (Azure's log store) that lives in a subscription the attacker cannot reach. Someone holding Owner over a subscription can wipe the logs inside it, and your investigation dies with them. You query the control plane with az monitor activity-log, and you run hunting queries written in Kusto Query Language (KQL, the read-only query language for Azure logs) against Microsoft Sentinel, Microsoft's cloud SIEM (security information and event management system, the tool that pulls together and correlates security logs), which is built on top of Log Analytics.
# What did the compromised principal DO on the control plane?az monitor activity-log list --start-time 2026-07-14T08:00:00Z \--query "[?caller=='[email protected]'].{t:eventTimestamp, op:operationName.value, rg:resourceGroupName}" \-o table# Then hunt the sign-ins behind it, in the Sentinel workspace. -w is the workspace GUID.az monitor log-analytics query -w 8b1f2c9a-6d4e-4a11-9c2f-77ac3e5b1042 \--analytics-query "SigninLogs| where TimeGenerated > ago(3d)| where UserPrincipalName == '[email protected]'| summarize hits=count() by IPAddress, country=tostring(LocationDetails.countryOrRegion)" \-o table
T Op Rg-------------------- ---------------------------------------------- -------2026-07-14T09:20:44Z Microsoft.Authorization/roleAssignments/write prod-rg # self-granted Owner2026-07-14T09:24:10Z Microsoft.KeyVault/vaults/accessPolicies/write prod-rg # opened prod-kv to itselfIPAddress country hits------------ ------------- ----41.203.0.55 Nigeria 214 # impossible-travel source: the attacker203.0.113.10 United States 6 # the real user's baseline
Read those two rows like a story. The self-granted role and the access-policy change are both control-plane moves, so the Activity Log caught them. The secret values the attacker pulled next will not show up here. Data-plane reads live in Key Vault's own AuditEvent logs, which is exactly why you wired that diagnostic feed to the workspace before any of this started.
Hunt persistence before you evict
A competent attacker plants a second way in before you ever notice the first. Evict them without hunting for it and you have only reset the clock: they walk back in an hour later through the door you never found. In Azure the footholds are almost all identity, because an identity backdoor is quieter than malware and it outlives any server you rebuild.
The usual ones: a new client secret or certificate added to an existing privileged service principal (a service principal, or SP, is the non-human identity an app or script uses to log in); a fresh role assignment handing out Owner; a PIM eligible assignment (PIM is Privileged Identity Management, the just-in-time system that keeps admins un-privileged until they activate a role for a short window) that sits dormant and survives your session revocation, because it is not a session at all, it is a right the attacker activates later; an added federated credential or a Conditional Access exclusion; or new app consent. Enumerate each one, compare it against a known-good baseline, and let Defender for Cloud (Azure's threat-detection service, renamed from Azure Security Center) back you up with its own alerts.
# 1. App credentials added today: the classic service-principal backdoor.az ad app list --all \--query "[?passwordCredentials[?starts_with(startDateTime,'2026-07-14')]].{app:displayName, appId:appId}" \-o table# 2. Attacker-planted PIM *eligible* admin. Not an active session, so it survived# revokeSignInSessions. The attacker activates it later.az rest --method GET \--url "https://graph.microsoft.com/v1.0/roleManagement/directory/roleEligibilityScheduleInstances?\$expand=principal" \--query "value[].{role:roleDefinitionId, who:principal.userPrincipalName}" -o table# 3. Corroborate with Defender for Cloud's own verdict.az security alert list -o table | grep -i suspicious
# 1. new app credentialsApp AppId------------ ------------------------------------billing-sync 6f3d0a91-4c2b-4f9e-8a1d-7b6c5e4d3a2f # secret added today -> investigate# 2. PIM eligible roles (roleDefinitionId 62e9... = Global Administrator)Role Who------------------------------------ --------------------62e90394-69f5-4237-9190-012177145e10 [email protected] # unexpected admin -> foothold# 3. Defender for Cloud alertSuspicious granting of permissions to a service principal High Active
Only once every foothold is written down do you eradicate. Delete the rogue credentials. Rotate every secret and key the attacker could have read, which creates a new version and leaves the old one in place for the audit trail. Rebuild any compromised compute from a clean image instead of cleaning it in place, because a box that ran attacker code is never fully trustworthy again.
# Drop the backdoor secret from the service principal.az ad app credential delete \--id 6f3d0a91-4c2b-4f9e-8a1d-7b6c5e4d3a2f \--key-id 4b9c1e77-2a3d-4e5f-9b0c-1d2e3f4a5b6c# Rotate the exposed vault secret. Creates a NEW version; the old one stays for audit.az keyvault secret set --vault-name prod-kv -n db-conn \--value "$(openssl rand -base64 32)" \--query "{version:id, enabled:attributes.enabled}" -o table
# credential delete -> (no output; exit code 0 means the backdoor secret is gone)# secret rotate -> a new version is createdVersion Enabled--------------------------------------------------------------- -------https://prod-kv.vault.azure.net/secrets/db-conn/9f2a4c7b1e8d... True
Recovery is the last phase and the one teams rush. Reconnect the isolated resources only after they are verified clean. Then wire the fast, mechanical steps of this sequence into a Sentinel playbook (a Logic App that runs the response for you, kicked off automatically by an automation rule) so the next containment is one action instead of a scramble at three in the morning. This automated-response wiring is what people mean by SOAR (Security Orchestration, Automation, and Response). And run the whole thing as a tabletop drill on a calm afternoon. A runbook you have never executed is a hypothesis, not a capability.
# ir-runbook.yaml -- Azure identity-compromise runbook. Rehearse quarterly.# IC = incident commander, IR = responder, $UPN = user principal name (sign-in address).# Times are targets, not guarantees.phase_1_triage: # 0-5 min (IC)- classify_blast_radius: [user_identity, vm, secret_or_key, service_principal]- open_evidence_log: who / what / when, one row per actionphase_2_contain: # 5-15 min (IR)identity:- az ad user update --id $UPN --account-enabled false- POST /users/$UPN/revokeSignInSessions # kills refresh tokens- add a Conditional Access block policy for $UPN # refuse re-authvm:- snapshot OS + data disks (incremental) into ir-evidence-rg- swap NIC onto quarantine-nsg (deny all in/out) # do NOT deallocatesecret_or_key:- assume replayed; rotate now, keep old version for auditphase_3_investigate:- build a caller timeline from the immutable Log Analytics workspace- Activity Log + Entra sign-ins + Sentinel KQL; pin the source IPsphase_4_hunt_persistence: # BEFORE eviction- new app passwordCredentials / keyCredentials- new Owner role assignments- PIM eligible admin assignments # survive session revocation- federated credentials, CA exclusions, new app consentphase_5_eradicate:- delete rogue credentials- rotate every secret/key the principal could read- rebuild compromised compute from a clean imagephase_6_recover:- reconnect only after verified clean- promote the mechanical steps into a Sentinel playbook (Logic App, SOAR)- post-incident review; update this file
az ad user update --account-enabled false and move on. Is the attacker's session contained?value: true), but the Activity Log shows the attacker still making Azure Resource Manager calls ten minutes later. What is happening?62e90394-... [email protected]. What did you miss?Try this
Run az ad user update --id [email protected] --account-enabled false on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: revokeSignInSessions does not kill a live access token. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.