M365 SOC Alert Triage Cheatsheet
How to triage a Microsoft 365 alert: classify it, pull the right table to confirm it, decide containment, and know what survives a token revoke. The analyst's first thirty minutes, on one page. No account needed.
This is the Microsoft 365 / Sentinel / Defender XDR triage path: the questions an analyst answers before escalating, the tables that confirm a true positive, and the containment moves that actually hold in a cloud-identity world. Tune thresholds to your tenant before acting on any single query.
The triage decision
Triage is one question asked fast: true positive, benign true positive, or false positive. You are not solving the incident yet, you are deciding whether there is one and how quickly it must move. Severity drives the SLA, so classify before you investigate, and reclassify as evidence changes the picture.
| Classification | Means | Action |
|---|---|---|
| True positive | Malicious activity confirmed or strongly indicated. | Escalate at the severity the impact warrants; begin the playbook. |
| Benign true positive | The activity happened but was authorized (admin, pentest, expected automation). | Document the authorization; tune the rule so it stops firing on it. |
| False positive | The detection logic misfired; the activity did not occur as alerted. | Close, and feed the rule back to detection engineering to fix. |
Triage by alert domain
Most M365 alerts fall into four domains. For each, the primary tables, the highest-value detections, and what separates the true positive from the noise. These are the course's real detections, abbreviated; pull the table that matches your alert and read the confirm line.
Identity & access
Sign-in, MFA, conditional-access, and directory-change alerts. The question across all of them: was this the real user, acting normally?
Credential compromise from unfamiliar infrastructure
// Hypothesis: Credential compromise from unfamiliar infrastructure
// ATT&CK: T1078 (Valid Accounts)
let baseline_period = 30d;
let detection_window = 1h;
// Step 1: Build per-user IP baseline from last 30 days
let known_ips =
SigninLogs
| where TimeGenerated between(
ago(baseline_period) .. ago(detection_window))
| where ResultType == 0
| distinct UserPrincipalName, IPAddress;
// Step 2: Find successful sign-ins from IPs NOT in baseline
SigninLogs
| where TimeGenerated > ago(detection_window)
| where ResultType == 0
| where isnotempty(IPAddress)
| join kind=leftanti known_ips
on UserPrincipalName, IPAddress
// Step 3: Enrich with location and device context
| extend
City = tostring(LocationDetails.city),
Country = tostring(LocationDetails.countryOrRegion),
ASN = AutonomousSystemNumber,
Browser = tostring(DeviceDetail.browser),
OS = tostring(DeviceDetail.operatingSystem),
IsManaged = tostring(DeviceDetail.isManaged)
| project
TimeGenerated,
UserPrincipalName,
IPAddress,
ASN,
City,
Country,
AppDisplayName,
Browser,
OS,
IsManaged,
ConditionalAccessStatus,
RiskLevelDuringSignIn
Confirms TP: a successful sign-in from an IP and ASN the user has never used, especially paired with a new device or a sensitive action minutes later.
Credential compromise via impossible travel
// Hypothesis: Credential compromise via impossible travel
// ATT&CK: T1078 (Valid Accounts)
let speed_threshold_kmh = 900;
let min_distance_km = 100;
let detection_window = 1h;
SigninLogs
| where TimeGenerated > ago(detection_window)
| where ResultType == 0
| extend
Lat = todouble(LocationDetails.geoCoordinates.latitude),
Lon = todouble(LocationDetails.geoCoordinates.longitude),
City = tostring(LocationDetails.city),
Country = tostring(LocationDetails.countryOrRegion)
| where isnotempty(Lat) and isnotempty(Lon)
// Get the previous sign-in for each user
| sort by UserPrincipalName asc, TimeGenerated asc
| extend
PrevTime = prev(TimeGenerated, 1),
PrevLat = prev(Lat, 1),
PrevLon = prev(Lon, 1),
PrevCity = prev(City, 1),
PrevCountry = prev(Country, 1),
PrevIP = prev(IPAddress, 1),
PrevUser = prev(UserPrincipalName, 1)
// Only compare consecutive sign-ins for the same user
| where UserPrincipalName == PrevUser
| where isnotempty(PrevLat) and isnotempty(PrevLon)
// Calculate distance (meters) and speed (km/h)
| extend DistanceMeters = geo_distance_2points(
Lon, Lat, PrevLon, PrevLat)
| extend DistanceKm = DistanceMeters / 1000.0
| extend TimeDiffHours = datetime_diff(
"second", TimeGenerated, PrevTime) / 3600.0
| where TimeDiffHours > 0
| extend SpeedKmh = round(DistanceKm / TimeDiffHours, 0)
// Filter: minimum distance + exceeds flight speed
| where DistanceKm > min_distance_km
| where SpeedKmh > speed_threshold_kmh
| project
TimeGenerated,
UserPrincipalName,
CurrentIP = IPAddress,
CurrentCity = City,
CurrentCountry = Country,
PrevTime,
PrevIP,
PrevCity,
PrevCountry,
DistanceKm = round(DistanceKm, 0),
TimeDiffMinutes = round(TimeDiffHours * 60, 1),
SpeedKmh
Confirms TP: two successful sign-ins too far apart in distance for the elapsed time. Rule out corporate VPN egress and cloud-proxy IPs first.
Direct privileged role assignment bypassing PIM
// Hypothesis: Direct privileged role assignment bypassing PIM
// ATT&CK: T1078.004 (Cloud Accounts), T1098 (Account Manipulation)
let high_priv_roles = dynamic([
"Global Administrator",
"Security Administrator",
"Privileged Role Administrator",
"Privileged Authentication Administrator",
"Exchange Administrator",
"SharePoint Administrator",
"Cloud Application Administrator",
"Application Administrator"
]);
AuditLogs
| where Category =~ "RoleManagement"
| where OperationName has "Add member to role outside of PIM"
or (LoggedByService =~ "Core Directory"
and OperationName =~ "Add member to role"
and Identity != "MS-PIM")
// Extract the assigned role name
| mv-apply TargetRole = TargetResources on (
where TargetRole.type =~ "Role"
| extend RoleName = tostring(TargetRole.displayName)
)
// Extract who was assigned the role
| mv-apply TargetUser = TargetResources on (
where TargetUser.type in~ ("User", "ServicePrincipal")
| extend TargetUPN = tostring(TargetUser.userPrincipalName),
TargetDisplay = tostring(TargetUser.displayName)
)
// Extract who performed the assignment
| extend
InitiatorUPN = tostring(InitiatedBy.user.userPrincipalName),
InitiatorIP = tostring(
iff(isnotempty(InitiatedBy.user.ipAddress),
InitiatedBy.user.ipAddress,
InitiatedBy.app.ipAddress)),
InitiatorApp = tostring(InitiatedBy.app.displayName)
| extend IsHighPriv = RoleName in (high_priv_roles)
| project
TimeGenerated,
RoleName,
IsHighPriv,
TargetUPN,
TargetDisplay,
InitiatorUPN,
InitiatorApp,
InitiatorIP,
OperationName,
Result
Confirms TP: a privileged role granted by direct assignment rather than a PIM activation, in a tenant that uses PIM, is an escalation that skipped the controlled path.
Email & collaboration
Phishing, malicious delivery, and mailbox-rule abuse. The question: did it reach the user, and did the attacker set up to persist or profit?
BEC persistence via external email forwarding
// Hypothesis: BEC persistence via external email forwarding
// ATT&CK: T1114.003 (Email Forwarding Rule)
let org_domains = dynamic([
"northgateeng.com",
"northgateeng.onmicrosoft.com"
]);
let forward_params = dynamic([
"ForwardTo", "ForwardAsAttachmentTo", "RedirectTo"
]);
OfficeActivity
| where TimeGenerated > ago(1h)
| where Operation in ("New-InboxRule", "Set-InboxRule")
| extend Params = parse_json(Parameters)
// Extract forwarding destination
| mv-apply P = Params on (
where P.Name in (forward_params)
| extend ForwardDest = tostring(P.Value)
)
| where isnotempty(ForwardDest)
// Extract the domain from the forwarding address
| extend ForwardDomain = tostring(
split(ForwardDest, "@")[1])
// Filter: external domains only
| where not(ForwardDomain has_any (org_domains))
// Extract rule name
| mv-apply N = Params on (
where N.Name == "Name"
| extend RuleName = tostring(N.Value)
)
| project
TimeGenerated,
UserId,
Operation,
RuleName,
ForwardDest,
ForwardDomain,
ClientIP,
SessionId
Confirms TP: a rule forwarding mail to a domain outside the org. The classic BEC persistence move; the rule name is often blank or a single character.
Malicious transport rule: org-wide interception
// Hypothesis: Malicious transport rule — org-wide interception
// ATT&CK: T1114.003 (Email Forwarding Rule)
let high_risk_params = dynamic([
"BlindCopyTo", "RedirectMessageTo",
"RemoveHeader"
]);
OfficeActivity
| where TimeGenerated > ago(1h)
| where Operation in (
"New-TransportRule",
"Set-TransportRule",
"Enable-TransportRule")
| extend Params = parse_json(Parameters)
| where Params has_any (high_risk_params)
// Extract rule details
| mv-apply P = Params on (
summarize RuleConfig = make_bag(
bag_pack(tostring(P.Name), tostring(P.Value)))
)
| extend
RuleName = tostring(RuleConfig.Name),
BCCTo = tostring(RuleConfig.BlindCopyTo),
RedirectTo = tostring(RuleConfig.RedirectMessageTo),
HeaderRemoved = tostring(RuleConfig.RemoveHeader),
SubjectFilter = tostring(RuleConfig.SubjectContainsWords),
SenderFilter = tostring(RuleConfig.FromAddressContainsWords)
| project
TimeGenerated,
UserId,
Operation,
RuleName,
BCCTo,
RedirectTo,
HeaderRemoved,
SubjectFilter,
SenderFilter,
ClientIP
Confirms TP: an org-wide transport rule is far higher impact than a single mailbox rule, it can intercept or redirect mail tenant-wide. Rarely changed legitimately.
AiTM phishing on cloud hosting platforms
// Hypothesis: AiTM phishing on cloud hosting platforms
// ATT&CK: T1566.002 (Spearphishing Link)
let aitm_hosting = dynamic([
"azurestaticapps.net",
"workers.dev",
"pages.dev",
"vercel.app",
"amplifyapp.com",
"netlify.app",
"web.app",
"firebaseapp.com",
"glitch.me",
"herokuapp.com"
]);
// Get latest verdict per email (post-Oct 2025 schema)
let delivered_emails =
EmailEvents
| where TimeGenerated > ago(1h)
| summarize arg_max(TimeGenerated, *) by NetworkMessageId
| where DeliveryAction == "Delivered"
| where EmailDirection == "Inbound";
// Join with URL info to find AiTM hosting patterns
delivered_emails
| join kind=inner (
EmailUrlInfo
| where TimeGenerated > ago(1h)
| where UrlDomain has_any (aitm_hosting)
) on NetworkMessageId
| project
TimeGenerated,
RecipientEmailAddress,
SenderFromAddress,
Subject,
UrlDomain,
Url,
DeliveryAction,
ThreatTypes,
AuthenticationDetails
Confirms TP: sign-in telemetry pointing at known AiTM proxy infrastructure (cloud-hosting ASNs), especially with MFA satisfied from that infrastructure.
Endpoint & lateral movement
Process, script, and lateral-movement signal from Defender. The question: is this malicious execution, and is it spreading?
LOLBin abuse: download, decode, execute
// Hypothesis: LOLBin abuse — download, decode, execute
// ATT&CK: T1218, T1105, T1059
let suspicious_parents = dynamic([
"WINWORD.EXE", "EXCEL.EXE", "POWERPNT.EXE",
"outlook.exe", "msedge.exe", "chrome.exe",
"firefox.exe", "iexplore.exe",
"wmiprvse.exe", "wscript.exe", "cscript.exe"
]);
DeviceProcessEvents
| where Timestamp > ago(1h)
// Certutil: download or decode operations
| where
(FileName =~ "certutil.exe" and
(ProcessCommandLine has "-urlcache"
or ProcessCommandLine has "-decode"
or ProcessCommandLine has "-decodehex"
or ProcessCommandLine has "http"))
// Mshta: any execution (rare in enterprise)
or (FileName =~ "mshta.exe" and
(ProcessCommandLine has "vbscript"
or ProcessCommandLine has "javascript"
or ProcessCommandLine has "http"))
// Bitsadmin: transfer operations
or (FileName =~ "bitsadmin.exe" and
(ProcessCommandLine has "/transfer"
or ProcessCommandLine has "/create"
or ProcessCommandLine has "http"))
// Rundll32: suspicious DLL load paths
or (FileName =~ "rundll32.exe" and
(ProcessCommandLine has_any (
"\\Temp\\", "\\AppData\\",
"\\Downloads\\", "\\ProgramData\\",
"http")))
// Regsvr32: network-based execution (squiblydoo)
or (FileName =~ "regsvr32.exe" and
(ProcessCommandLine has "/i:http"
or ProcessCommandLine has "scrobj.dll"))
| extend SuspiciousParent =
InitiatingProcessFileName in~ (suspicious_parents)
| project
Timestamp,
DeviceName,
FileName,
ProcessCommandLine,
ParentProcess = InitiatingProcessFileName,
GrandParent = InitiatingProcessParentFileName,
SuspiciousParent,
AccountName,
SHA256
Confirms TP: a living-off-the-land binary (certutil, mshta, bitsadmin) used to download or decode, spawned by an Office app or browser.
LSASS credential dump attempt
// Hypothesis: LSASS credential dump attempt
// ATT&CK: T1003.001 (LSASS Memory)
DeviceProcessEvents
| where Timestamp > ago(1h)
| where
// Procdump targeting LSASS
(FileName =~ "procdump.exe" and
ProcessCommandLine has_any ("lsass", "-ma"))
// Comsvcs.dll MiniDump (LOLBin credential dump)
or (FileName =~ "rundll32.exe" and
ProcessCommandLine has "comsvcs.dll" and
ProcessCommandLine has "MiniDump")
// Mimikatz indicators
or ProcessCommandLine has_any (
"sekurlsa", "logonpasswords",
"kerberos::list", "lsadump")
// LSASS dump via Task Manager or renamed tool
or (ProcessCommandLine has "lsass" and
ProcessCommandLine has_any (
".dmp", "dump", "MiniDumpWriteDump"))
| project
Timestamp,
DeviceName,
FileName,
ProcessCommandLine,
InitiatingProcessFileName,
AccountName,
SHA256
Confirms TP: a process opening LSASS with read access, or a SAM-hive copy, outside the handful of legitimate EDR/backup callers you have baselined.
Ransomware preparation: shadow copy + recovery
// Hypothesis: Ransomware preparation — shadow copy + recovery
// ATT&CK: T1490 (Inhibit System Recovery)
DeviceProcessEvents
| where Timestamp > ago(15m)
| where
// Shadow copy deletion
(FileName =~ "vssadmin.exe" and
ProcessCommandLine has "delete shadows")
or (FileName =~ "wmic.exe" and
ProcessCommandLine has "shadowcopy delete")
// Recovery disabling
or (FileName =~ "bcdedit.exe" and
(ProcessCommandLine has "recoveryenabled"
or ProcessCommandLine has "safeboot"))
// Backup process termination
or (FileName =~ "wmic.exe" and
ProcessCommandLine has "process" and
ProcessCommandLine has "delete")
// Security/backup service stopping
or (FileName =~ "net.exe" and
ProcessCommandLine has "stop" and
ProcessCommandLine has_any (
"vss", "backup", "sql",
"exchange", "defender"))
| summarize
Commands = count(),
Devices = dcount(DeviceName),
DeviceList = make_set(DeviceName, 10),
CommandList = make_set(ProcessCommandLine, 5)
by bin(Timestamp, 15m)
| where Devices >= 3 or Commands >= 4
Confirms TP: shadow-copy deletion (vssadmin/wmic) and recovery tampering, the near-universal pre-encryption step. Treat as an active incident, not a tuning candidate.
Cloud & SaaS
OAuth grants, app abuse, storage access, and session anomalies. The question: is an attacker-controlled app or a stolen session operating in the tenant?
Consent phishing: rare app with high-risk permissions
// Hypothesis: Consent phishing — rare app with high-risk permissions
// ATT&CK: T1566, T1550.001, T1098.003
let high_risk_perms = dynamic([
"Mail.ReadWrite", "Mail.Send",
"Files.ReadWrite.All", "Contacts.ReadWrite",
"MailboxSettings.ReadWrite",
"Notes.ReadWrite.All",
"User.ReadWrite.All",
"Application.ReadWrite.All",
"Directory.ReadWrite.All"
]);
// Build baseline of known apps (30-day history)
let known_apps =
AuditLogs
| where TimeGenerated between(ago(30d) .. ago(1d))
| where OperationName == "Consent to application"
| extend AppId = tostring(
TargetResources[0].id)
| summarize by AppId;
// Detect new consent grants with high-risk permissions
AuditLogs
| where TimeGenerated > ago(1h)
| where OperationName == "Consent to application"
| where Result == "success"
| extend
AppId = tostring(TargetResources[0].id),
AppName = tostring(TargetResources[0].displayName),
Permissions = tostring(
TargetResources[0].modifiedProperties)
| where Permissions has_any (high_risk_perms)
// Filter to apps not seen in the last 30 days
| join kind=leftanti known_apps on AppId
| extend ConsentUser = tostring(
InitiatedBy.user.userPrincipalName)
| project
TimeGenerated,
ConsentUser,
AppName,
AppId,
Permissions
Confirms TP: consent to an unverified app requesting high-risk Graph scopes (Mail.ReadWrite, Files.ReadWrite.All), especially an app new to the tenant.
Key listing → bulk blob reads = exfiltration
// Hypothesis: Key listing → bulk blob reads = exfiltration
// ATT&CK: T1530, T1537
// Step 1: Management plane — who listed storage keys?
let key_listings =
AzureActivity
| where TimeGenerated > ago(1h)
| where OperationNameValue =~
"microsoft.storage/storageaccounts/listkeys/action"
| where ActivityStatusValue == "Success"
| extend StorageAccount = tostring(
split(_ResourceId, "/")[-1])
| project
KeyListTime = TimeGenerated,
Caller, CallerIpAddress,
StorageAccount;
// Step 2: Data plane — bulk blob reads in same window
let bulk_reads =
StorageBlobLogs
| where TimeGenerated > ago(1h)
| where OperationName in (
"GetBlob", "ListBlobs", "ListContainers")
| where StatusCode between (200 .. 299)
| summarize
BlobReads = count(),
UniqueContainers = dcount(ObjectKey),
FirstRead = min(TimeGenerated),
LastRead = max(TimeGenerated),
DataTransferredMB = round(
sum(ResponseBodySize) / 1048576.0, 2)
by AccountName, CallerIpAddress,
AuthenticationType;
// Step 3: Correlate — key listing then bulk reads
key_listings
| join kind=inner bulk_reads
on $left.StorageAccount == $right.AccountName
| where BlobReads > 50
| project
KeyListTime, Caller,
StorageAccount, BlobReads,
UniqueContainers, DataTransferredMB,
ReadIP = CallerIpAddress1,
AuthenticationType
Confirms TP: a storage-key listing followed by bulk blob reads is the cloud exfiltration signature, the key listing is the access, the bulk read is the theft.
Same session, different IPs = token replay
// Hypothesis: Same session, different IPs = token replay
// ATT&CK: T1539 (Steal Web Session Cookie)
SigninLogs
| where TimeGenerated > ago(1h)
| where ResultType == 0
| where isnotempty(OriginalRequestId)
| extend
Country = tostring(LocationDetails.countryOrRegion),
City = tostring(LocationDetails.city)
| summarize
UniqueIPs = dcount(IPAddress),
IPs = make_set(IPAddress, 5),
Countries = make_set(Country),
Apps = make_set(AppDisplayName),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by UserPrincipalName, OriginalRequestId
// Sessions used from 2+ IPs within the lookback
| where UniqueIPs > 1
| extend SessionDuration = LastSeen - FirstSeen
| extend MultiCountry = array_length(Countries) > 1
| project
UserPrincipalName, OriginalRequestId,
UniqueIPs, IPs, Countries,
MultiCountry, Apps,
FirstSeen, LastSeen, SessionDuration
Confirms TP: the same session token used from two different IPs is token replay, the attacker is riding a stolen session, not authenticating fresh.
The containment decision
Containment in M365 is not "disable the account" reflexively. It is three questions, in order, because a stolen session token can outlive a password reset and an app grant can outlive both.
- What does the adversary currently have access to? The account, a live session token, a consented app, a mailbox rule, a registered device? Each is a separate door.
- Does persistence survive token revocation? Revoking sessions and resetting the password closes the account door, but an OAuth app grant or an app-registration credential keeps working. Token revocation alone is not containment if an app holds access.
- Verify containment. After acting, confirm: no new sign-ins succeed, the app grant is gone, the mailbox rule is removed. Containment is proven by the absence of continued access, not by the action taken.
Revoke-MgUserSignInSession / revoke refresh tokens), then check for the persistence that survives both.The investigation playbook structure
A production playbook has seven sections. Triage is one phase inside it. The frame keeps an investigation repeatable and handoff-ready under pressure.
| Section | Purpose |
|---|---|
| 1. Trigger conditions | What fires this playbook: the alert(s) and the threshold. |
| 2. Severity & SLA | Classification matrix and the clock each level starts. |
| 3. Triage | Confirm TP/FP fast; pull the domain table; decide escalate or close. |
| 4. Investigation | Scope it: who, what, when, how far. Collect and preserve evidence. |
| 5. Containment | The three-question decision above; act proportionally. |
| 6. Communication | Who is told, when, in what words. Stakeholders and legal. |
| 7. Post-incident | Lessons, rule tuning, the detection gap that let it in. |
Alert: high-risk sign-in for a user who also satisfied MFA from a new location minutes earlier. Triage pulls SigninLogs (MFA satisfied, unfamiliar IP, ConditionalAccessStatus success) and AuditLogs (a new inbox rule created right after). That pairing, MFA-satisfied sign-in plus an immediate mailbox-rule change, is the AiTM signature: the attacker proxied the login, captured the session token, and is now operating as the user.
Contain: reset password AND revoke sessions (the token gap), remove the inbox rule, check for app-registration or OAuth persistence, then verify no further sign-ins succeed. A password reset alone would have left the live session running.
Alert: an external forwarding rule created on a finance user's mailbox. Triage pulls OfficeActivity (the New-InboxRule with a ForwardTo an external domain) and EmailEvents (the phish that landed days earlier). The rule quietly copies invoice and payment threads to the attacker, who then injects a fraudulent payment request into a real conversation. BEC differs from credential phishing: the goal is the money, not the access, so the inbox rule and any payment-thread activity matter more than the sign-in itself.
Contain: remove the rule, revoke sessions, and immediately check whether a fraudulent payment was already initiated, financial recovery is time-critical and runs in parallel with the technical containment, not after it.
Alert: mass file activity and shadow-copy deletion on an endpoint. The value is that this fires before encryption completes. Triage pulls DeviceProcessEvents (vssadmin or wmic deleting shadow copies, a near-universal pre-encryption step) and DeviceFileEvents (rapid rename/modify across many files). The window between this signal and full encryption is short, so triage here is about speed: confirm the pattern and isolate immediately.
Contain: isolate the host from the network now (Defender's isolate action), before investigating further. Pre-encryption is the one triage moment where containment correctly precedes full scoping, every minute of analysis is files lost.
M365 persistence checklist
After an identity compromise, check everything that survives a password reset and session revoke. This is the list attackers rely on you to skip.
| Check | Why it survives a token revoke |
|---|---|
| OAuth app consents | A consented app holds its own access token, independent of the user's password. |
| App registrations / service principals | Client secrets and certificates authenticate as the app, not the user. |
| Inbox / forwarding rules | Continue forwarding or hiding mail regardless of credential changes. |
| Registered devices / MFA methods | An attacker-registered device or authenticator re-establishes access. |
| Mailbox delegation / permissions | Granted access to another mailbox persists until explicitly removed. |
| Conditional access exclusions | An added exclusion quietly weakens enforcement for the attacker. |
Quick lookup
Alert type to table
| Alert is about | Pull |
|---|---|
| Sign-in / MFA / risk | SigninLogs, AADNonInteractiveUserSignInLogs |
| Role / app / directory change | AuditLogs |
| Phishing / mail delivery | EmailEvents |
| Inbox rules / mailbox ops | OfficeActivity |
| Process / script / endpoint | DeviceProcessEvents, DeviceNetworkEvents |
| OAuth / SaaS / download | CloudAppEvents |
Containment action to effect
| Action | Stops / misses |
|---|---|
| Password reset | Stops new password auth. MISSES the live session token and app grants. |
| Revoke sessions/tokens | Stops the live session. MISSES OAuth apps and app registrations. |
| Remove app consent | Stops the consented app. Pair with the above for full closure. |
| Disable account | Blunt but total for the user. MISSES app-registration credentials. |
From triaging alerts to running the SOC
This cheatsheet is the triage path. M365 Security Operations teaches the whole discipline: building the detections, writing the playbooks, automating the response, and measuring the SOC that runs them.
Explore the course