Investigate OAuth supply chain attacks, detect compromised app indicators, contain and remediate app-based breaches, build detection rules for supply chain threats, harden tenant defences, and create incident response playbooks.
OAuth-based supply chain attacks exploit the trusted relationship between applications and your tenant. Attackers compromise a legitimate vendor''s app or create convincing lookalike apps to gain persistent access to enterprise data. This lab covers investigating app-based attacks, detecting supply chain compromises, building incident response procedures for app-based threats, and hardening your tenant against future attacks.
A security alert reveals that a widely-used project management app experienced a supply chain compromise. The vendor''s OAuth client secret was leaked, and an attacker used it to access data in 500+ tenants. Your organisation is one of the affected tenants. The SOC must determine: what data was accessed, which users are affected, whether the attacker established persistence, and how to remediate without disrupting 200 employees who depend on the app.
Supply chain attacks through OAuth apps are among the most sophisticated threats to enterprise security. The SolarWinds and Midnight Blizzard attacks demonstrated how compromised applications can bypass all traditional security controls. Your defence must include the ability to detect, investigate, and respond to app-based threats rapidly.
Microsoft.Graph module v2.x; Azure CLI for Logic App managementApplication.ReadWrite.All, DelegatedPermissionGrant.ReadWrite.All, AppRoleAssignment.ReadWrite.All, User.RevokeSessions.All, SecurityEvents.ReadWrite.AllConsent phishing is the most common vector for OAuth-based supply chain attacks. Attackers register apps with names that mimic trusted services, then send phishing emails containing OAuth consent links. When a user clicks and approves, the attacker’s app gains persistent access to their mailbox, files, and profile without needing their password. The KQL query below detects these malicious consent patterns by correlating suspicious app consent events with known phishing indicators.
// WHAT: Detect malicious OAuth consent phishing - apps consented via phishing campaigns
// WHY: Consent phishing bypasses MFA and credential protections entirely. The attacker
// doesn't steal a password - they trick the user into granting API access directly.
// This query identifies consent events with high-risk characteristics.
// TABLES: AuditLogs - captures consent grant operations
// AADSignInEventsBeta - provides sign-in context around the consent event
// KEY FIELDS:
// OperationName - "Consent to application"
// TargetResources - the app that received consent
// InitiatedBy - the user who clicked the consent prompt
// OUTPUT: Consent events matching phishing indicators: unverified publishers, high-risk
// scopes, apps less than 30 days old, multiple users consenting within hours
// Detect suspicious consent patterns in the last 30 days
AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName == "Consent to application"
| extend AppName = tostring(TargetResources[0].displayName),
AppId = tostring(TargetResources[0].id),
ConsentUser = tostring(InitiatedBy.user.userPrincipalName),
ConsentIP = tostring(InitiatedBy.user.ipAddress),
Scopes = tostring(parse_json(
tostring(TargetResources[0].modifiedProperties))[0].newValue)
| project TimeGenerated, AppName, AppId, ConsentUser, ConsentIP, Scopes
// Flag 1: Multiple users consenting to the same app within 4 hours (campaign indicator)
| join kind=inner (
AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName == "Consent to application"
| extend AppId = tostring(TargetResources[0].id),
ConsentUser = tostring(InitiatedBy.user.userPrincipalName)
| summarize ConsentCount = dcount(ConsentUser),
FirstConsent = min(TimeGenerated),
LastConsent = max(TimeGenerated)
by AppId
| where ConsentCount >= 3 // 3+ users = likely phishing campaign
| where (LastConsent - FirstConsent) < 4h // compressed timeframe
) on AppId
// Flag 2: Filter to high-risk permission scopes
| where Scopes has_any ("Mail.Read", "Mail.ReadWrite", "Files.ReadWrite",
"User.Read.All", "MailboxSettings.ReadWrite")
| project TimeGenerated, AppName, AppId, ConsentUser, ConsentIP,
Scopes, ConsentCount, FirstConsent, LastConsent
| order by ConsentCount desc, TimeGenerated asc// WHAT: Build an activity timeline for a suspected compromised OAuth application
// WHY: Maps the full scope of a supply chain compromise - shows exactly what data
// the attacker accessed, when, and how much, enabling accurate breach assessment
// TABLE: CloudAppEvents - captures API calls made by OAuth apps to Microsoft 365 services
// KEY FIELDS:
// Application - the display name of the OAuth app making the API calls
// ActionType - the Graph API operation performed (e.g., "FileDownloaded",
// "MailItemsAccessed", "FileUploaded", "FolderCreated", "SearchQueryPerformed")
// RawEventData.Size - data volume in bytes per operation (when available)
// OUTPUT: Hourly breakdown of app activities and data volume over the past 90 days,
// used to identify the start/end of anomalous activity (the compromise window)
CloudAppEvents
| where Timestamp > ago(90d)
| where Application == "CompromisedAppName"
| summarize Activities = count(), DataVolumeBytes = sum(RawEventData.Size)
by ActionType, bin(Timestamp, 1h)
| order by Timestamp ascOnce a compromised app is identified, you need to investigate whether it was used for lateral movement before containment. Attackers often use a compromised app’s API access to create inbox rules, forward emails to external addresses, add new permission grants, or create additional app registrations for persistence. The KQL query below maps the full lateral movement chain - every action the app took that could establish additional footholds in your tenant.
// WHAT: Investigate lateral movement by a compromised OAuth application
// WHY: Before containing the app, you must understand what persistence mechanisms
// the attacker established - inbox rules, mail forwarding, new app registrations,
// or additional consent grants - so containment covers ALL attacker footholds
// TABLES: CloudAppEvents - app API activity against Microsoft 365
// AuditLogs - Entra ID changes made by or involving the app
// OUTPUT: All high-risk actions by the compromised app that could indicate lateral
// movement, persistence, or data staging - grouped by attack technique
let CompromisedApp = "";
let InvestigationWindow = 30d;
// Phase 1: Detect inbox rule creation and mail forwarding (persistence + exfil)
let InboxRules = CloudAppEvents
| where Timestamp > ago(InvestigationWindow)
| where Application == CompromisedApp
| where ActionType in (
"New-InboxRule", "Set-InboxRule",
"Set-Mailbox", // forwarding configuration
"New-TransportRule"
)
| project Timestamp, ActionType,
TargetUser = AccountDisplayName,
Details = RawEventData,
Technique = "T1114.003 - Email Forwarding Rule";
// Phase 2: Detect new app registrations or consent grants (persistence)
let PersistenceActions = AuditLogs
| where TimeGenerated > ago(InvestigationWindow)
| where InitiatedBy has CompromisedApp
or TargetResources has CompromisedApp
| where OperationName in (
"Add application",
"Add service principal",
"Consent to application",
"Add delegated permission grant",
"Add app role assignment to service principal",
"Add service principal credentials"
)
| project Timestamp = TimeGenerated, ActionType = OperationName,
TargetUser = tostring(TargetResources[0].displayName),
Details = TargetResources,
Technique = "T1098.003 - Additional Cloud Credentials";
// Phase 3: Detect data staging and bulk access (exfiltration preparation)
let DataStaging = CloudAppEvents
| where Timestamp > ago(InvestigationWindow)
| where Application == CompromisedApp
| where ActionType in (
"FileDownloaded", "MailItemsAccessed",
"SearchQueryPerformed", "FileUploaded"
)
| summarize ActionCount = count(),
DataBytes = sum(tolong(RawEventData.Size)),
UniqueUsers = dcount(AccountDisplayName)
by ActionType, bin(Timestamp, 1h)
| where ActionCount > 50 // bulk activity threshold
| extend Technique = "T1530 - Data from Cloud Storage";
// Combine all lateral movement indicators
InboxRules
| union PersistenceActions
| project Timestamp, Technique, ActionType, TargetUser, Details
| order by Timestamp asc Containment must be swift and complete. The PowerShell script below performs a full containment sequence for a compromised OAuth application: disabling the service principal to stop all API access, revoking every delegated and application permission grant, removing all credentials and certificates, and revoking sessions for every user who consented to the app. Each action is logged with timestamps for forensic evidence preservation.
# WHAT: Full containment and remediation of a compromised OAuth application
# WHY: Every second the app remains active, the attacker can exfiltrate data. This
# script executes a complete containment sequence: disable, revoke permissions,
# remove credentials, revoke user sessions - with forensic evidence logging
# REQUIRES: Microsoft.Graph module v2.x
# Scopes: Application.ReadWrite.All, DelegatedPermissionGrant.ReadWrite.All,
# AppRoleAssignment.ReadWrite.All, User.RevokeSessions.All
Connect-MgGraph -Scopes "Application.ReadWrite.All",`
"DelegatedPermissionGrant.ReadWrite.All",`
"AppRoleAssignment.ReadWrite.All",`
"User.RevokeSessions.All"
function Invoke-AppContainment {
param(
[Parameter(Mandatory)][string]$ServicePrincipalId,
[Parameter(Mandatory)][string]$IncidentId
)
$log = @()
$ts = { (Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC" -AsUTC) }
Write-Host "=== CONTAINMENT INITIATED: $IncidentId ===" -ForegroundColor Red
# 1. DISABLE the service principal (stops all future API calls)
try {
Update-MgServicePrincipal -ServicePrincipalId $ServicePrincipalId `
-AccountEnabled:$false
$log += "$(& $ts) | DISABLED service principal $ServicePrincipalId"
Write-Host "[1/5] Service principal DISABLED" -ForegroundColor Green
} catch { Write-Host "[ERROR] Disable failed: $_" -ForegroundColor Red }
# 2. REVOKE all delegated permission grants (OAuth2 user consent)
$grants = Get-MgServicePrincipalOauth2PermissionGrant `
-ServicePrincipalId $ServicePrincipalId -All
foreach ($g in $grants) {
Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $g.Id
$log += "$(& $ts) | REVOKED delegated grant: $($g.Scope)"
}
Write-Host "[2/5] Revoked $($grants.Count) delegated permission grants" -ForegroundColor Yellow
# 3. REMOVE all app role assignments (application-level permissions)
$roles = Get-MgServicePrincipalAppRoleAssignment `
-ServicePrincipalId $ServicePrincipalId -All
foreach ($r in $roles) {
Remove-MgServicePrincipalAppRoleAssignment `
-ServicePrincipalId $ServicePrincipalId `
-AppRoleAssignmentId $r.Id
$log += "$(& $ts) | REMOVED app role: $($r.AppRoleId)"
}
Write-Host "[3/5] Removed $($roles.Count) app role assignments" -ForegroundColor Yellow
# 4. REMOVE all credentials from the app registration
$sp = Get-MgServicePrincipal -ServicePrincipalId $ServicePrincipalId
$appReg = Get-MgApplication -Filter "appId eq '$($sp.AppId)'" -ErrorAction SilentlyContinue
if ($appReg) {
foreach ($secret in $appReg.PasswordCredentials) {
Remove-MgApplicationPassword -ApplicationId $appReg.Id `
-KeyId $secret.KeyId
$log += "$(& $ts) | REMOVED client secret: $($secret.KeyId)"
}
foreach ($cert in $appReg.KeyCredentials) {
Remove-MgApplicationKey -ApplicationId $appReg.Id `
-KeyId $cert.KeyId
$log += "$(& $ts) | REMOVED certificate: $($cert.KeyId)"
}
Write-Host "[4/5] Removed all credentials from app registration" -ForegroundColor Yellow
}
# 5. REVOKE sessions for all users who consented to this app
$consentedUsers = $grants | Select-Object -ExpandProperty PrincipalId -Unique
foreach ($userId in $consentedUsers) {
Revoke-MgUserSignInSession -UserId $userId -ErrorAction SilentlyContinue
$log += "$(& $ts) | REVOKED sessions for user: $userId"
}
Write-Host "[5/5] Revoked sessions for $($consentedUsers.Count) affected users" -ForegroundColor Yellow
# Save forensic evidence log
$logPath = "Containment-$IncidentId-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
$log | Out-File $logPath -Encoding UTF8
Write-Host "`n=== CONTAINMENT COMPLETE ===" -ForegroundColor Green
Write-Host "Evidence log: $logPath" -ForegroundColor Cyan
Write-Host "Actions taken: $($log.Count)" -ForegroundColor Cyan
}
# Usage:
# Invoke-AppContainment `
# -ServicePrincipalId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
# -IncidentId "INC-2026-0310-001"After containing and remediating a supply chain compromise, you need to assess the full blast radius. How many apps from the same publisher exist in your tenant? Did any other vendor apps exhibit similar anomalous behaviour during the same timeframe? The KQL query below performs a supply chain impact assessment - identifying all apps sharing characteristics with the compromised app (same publisher, same IP ranges, similar permission profiles) that may also require investigation.
// WHAT: Supply chain impact assessment - find all apps related to the compromised vendor
// WHY: Supply chain attacks often affect multiple apps from the same publisher or
// infrastructure. If one vendor app was compromised, other apps sharing the same
// publisher, IP ranges, or permission patterns may also be affected.
// TABLES: AADServicePrincipalSignInLogs - authentication patterns for all service principals
// AuditLogs - consent and configuration history
// OUTPUT: All apps matching the compromised app's publisher or authentication patterns,
// with risk indicators for each - used to scope the investigation radius
let CompromisedAppId = "";
let InvestigationWindow = 30d;
// Step 1: Get the compromised app's characteristics
let CompromisedProfile = AADServicePrincipalSignInLogs
| where TimeGenerated > ago(InvestigationWindow)
| where AppId == CompromisedAppId
| summarize CompromisedIPs = make_set(IPAddress),
CompromisedLocations = make_set(Location),
Publisher = any(AppDisplayName)
by HomeTenantId;
// Step 2: Find all OTHER apps from the same home tenant (same vendor)
let RelatedApps = AADServicePrincipalSignInLogs
| where TimeGenerated > ago(InvestigationWindow)
| where ResultType == 0
| where AppId != CompromisedAppId
| join kind=inner CompromisedProfile on HomeTenantId
| summarize AppIPs = make_set(IPAddress),
SignInCount = count(),
LastActive = max(TimeGenerated)
by AppId, AppDisplayName, HomeTenantId;
// Step 3: Check which related apps share IP infrastructure with compromised app
RelatedApps
| join kind=inner CompromisedProfile on HomeTenantId
| mv-expand AppIP = AppIPs to typeof(string)
| extend SharesIP = CompromisedIPs has AppIP
| summarize SharedIPCount = countif(SharesIP),
TotalIPs = dcount(AppIP),
SignInCount = any(SignInCount),
LastActive = any(LastActive)
by AppId, AppDisplayName, HomeTenantId
| extend RiskLevel = case(
SharedIPCount > 0, "HIGH - Shares infrastructure with compromised app",
SignInCount > 100, "MEDIUM - Same vendor, high activity",
"LOW - Same vendor, normal activity"
)
| order by case(RiskLevel has "HIGH", 1, RiskLevel has "MEDIUM", 2, 3) asc After an OAuth supply chain incident, you need to verify that remediation was complete - no attacker persistence remains, all affected user sessions have been invalidated, and the app estate is clean. The PowerShell script below performs a comprehensive post-incident verification: confirming the compromised app is fully disabled and stripped of permissions, checking that no new suspicious apps appeared during the incident window, and validating that detection rules are in place to catch similar attacks in the future.
# WHAT: Post-incident recovery verification for OAuth supply chain compromise
# WHY: "Trust but verify" - after containment and remediation, you must confirm that
# all attacker footholds have been removed, no persistence remains, and your
# detection capabilities are updated to catch similar attacks in the future
# REQUIRES: Microsoft.Graph module v2.x
# Scopes: Application.Read.All, AuditLog.Read.All, SecurityEvents.Read.All
Connect-MgGraph -Scopes "Application.Read.All","AuditLog.Read.All",`
"SecurityEvents.Read.All"
function Invoke-PostIncidentVerification {
param(
[Parameter(Mandatory)][string]$ServicePrincipalId,
[Parameter(Mandatory)][string]$IncidentId,
[Parameter(Mandatory)][datetime]$IncidentStartDate
)
$results = @()
Write-Host "=== POST-INCIDENT VERIFICATION: $IncidentId ===" -ForegroundColor Cyan
# CHECK 1: Verify service principal is disabled
$sp = Get-MgServicePrincipal -ServicePrincipalId $ServicePrincipalId
$check1 = [PSCustomObject]@{
Check = "Service Principal Disabled"
Status = if (-not $sp.AccountEnabled) { "PASS" } else { "FAIL" }
Detail = "AccountEnabled = $($sp.AccountEnabled)"
}
$results += $check1
# CHECK 2: Verify no permission grants remain
$grants = Get-MgServicePrincipalOauth2PermissionGrant `
-ServicePrincipalId $ServicePrincipalId -All
$roles = Get-MgServicePrincipalAppRoleAssignment `
-ServicePrincipalId $ServicePrincipalId -All
$check2 = [PSCustomObject]@{
Check = "Permissions Fully Revoked"
Status = if ($grants.Count -eq 0 -and $roles.Count -eq 0) { "PASS" } else { "FAIL" }
Detail = "Delegated grants: $($grants.Count), App roles: $($roles.Count)"
}
$results += $check2
# CHECK 3: Verify no credentials remain on the app registration
$appReg = Get-MgApplication -Filter "appId eq '$($sp.AppId)'" -ErrorAction SilentlyContinue
$credCount = 0
if ($appReg) {
$credCount = $appReg.PasswordCredentials.Count + $appReg.KeyCredentials.Count
}
$check3 = [PSCustomObject]@{
Check = "Credentials Removed"
Status = if ($credCount -eq 0) { "PASS" } else { "FAIL" }
Detail = "Remaining credentials: $credCount"
}
$results += $check3
# CHECK 4: Scan for new app registrations created during incident window
$suspiciousApps = Get-MgApplication -All | Where-Object {
$_.CreatedDateTime -ge $IncidentStartDate -and
$_.CreatedDateTime -le (Get-Date)
}
$check4 = [PSCustomObject]@{
Check = "No Suspicious New Apps"
Status = if ($suspiciousApps.Count -le 2) { "PASS" } else { "REVIEW" }
Detail = "Apps created since incident: $($suspiciousApps.Count)"
}
$results += $check4
# CHECK 5: Verify inbox rules created during incident have been removed
# (This requires Exchange Online PowerShell - flag for manual review)
$check5 = [PSCustomObject]@{
Check = "Inbox Rules Reviewed"
Status = "MANUAL"
Detail = "Verify no attacker inbox rules remain via Exchange Online PowerShell"
}
$results += $check5
# Display verification results
Write-Host "`n=== VERIFICATION RESULTS ===" -ForegroundColor White
foreach ($r in $results) {
$color = switch ($r.Status) {
"PASS" { "Green" }
"FAIL" { "Red" }
"REVIEW" { "Yellow" }
"MANUAL" { "Cyan" }
}
Write-Host "[$($r.Status)] $($r.Check): $($r.Detail)" -ForegroundColor $color
}
# Export verification report
$reportPath = "PostIncident-Verification-$IncidentId.json"
$results | ConvertTo-Json | Out-File $reportPath
Write-Host "`nReport saved: $reportPath" -ForegroundColor Green
$failCount = ($results | Where-Object Status -eq "FAIL").Count
if ($failCount -gt 0) {
Write-Host "`nWARNING: $failCount checks FAILED - remediation incomplete!" -ForegroundColor Red
} else {
Write-Host "`nAll automated checks passed. Complete manual review items." -ForegroundColor Green
}
}
# Usage:
# Invoke-PostIncidentVerification `
# -ServicePrincipalId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
# -IncidentId "INC-2026-0310-001" `
# -IncidentStartDate (Get-Date).AddDays(-7)| Resource | Description |
|---|---|
| Investigate OAuth App Attacks | Microsoft guidance on investigating compromised and malicious OAuth applications |
| Consent Phishing Investigation | Step-by-step guide for investigating illicit consent grant attacks |
| Workload Identity Protection | Secure service principals with Conditional Access and identity protection |
| MITRE ATT&CK - Cloud Techniques | Reference for cloud-specific attack techniques used in supply chain compromises |
| Microsoft Graph - Revoke Sessions | API reference for programmatically revoking user and app sessions |
| Midnight Blizzard Attack Analysis | Microsoft’s analysis of the Midnight Blizzard OAuth app attack - lessons learned |