When to use this runbook
You suspect a malicious OAuth application has been consented to in your tenant, or one of the detections in this bundle has fired. Triggers that should land you here:
- Detection 01 fires (suspicious app consent grant)
- Detection 02 fires (mass consent campaign — escalate immediately)
- Detection 03 fires (anomalous SP sign-in — dormancy break, new country, hosting ASN)
- Detection 04 fires (credential addition to an app, especially a freshly-consented one)
- Detection 05 fires (Graph API mass read after recent consent — assume data loss)
- A user reports clicking a Microsoft-themed phishing link that ended at a consent prompt
- An external party reports receiving spam or phishing from a tenant user's mailbox
- The audit script
Get-RiskyConsentGrants.ps1surfaces a high-risk SP and you need to triage
The decision tree
Alert fireschr(10)│chr(10)├─ Detection 01: Suspicious app consent (single user)chr(10)│ └─ Triage path A: Single-user consentchr(10)│chr(10)├─ Detection 02: Mass consent campaign (≥5 users / 1h)chr(10)│ └─ Triage path B: Campaign — escalate to High immediatelychr(10)│chr(10)├─ Detection 03: SP anomalous sign-inchr(10)│ └─ Triage path C: Workload-identity anomalychr(10)│chr(10)├─ Detection 04: App credential additionchr(10)│ ├─ isPostConsentPersistence == true → Path D-1 (High)chr(10)│ └─ isPostConsentPersistence == false → Path D-2 (Medium)chr(10)│chr(10)└─ Detection 05: Graph API mass read after consentchr(10)└─ Triage path E: Active exfiltration — High; assume data lossSeverity floors and escalation triggers:
- Path A (Single-user consent) — default Medium. Escalate to High when the consenting user holds a directory role, or when scopes include
Application.ReadWrite.AllorRoleManagement.ReadWrite.Directory. - Path B (Campaign) — default High already. Escalate to Critical when ≥20 users in 1 hour, or any consenter is in a privileged-role group.
- Path C (Workload identity anomaly) — default Medium. Escalate to High when the SP holds Application or Cloud Application role, or when the hosting-ASN trigger fires alongside the new-country trigger.
- Path D-1 (Persistence on freshly-consented app) — default High. Escalate when the initiator is anyone other than a known IT-admin account.
- Path D-2 (Routine cred-add) — default Medium when the cred-add target is in the
ApprovedAppIdswatchlist; otherwise route to D-1. - Path E (Active exfiltration) — default High already. Escalate to Critical on any executive mailbox in the read set.
When in doubt: if multiple detections fire on the same AppId or same user within 24 hours, treat as a chained incident, escalate severity by one tier, and run all triage paths that fired. The chain is what gives you scope. If Detection 04 fires with isPostConsentPersistence == true and Detection 03 fires for the same SP within 6 hours, you are looking at the canonical Stage 5 → Stage 7 → Stage 8 sequence from the threat model — Critical, skip directly to containment.
Why password reset alone leaves the attacker in place
This is the single most important thing about responding to OAuth consent compromise. SOCs that run credential-rotation-on-autopilot will reset the user's password, declare the incident closed, and leave the OAuth grant fully operational. The attacker keeps reading mail. They keep downloading files. The password reset accomplished nothing against the actual access path.
The reason: the OAuth grant is independent of the user's password. It is signed by Microsoft, scoped to the user's data, and valid for up to 90 days regardless of password state. Password reset does not touch it. MFA enrollment does not touch it. The "force sign out" button on the user's Entra profile does not touch it (that signs out interactive sessions, not OAuth-app delegated tokens).
The four containment moves below are what actually take the attacker's access away. Run them in order.
Containment — the four moves, in order
Authority for containment in this bundle is L3 / IR lead. Disabling SPs and revoking consent is reversible but tenant-wide-visible. Do not delegate below L3.
Pre-containment snapshot (60 seconds)
Capture state before you change anything. The clixml exports are forensic evidence — save to a controlled location named for the incident.
Connect-MgGraph -Scopes 'Application.ReadWrite.All','Directory.ReadWrite.All','DelegatedPermissionGrant.ReadWrite.All','AppRoleAssignment.ReadWrite.All','User.ReadWrite.All','AuditLog.Read.All'chr(10)chr(10)$AppId = '{the-app-id-from-the-alert}'chr(10)chr(10)$sp = Get-MgServicePrincipal -Filter "appId eq '$AppId'"chr(10)$sp | Export-Clixml ".\incident-{INC#}-sp-snapshot.clixml"chr(10)chr(10)Get-MgOauth2PermissionGrant -Filter "clientId eq '$($sp.Id)'" |chr(10)Export-Clixml ".\incident-{INC#}-delegated-grants.clixml"chr(10)chr(10)Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id |chr(10)Export-Clixml ".\incident-{INC#}-app-role-assignments.clixml"Move 1 — Revoke OAuth consent grants
Two kinds of grants exist on the SP. You need both.
Delegated grants (oauth2PermissionGrants) — the per-user consents:
$grants = Get-MgOauth2PermissionGrant -Filter "clientId eq '$($sp.Id)'"chr(10)foreach ($g in $grants) {chr(10)Write-Host "Revoking delegated grant $($g.Id) for principal $($g.PrincipalId) scopes='$($g.Scope)'"chr(10)Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $g.Idchr(10)}Application role assignments (appRoleAssignments) — the application-permission grants (admin-consented, tenant-wide). These survive user actions:
$assignments = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Idchr(10)foreach ($a in $assignments) {chr(10)Write-Host "Removing app role assignment $($a.Id) ($($a.AppRoleId)) on resource $($a.ResourceDisplayName)"chr(10)Remove-MgServicePrincipalAppRoleAssignment `chr(10)-ServicePrincipalId $sp.Id `chr(10)-AppRoleAssignmentId $a.Idchr(10)}After both blocks complete, re-run Get-MgOauth2PermissionGrant and Get-MgServicePrincipalAppRoleAssignment to confirm zero grants remain.
Move 2 — Disable the service principal
Update-MgServicePrincipal -ServicePrincipalId $sp.Id -AccountEnabled:$falsechr(10)chr(10)# Verifychr(10)(Get-MgServicePrincipal -ServicePrincipalId $sp.Id).AccountEnabled # expect: FalseA disabled SP cannot sign in. Existing access tokens remain valid until they expire (up to 90 minutes), which is why Move 3 is needed.
Move 3 — Revoke user refresh tokens
This invalidates refresh tokens already in the wild — including any the attacker may have minted seconds before the SP was disabled. Run for every user who consented to the malicious app (the principal IDs from the delegated grants you exported in the pre-containment snapshot).
# For each affected userchr(10)$affectedUsers = @('user1@yourtenant.com', 'user2@yourtenant.com')chr(10)foreach ($upn in $affectedUsers) {chr(10)Revoke-MgUserSignInSession -UserId $upnchr(10)Write-Host "Revoked sessions for $upn"chr(10)}This invalidates all refresh tokens for the user, not just the ones bound to the malicious app. That is the right behavior — you do not know which other tokens the attacker may have obtained as side effects. Users will be re-prompted to authenticate on their next access to any Microsoft service. The friction is acceptable for the security gain.
Move 4 (campaign only) — Tenant-block the AppId
If Detection 02 fired or this is otherwise a campaign hitting multiple users, block the AppId from receiving any new consents — including from users you have not yet identified as touched.
# Disable the application object (separate from the SP)chr(10)$app = Get-MgApplication -Filter "appId eq '$AppId'"chr(10)Update-MgApplication -ApplicationId $app.Id -SignInAudience 'AzureADMyOrg' # restrict to tenantchr(10)# Then explicitly block via the user consent denylist (if available in your tenant)The SignInAudience change prevents future cross-tenant consents to the same AppId. For tenant-wide deny on an AppId, the cleaner path is via the Restrict access to the app setting on the Enterprise Application object in the portal.
Why this order specifically
Each step depends on the previous one having happened, and reordering breaks the containment.
- If you disable the SP first without revoking consent, you have severed access for now, but the consent grant remains. Re-enabling the SP later (perhaps as part of a rollback or a different troubleshooting session) restores the attacker's path. The SP-disable is reversible; the consent revocation needs to be in place before the disable to make sure the access stays gone.
- If you revoke consent first without disabling the SP, the SP can immediately solicit fresh consent from any user who clicks the next phishing email. The consent revocation does not prevent re-consent.
- If you skip refresh-token revocation, tokens already in the wild — particularly the long-lived refresh tokens minted seconds before SP-disable — remain valid until they expire naturally. That can be hours.
- If you skip the AppId block in a campaign scenario, users you have not yet contained will continue clicking the lure and consenting. The block is the only thing that prevents new consents during the IR window.
Eradication — what comes after containment
Containment stops the bleeding. Eradication finds and removes everything the attacker did during their access window.
Audit what the attacker did
For each user whose consent was revoked, pull the access window:
$startTime = '2024-XX-XXTXX:XX:00Z' # time of consentchr(10)$endTime = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ssZ") # nowchr(10)chr(10)# Mailbox audit (requires Exchange Online module)chr(10)Connect-ExchangeOnlinechr(10)Search-MailboxAuditLog -Identity user1@yourtenant.com `chr(10)-StartDate $startTime -EndDate $endTime `chr(10)-ShowDetails | Export-Csv ".\incident-{INC#}-mailbox-audit-$upn.csv"chr(10)chr(10)# Graph activity for the SPchr(10)Get-MgAuditLogSignIn -Filter "appId eq '$AppId' and createdDateTime ge $startTime" |chr(10)Export-Csv ".\incident-{INC#}-sp-signins.csv"Look for: bulk message reads, message forwarding rules added (New-InboxRule), sent items the user did not send, file downloads, profile changes, OAuth apps the user authorized that they do not recognize.
Mailbox rules — the silent persistence
A common attacker trick: after consenting and reading mail, set up an inbox rule that auto-forwards or auto-deletes incoming mail. This survives consent revocation because it is a mailbox-level configuration, not an OAuth grant.
# Find rules created during the access windowchr(10)Get-InboxRule -Mailbox user1@yourtenant.com |chr(10)Where-Object { $_.WhenCreated -ge $startTime -and $_.WhenCreated -le $endTime } |chr(10)Format-List Name, Description, ForwardTo, RedirectTo, DeleteMessage, MoveToFolderDisable any rule the user does not recognize. The classic attacker rule auto-forwards mail matching specific keywords (financial, contract, customer-name) to an external mailbox, then auto-deletes the original so the user never sees the response.
Application owners
If the attacker added themselves as an owner of an application registration (Detection 04 catches this), they can re-add credentials at will. Audit owners on every app the attacker had access to:
Get-MgApplicationOwner -ApplicationId $app.Id | Format-List DisplayName, UserPrincipalName, IdRemove any owner the attacker added. Reset secrets and certificates on any app the attacker had owner access to.
Recovery
Once eradication is complete:
- Re-enable the SP (only if it is a legitimate app you needed to disable as part of containment — otherwise leave disabled). For attacker-controlled apps, leave the SP disabled and the application blocked.
- Communicate to affected users. Brief them on what happened, what data was potentially accessed, and what they should look for going forward.
- 30-day monitoring. Track sign-ins for affected users and any related SPs for the next 30 days. The attacker may have established additional persistence you did not catch — recurrence in week 2 or 3 is the signal.
- Update detections. Whatever the attacker did during this incident — specific AppIds, IPs, ASNs, scope sets — update your watchlists and detections. Their TTPs are now known to you. Do not waste them.
Notification — when and to whom
External notification depends on what data was accessed. Internal notification is non-negotiable.
Internal:
- Security team (already involved if you got here)
- Affected users — they need to know their account had unauthorized access and what data was potentially read
- Affected users' managers — operational impact briefing
- Privacy / legal if regulated data was in scope (PII, PHI, financial)
- Executive leadership if the access was tenant-wide or hit executive mailboxes
External:
- Customers / partners whose data may have been accessed (depends on your data inventory and what scopes the app had)
- Regulators if the breach triggers notification requirements (GDPR, HIPAA, state breach laws — depends on jurisdiction and scope)
- Microsoft via MSRC if the app was a fraudulent verified publisher or if the campaign appears tenant-spanning (helps Microsoft contain the broader campaign and may help other affected tenants)
The notification timeline is shaped by the regulations that apply to your data. The IR runbook's job is to give you the facts (what was accessed, when, by which app); the notification decision is legal/compliance.
Lessons-learned template
Every incident closes with a written post-mortem. Use this skeleton.
# Incident {INC#} — OAuth Consent Compromisechr(10)chr(10)## Summarychr(10)- Date detected: {date}chr(10)- Date contained: {date}chr(10)- Date closed: {date}chr(10)- Affected users: {count}chr(10)- Affected mailboxes / files: {scope}chr(10)- Detection that fired first: {01/02/03/04/05}chr(10)chr(10)## Timelinechr(10)- T+0: {first attacker action visible in logs}chr(10)- T+X: Detection fireschr(10)- T+X: Containment Move 1 (revoke grants) completeschr(10)- T+X: Containment Move 2 (disable SP) completeschr(10)- T+X: Containment Move 3 (revoke refresh tokens) completeschr(10)- T+X: Eradication completechr(10)chr(10)## Root causechr(10){What allowed the consent to be granted? Tenant configuration gap?chr(10)User awareness gap? Bypass of an existing control?}chr(10)chr(10)## What workedchr(10){Detections that fired correctly. Containment steps that executedchr(10)without issue. Communications that landed.}chr(10)chr(10)## What did not workchr(10){Detections that should have fired earlier and did not.chr(10)Steps that took longer than expected. Missing tooling or playbooks.}chr(10)chr(10)## Action itemschr(10)- [ ] Update detection {N}: {specific change}chr(10)- [ ] Add AppId {ID} to deny listchr(10)- [ ] Tighten control {X}: {specific config change}chr(10)- [ ] Tabletop scenario added: {specific case}chr(10)- [ ] User communication: {what to send and when}Send to the IR distribution list within 5 business days of incident close. The post-mortem feeds the next quarter's detection improvements and the next round of consent-framework tuning.
Quick reference card
SNAPSHOT FIRST: Export-Clixml on SP + grants + role assignmentschr(10)MOVE 1: Remove-MgOauth2PermissionGrant + Remove-MgServicePrincipalAppRoleAssignmentchr(10)MOVE 2: Update-MgServicePrincipal -AccountEnabled:$falsechr(10)MOVE 3: Revoke-MgUserSignInSession (per affected user)chr(10)MOVE 4 (CAMPAIGN): Block AppId via Enterprise Application → Restrict accesschr(10)AUDIT WINDOW: Search-MailboxAuditLog + Get-MgAuditLogSignInchr(10)INBOX RULES: Get-InboxRule | filter on WhenCreated in windowchr(10)APP OWNERS: Get-MgApplicationOwner — remove attacker-added ownersPrint and tape next to the SOC monitor. The order of the four moves is what stops most SOCs from getting this right on the first incident.