The M365 and Entra ID Detection Reference
Every attacker technique that appears in real Microsoft 365 and Entra ID intrusions, mapped to the MITRE ATT&CK Identity Provider and Office Suite matrices, with the KQL to detect it, the data source it queries, and the false positives to expect. Built for the analyst who has to catch this in production, not memorize a framework.
What this is
Microsoft 365 and Entra ID are attacked through their own features, not through software flaws. An adversary who phishes one session cookie, consents one malicious app, or registers one rogue device is using the platform exactly as designed. Detection is how you catch that, and detection in M365 means knowing which log records the behavior, what normal looks like, and the query that separates the two.
This reference covers the M365 and Entra attack surface the way MITRE scopes it: the Identity Provider matrix (Entra ID, the identity plane) and the Office Suite matrix (Exchange, SharePoint, Teams, the productivity plane). For each technique that appears in real intrusions you get what it looks like in the logs, the KQL that detects it, the data source it queries, and the false positives that will fool you if you deploy it without tuning.
The reference, in four parts
This hub pulls together four detailed references into one working order. Read them as a sequence: understand the attack surface, hunt for activity, write the detection, engineer it as a repeatable discipline.
Coverage map
The reference maps to the MITRE ATT&CK Identity Provider and Office Suite matrices. Each technique below links to its detection guidance. This is a living reference: sections marked authoring are being written to the same verified standard as the rest and will link through as they land.
Credential Access
This is where most M365 intrusions start. The attacker wants a valid session, and in a cloud-first tenant that means phishing a token, spraying a password, or fatiguing a user into approving MFA. The detections below query the identity plane. One schema note before you deploy any of them: on December 9, 2025 the advanced-hunting table AADSignInEventsBeta was replaced by EntraIdSignInEvents. If you are copying older queries that reference the beta table, update them. The queries here use the current tables.
AiTM phishing puts a reverse proxy between the user and the real login page, captures the authenticated session cookie after MFA completes, and replays it. The attacker never needs the password or the second factor again. The tell is in the sign-in risk signals: Entra Identity Protection flags these sessions with specific risk event types. Hunt for them directly rather than waiting for an alert.
SigninLogs
| where TimeGenerated > ago(7d)
| where RiskLevelDuringSignIn in ("high", "medium")
// or match the risk detections Identity Protection assigns to AiTM
or RiskEventTypes_V2 has_any ("adversaryInTheMiddle", "anomalousToken", "tokenIssuerAnomaly")
| project TimeGenerated, UserPrincipalName, IPAddress, AppDisplayName,
RiskLevelDuringSignIn, RiskEventTypes_V2, ConditionalAccessStatus, ResultType
| sort by TimeGenerated descThe stronger signal is correlation. A user who clicks a phishing URL and then produces a risky sign-in within the hour is the AiTM pattern in two events. If you have Defender for Office 365, join the click to the sign-in:
let clicks = UrlClickEvents
| where Timestamp > ago(7d)
| where ThreatTypes has "Phish"
| project ClickTime = Timestamp, AccountUpn, Url;
clicks
| join kind=inner (
EntraIdSignInEvents
| where RiskLevelDuringSignIn in ("high", "medium")
| project SignInTime = Timestamp, AccountUpn, IPAddress, RiskLevelDuringSignIn
) on AccountUpn
| where SignInTime between (ClickTime .. (ClickTime + 1h))
| project ClickTime, SignInTime, AccountUpn, Url, IPAddress, RiskLevelDuringSignInRather than steal a session, the attacker gets the user to consent to a malicious OAuth application, which is then granted a token with the scopes the user approved. The token persists independent of the user's password and MFA. Detection lives in the consent and app-role-assignment events in AuditLogs.
AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName in ("Consent to application", "Add app role assignment grant to user")
| extend target = tostring(TargetResources[0].displayName)
| extend actor = tostring(InitiatedBy.user.userPrincipalName)
| mv-expand ModifiedProperties = TargetResources[0].modifiedProperties
| where ModifiedProperties.displayName == "ConsentAction.Permissions"
| project TimeGenerated, actor, target,
Permissions = tostring(ModifiedProperties.newValue)
| sort by TimeGenerated descPassword spraying tries one password across many accounts to stay under lockout thresholds. In the sign-in logs it is a single source (or a small set of sources) generating failures across a large set of distinct users, with the occasional success. The detection is a fan-out on failed sign-ins grouped by source.
SigninLogs
| where TimeGenerated > ago(1d)
| where ResultType != 0
// 50126 = invalid password; the classic spray error code
| where ResultType == 50126
| summarize
FailedUsers = dcount(UserPrincipalName),
Attempts = count(),
Users = make_set(UserPrincipalName, 15)
by IPAddress, bin(TimeGenerated, 1h)
| where FailedUsers >= 10
| sort by FailedUsers descOnce an attacker has a valid password (from a spray or a dump), they trigger repeated MFA push notifications hoping the user approves one out of annoyance. The pattern is a burst of MFA challenges to a single user in a short window, ending in either repeated denials or one approval. The sign-in logs record each challenge and its result.
SigninLogs
| where TimeGenerated > ago(1d)
| where ResultType in (0, 50074, 50076, 500121)
// 50074/50076/500121 = MFA required / denied / not satisfied
| summarize
Challenges = count(),
Denials = countif(ResultType != 0),
Approved = countif(ResultType == 0)
by UserPrincipalName, IPAddress, bin(TimeGenerated, 15m)
| where Challenges >= 5 and Denials >= 3
| sort by Challenges descAfter compromising an account, the attacker registers their own MFA method so they can authenticate independently of the user going forward. This is quiet persistence and it is logged as a security-info registration in AuditLogs. Correlating it with a preceding risky sign-in turns a noisy event into a high-fidelity one.
AuditLogs
| where TimeGenerated > ago(7d)
| where OperationName in ("User registered security info", "User registered all required security info", "Admin registered security info")
| extend actor = tostring(InitiatedBy.user.userPrincipalName)
| extend method = tostring(TargetResources[0].displayName)
| project TimeGenerated, actor, method, ResultDescription
| sort by TimeGenerated descIf an attacker extracts the AD FS token-signing certificate, they can forge SAML tokens claiming any identity and any privilege, and those tokens are cryptographically valid. There is no clean signature, which is why this technique is dangerous. Detection is three anomaly patterns, not one rule. First, on the AD FS side, watch token-issuance events for volume anomalies from accounts that do not normally request them.
SecurityEvent
| where TimeGenerated > ago(7d)
| where EventSourceName == "AD FS Auditing"
// 1200/1201/1202 = token issuance events
| where EventID in (1200, 1201, 1202)
| summarize TokensIssued = count() by Account, bin(TimeGenerated, 1h)
| where TokensIssued > 50
| sort by TokensIssued descSecond, on the cloud side, the forged authentications arrive at the service provider. Golden SAML logins appear in the sign-in logs with unusual IPs, unexpected user agents, or an auth context that does not match the user's normal pattern. The Microsoft 365 sign-in logs record the federation source, so a federated sign-in from an anomalous location for a privileged account is the second pattern to hunt.
Persistence and Privilege Escalation
Once an attacker has a session, they want to keep it and widen it. In M365 that means planting something that survives a password reset: a device they control, a credential on an application, a federated domain pointing at their own identity provider, or a weakened conditional access policy. These are the quiet moves. Most of them are logged in AuditLogs as routine administrative operations, which is exactly why they hide. The detections below separate the attacker's version from the admin's.
Conditional access that requires a compliant or managed device is only as strong as the device-registration process behind it. An attacker who registers their own device, or joins one that gets marked compliant, turns a device-based control into a door they hold the key to. The high-signal pattern is not the registration alone, it is the sequence: a sign-in from an unusual platform where no CA policy applied, followed shortly by a new device registration for the same user.
let newDevices = AuditLogs
| where TimeGenerated > ago(7d)
| where OperationName in ("Add device", "Update device")
| extend actor = tostring(InitiatedBy.user.userPrincipalName)
| where isnotempty(actor)
| project DeviceTime = TimeGenerated, actor,
DeviceName = tostring(TargetResources[0].displayName);
newDevices
| join kind=inner (
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == 0
| project SignInTime = TimeGenerated,
actor = UserPrincipalName, DeviceOS = tostring(DeviceDetail.operatingSystem), IPAddress
) on actor
| where DeviceTime between (SignInTime .. (SignInTime + 2h))
| where DeviceOS in ("Linux", "", "Other")
| project SignInTime, DeviceTime, actor, DeviceOS, DeviceName, IPAddressAn application or service principal can hold its own credentials. An attacker who adds a client secret or certificate to an existing app registration gets a persistent, password-independent way back in that authenticates as that app with all its granted permissions. This is one of the most durable M365 persistence techniques and it is a single audit event.
AuditLogs
| where TimeGenerated > ago(30d)
| where ActivityDisplayName has_any ("Add service principal credentials",
"Update application", "Add key credential", "Add application credentials")
| where TargetResources[0].type =~ "Application" or TargetResources[0].type =~ "ServicePrincipal"
| extend AppName = tostring(TargetResources[0].displayName)
| extend actor = tostring(InitiatedBy.user.displayName)
| extend ChangedProps = TargetResources[0].modifiedProperties
| where ChangedProps has_any ("keyCredentials", "passwordCredentials")
| project TimeGenerated, AppName, ActivityDisplayName, actor
| sort by TimeGenerated descConditional access is the control plane for authentication in Entra. An attacker with sufficient privilege does not bypass it, they edit it: disable a policy, switch it to report-only, or add their compromised account to an exclusion group. The change is logged, and the highest-fidelity version of this detection flags a CA modification made by someone who does not normally touch CA.
let known = AuditLogs
| where TimeGenerated between (ago(14d) .. ago(1d))
| where OperationName has "conditional access policy"
| where Result =~ "success"
| extend actor = tostring(InitiatedBy.user.userPrincipalName)
| summarize by actor;
AuditLogs
| where TimeGenerated > ago(1d)
| where OperationName in ("Add conditional access policy",
"Update conditional access policy", "Delete conditional access policy")
| where Result =~ "success"
| extend actor = tostring(InitiatedBy.user.userPrincipalName)
| extend CAPolicy = tostring(TargetResources[0].displayName)
| where actor !in (known)
| project TimeGenerated, actor, OperationName, CAPolicy,
NewValue = tostring(TargetResources[0].modifiedProperties[0].newValue)This is the cloud-side cousin of Golden SAML and one of the most powerful persistence techniques in a hybrid tenant. An attacker who can configure domain authentication federates a domain to an identity provider they control, or alters federation settings, and can then mint valid tokens for any user in that domain. Microsoft's own analytic rule watches the exact operations, and the full attack has a precursor chain worth hunting as a unit.
AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName in ("Set domain authentication",
"Set federation settings on domain", "Add unverified domain", "Verify domain")
| extend actor = tostring(InitiatedBy.user.userPrincipalName)
| extend Domain = tostring(TargetResources[0].displayName)
| extend actorIP = tostring(InitiatedBy.user.ipAddress)
| project TimeGenerated, OperationName, Domain, actor, actorIP, Result
| sort by Domain asc, TimeGenerated ascSort by domain and time as above and the attack chain reads in order: the same actor adding an unverified domain, verifying it, then setting domain authentication to federated within a short window is the federation backdoor being built. The federation configuration detail itself is not in the audit event, so confirm with Graph: Get-MgDomainFederationConfiguration to inspect the issuer URI and signing certificate for an attacker-controlled IdP.
The clearest privilege-escalation signal in Entra is a role assignment to a high-privilege directory role: Global Administrator, Privileged Role Administrator, Application Administrator. An attacker who reaches one of these can grant themselves or a backdoor account standing admin rights. This is logged as a role-membership add.
AuditLogs
| where TimeGenerated > ago(7d)
| where OperationName == "Add member to role"
| extend actor = tostring(InitiatedBy.user.userPrincipalName)
| extend role = tostring(TargetResources[0].modifiedProperties[1].newValue)
| extend target = tostring(TargetResources[0].userPrincipalName)
| where role has_any ("Global Administrator", "Privileged Role Administrator",
"Application Administrator", "Privileged Authentication Administrator")
| project TimeGenerated, actor, target, role
| sort by TimeGenerated descCollection and Exfiltration
This is the objective for most M365 intrusions: read the email, take the files, move the data out. The collection techniques are logged in the Exchange and SharePoint audit streams, which surface in the CloudAppEvents table in Defender and OfficeActivity in Sentinel. One prerequisite that trips teams up: mailbox and SharePoint auditing must be enabled in Microsoft Purview for these events to exist at all. Confirm that before you trust an empty result to mean nothing happened.
An attacker in a mailbox creates an inbox rule that forwards or redirects mail to an external address, then keeps reading the victim's email long after the session ends and without logging back in. It is the stealthiest email-collection method because it persists silently. The detection watches the rule-creation operations for the forwarding parameters, and the key tuning move is excluding internal destinations so only external forwarding surfaces.
CloudAppEvents
| where Timestamp > ago(1d)
| where Application == "Microsoft Exchange Online"
| where ActionType in ("New-InboxRule", "Set-InboxRule", "Set-Mailbox", "UpdateInboxRules")
| extend RawData = tostring(RawEventData)
| where RawData has_any ("ForwardTo", "ForwardAsAttachmentTo", "RedirectTo", "DeliverToMailboxAndForward")
| where RawData !has "@yourdomain.com" // exclude internal forwarding; set to your tenant domain
| project Timestamp, AccountDisplayName, ActionType, IPAddress, CountryCode, ISP,
RuleConfig = RawEventData.Parameters
| sort by Timestamp descThe refinement that cuts false positives, straight from Microsoft's alert-grading playbook, is checking whether the ISP or country that created the rule is normal for that user. A forwarding rule created from an ISP the user has never authenticated from in 30 days is the high-confidence version.
Instead of forwarding, an attacker grants their account delegate or full-access permissions on the victim's mailbox, then reads it directly. This is quieter than a forwarding rule because no mail leaves the tenant, and it is logged as an Exchange permission change.
CloudAppEvents
| where Timestamp > ago(7d)
| where Application == "Microsoft Exchange Online"
| where ActionType in ("Add-MailboxPermission", "Add-RecipientPermission")
| extend RawData = tostring(RawEventData)
| where RawData has_any ("FullAccess", "SendAs", "SendOnBehalf")
| project Timestamp, actor = AccountDisplayName, ActionType, IPAddress,
Grant = RawEventData.Parameters
| sort by Timestamp descWhen an attacker reaches SharePoint or OneDrive, the objective is bulk collection: download many files quickly. Normal users download a handful; an attacker staging exfiltration downloads dozens or hundreds in a short window. The detection is a volume threshold on file-download events per user per time bin.
let threshold = 50;
CloudAppEvents
| where Timestamp > ago(1d)
| where ActionType == "FileDownloaded"
| where Application in ("Microsoft SharePoint Online", "Microsoft OneDrive for Business")
| summarize
FilesDownloaded = dcount(ObjectName),
Files = make_set(ObjectName, 15)
by Actor = AccountDisplayName, ClientIP = tostring(RawEventData.ClientIP), bin(Timestamp, 1h)
| where FilesDownloaded > threshold
| sort by FilesDownloaded descA subset of malicious inbox rules exists not to forward mail but to hide it: moving incoming messages to a rarely-checked folder, marking them read, or deleting them, so the victim never sees the security notifications, replies to the attacker's lateral phishing, or bounce messages. This is defense evasion inside the mailbox, and it is the tell that a forwarding or BEC operation is actively being concealed.
CloudAppEvents
| where Timestamp > ago(7d)
| where Application == "Microsoft Exchange Online"
| where ActionType in ("New-InboxRule", "Set-InboxRule")
| extend RawData = tostring(RawEventData)
| where RawData has_any ("DeleteMessage", "MarkAsRead", "MoveToFolder")
| where RawData has_any ("Deleted Items", "RSS", "Conversation History", "Archive")
or RawData has "DeleteMessage"
| project Timestamp, AccountDisplayName, IPAddress, RuleConfig = RawEventData.Parameters
| sort by Timestamp descDiscovery and Defense Evasion
Before an attacker moves, they look around: who are the admins, what applications exist, where is the sensitive data. And when they want to stay hidden, they go after the logs themselves. The evasion techniques here matter more than their frequency suggests, because a successful one blinds every other detection in this reference. Treat audit-tampering events as the highest-priority tripwires you have.
The Microsoft 365 unified audit log is the source behind most of the detections in this reference. An attacker who reaches the Audit Logs role in Exchange Online can disable it with a single command, and from that moment mailbox access, file downloads, and rule creation stop being recorded. The disable action is itself audited, so this is your canary: if you see it and did not authorise it, assume active compromise and that subsequent activity is going dark.
OfficeActivity
| where TimeGenerated > ago(30d)
| where Operation == "Set-AdminAuditLogConfig"
| extend params = tostring(Parameters)
| where params has "UnifiedAuditLogIngestionEnabled"
// the disable is the dangerous case: value set to False
| where params has "False"
| project TimeGenerated, UserId, ClientIP, Operation, params
| sort by TimeGenerated descAfter using a compromised mailbox to send internal phishing or move funds, an attacker deletes the evidence: hard-deleting sent items, purging the Deleted Items folder, or removing specific messages so the real user and investigators cannot see what was sent from their account. Exchange logs these as hard-delete operations.
CloudAppEvents
| where Timestamp > ago(7d)
| where Application == "Microsoft Exchange Online"
| where ActionType in ("HardDelete", "SoftDelete", "MoveToDeletedItems")
| extend items = tostring(RawEventData.AffectedItems)
| summarize DeleteActions = count(), Sample = make_set(items, 10)
by AccountDisplayName, ActionType, bin(Timestamp, 1h)
| where DeleteActions > 20
| sort by DeleteActions descHaving gained access, an attacker enumerates the directory: who holds privileged roles, which accounts to target next, where the value is. In M365 this shows up as bursts of directory-read activity or, from a compromised session, tooling that lists users and roles. The pattern is a single principal reading an unusually broad slice of the directory in a short window.
CloudAppEvents
| where Timestamp > ago(1d)
| where ActionType in ("SearchQueryInitiatedExchange", "SearchQueryInitiatedSharePoint")
or ActionType has "Directory"
| summarize
Queries = count(),
Actions = make_set(ActionType, 10)
by AccountDisplayName, ClientIP = tostring(RawEventData.ClientIP), bin(Timestamp, 1h)
| where Queries > 30
| sort by Queries descImpact and Fraud
For a large share of M365 intrusions, this is the objective the whole chain was building toward: use the compromised mailbox to move money or spread further. Business email compromise is the costly one, and the hard part is that it is a behavioral attack wearing normal infrastructure. There is no malware and no exploit, just a trusted account making a plausible request. That is why the detection here is less about a single query and more about correlating the signals from every earlier section into one story.
Once inside a mailbox, an attacker uses it to phish other employees. These messages come from a real internal, trusted sender, so they sail past the filters tuned for external threats and land with the credibility of a known colleague. The detection looks for internal-to-internal mail carrying phishing indicators, or a sudden spike in a single internal sender messaging many internal recipients.
EmailEvents
| where Timestamp > ago(1d)
| where SenderFromDomain == RecipientEmailAddress // same-tenant sender and recipient
or (EmailDirection == "Intra-org")
| where ThreatTypes has_any ("Phish", "Malware")
or PhishFilterVerdict != ""
| summarize
Recipients = dcount(RecipientEmailAddress),
Subjects = make_set(Subject, 10)
by SenderFromAddress, bin(Timestamp, 1h)
| where Recipients > 5
| sort by Recipients descBEC has no clean signature because every individual action is something a normal user does: sign in, read mail, create a rule, send a message. It is the sequence that betrays it. The detection that works is a correlation rule chaining the earlier signals in this reference: a risky sign-in, followed by an inbox rule that hides or forwards mail, followed by outbound mail to finance or an external party about payment. No single event, the chain.
let risky = SigninLogs
| where TimeGenerated > ago(2d)
| where RiskLevelDuringSignIn in ("high", "medium")
| distinct UserPrincipalName, RiskTime = TimeGenerated;
let rules = CloudAppEvents
| where Timestamp > ago(2d)
| where ActionType in ("New-InboxRule", "Set-InboxRule")
| extend RawData = tostring(RawEventData)
| where RawData has_any ("ForwardTo", "RedirectTo", "DeleteMessage", "MoveToFolder")
| project RuleTime = Timestamp, UserPrincipalName = AccountDisplayName;
risky
| join kind=inner rules on UserPrincipalName
| where RuleTime between (RiskTime .. (RiskTime + 12h))
| project UserPrincipalName, RiskTime, RuleTimeTo slow the response once the fraud is in motion, or during destructive attacks, an attacker may reset the victim's password, revoke their sessions, or disable the account, locking out the real user while the attacker keeps a live token. In Entra this is a password reset or account state change made by an unexpected actor.
AuditLogs
| where TimeGenerated > ago(1d)
| where OperationName in ("Reset user password", "Change user password",
"Disable account", "Update user")
| extend actor = tostring(InitiatedBy.user.userPrincipalName)
| extend target = tostring(TargetResources[0].userPrincipalName)
| where actor != target // someone acting on another user's account
| project TimeGenerated, actor, target, OperationName
| sort by TimeGenerated descCorrelation Detections
Single-technique rules catch single techniques. Real intrusions are chains, and the highest-fidelity detections in M365 fire on the sequence rather than any one link. These correlation detections join signals across the identity, email, and cloud-app planes. They produce far fewer alerts than their component rules, and the alerts they do produce are close to actionable on arrival. A note on joins: across Defender advanced hunting, the stable identity key is AccountObjectId, and email correlates on NetworkMessageId. Using a display name or a bare UPN as a join key is the usual reason a correlation query silently returns nothing.
The AiTM chain is a phishing click, then a session from a new location using the stolen token. Neither event alarms on its own: users click links and travel constantly. Joined within a tight window, they are the attack. This correlation catches the compromise at the moment the token is first replayed, which is the earliest point you can act.
let window = 1h;
let clicks = UrlClickEvents
| where Timestamp > ago(2d)
| where ThreatTypes has "Phish" or IsClickedThrough == true
| project ClickTime = Timestamp, AccountUpn, Url;
clicks
| join kind=inner (
EntraIdSignInEvents
| where Timestamp > ago(2d)
| where RiskLevelDuringSignIn in ("high", "medium")
| project SignInTime = Timestamp, AccountUpn, IPAddress, Country, RiskLevelDuringSignIn
) on AccountUpn
| where SignInTime between (ClickTime .. (ClickTime + window))
| project AccountUpn, ClickTime, Url, SignInTime, IPAddress, Country, RiskLevelDuringSignIn
| sort by SignInTime descBusiness email compromise has a recognisable staging sequence: a suspicious sign-in, then an inbox rule that forwards or hides mail, usually within hours. This correlation is the single most useful BEC tripwire because it fires during staging, before the fraudulent mail goes out and before money moves.
let risky = SigninLogs
| where TimeGenerated > ago(2d)
| where RiskLevelDuringSignIn in ("high", "medium") or ResultType == 0 and IsInteractive == true
| where isnotempty(UserPrincipalName)
| project RiskTime = TimeGenerated, UserPrincipalName, IPAddress, Country = tostring(LocationDetails.countryOrRegion);
risky
| join kind=inner (
CloudAppEvents
| where Timestamp > ago(2d)
| where ActionType in ("New-InboxRule", "Set-InboxRule", "UpdateInboxRules")
| extend RawData = tostring(RawEventData)
| where RawData has_any ("ForwardTo", "RedirectTo", "DeleteMessage", "MoveToFolder", "MarkAsRead")
| project RuleTime = Timestamp, UserPrincipalName = AccountDisplayName, RuleData = RawData
) on UserPrincipalName
| where RuleTime between (RiskTime .. (RiskTime + 6h))
| project UserPrincipalName, RiskTime, IPAddress, Country, RuleTime, RuleData
| sort by RuleTime descIllicit consent phishing tricks a user into approving a malicious OAuth app, which then reads mail through the Graph API. The consent event and the subsequent programmatic mail access are separated by minutes to hours. Correlating the consent grant with a burst of application-driven mail reads confirms the app is doing what it was granted to do, which turns a noisy consent alert into a confirmed incident.
let consents = AuditLogs
| where TimeGenerated > ago(7d)
| where OperationName in ("Consent to application", "Add delegated permission grant")
| extend AppId = tostring(TargetResources[0].id)
| extend actor = tostring(InitiatedBy.user.userPrincipalName)
| project ConsentTime = TimeGenerated, AppId, actor;
consents
| join kind=inner (
CloudAppEvents
| where Timestamp > ago(7d)
| where ActionType in ("MailItemsAccessed", "Send", "MessagesListed")
| extend AppId = tostring(RawEventData.AppId)
| summarize Reads = count(), FirstRead = min(Timestamp) by AppId
) on AppId
| where FirstRead > ConsentTime
| project actor, AppId, ConsentTime, FirstRead, Reads
| where Reads > 20
| sort by Reads descWhen an attacker takes over a privileged account, the giveaway is speed: a sign-in from an anomalous location followed almost immediately by a high-impact administrative action, a role grant, a CA policy change, a credential added to an app. Legitimate admins do administrative things from their normal locations and devices. The correlation flags admin actions taken shortly after a sign-in that Entra scored risky.
let riskyAdmin = SigninLogs
| where TimeGenerated > ago(2d)
| where RiskLevelDuringSignIn in ("high", "medium")
| project RiskTime = TimeGenerated, UserPrincipalName, IPAddress,
Country = tostring(LocationDetails.countryOrRegion);
riskyAdmin
| join kind=inner (
AuditLogs
| where TimeGenerated > ago(2d)
| where OperationName in ("Add member to role", "Add conditional access policy",
"Update conditional access policy", "Add service principal credentials",
"Set domain authentication")
| extend UserPrincipalName = tostring(InitiatedBy.user.userPrincipalName)
| project ActionTime = TimeGenerated, UserPrincipalName, OperationName
) on UserPrincipalName
| where ActionTime between (RiskTime .. (RiskTime + 2h))
| project UserPrincipalName, RiskTime, IPAddress, Country, ActionTime, OperationName
| sort by ActionTime descAdditional Techniques
The techniques below round out the coverage of the two matrices. Some are variants of the detections above, some are lower-frequency but worth knowing where they surface.
Distinct from server-side inbox rules, Outlook client rules (and the related Outlook Forms and Home Page techniques) can execute an action when mail arrives, historically including launching a payload. They persist in the mailbox and survive password resets. The detection watches rule creation for the client-side rule types and the tell-tale start-application or run-executable actions.
CloudAppEvents
| where Timestamp > ago(7d)
| where Application == "Microsoft Exchange Online"
| where ActionType in ("New-InboxRule", "Set-InboxRule", "UpdateInboxRules")
| extend RawData = tostring(RawEventData)
| where RawData has_any ("StartApplication", "RunExecutable", "ForwardTo")
| project Timestamp, AccountDisplayName, IPAddress, ActionType, RawData
| sort by Timestamp descTeams holds as much sensitive conversation as email holds mail, and an attacker with a session or a stolen token reads it through the Graph API. Microsoft's expanded cloud logs record message-read access as the MessagesListed event, and the strongest signal is an anomalous application ID reading many messages quickly, the fingerprint of scripting rather than a human scrolling.
CloudAppEvents
| where Timestamp > ago(1d)
| where ActionType == "MessagesListed"
| extend AppId = tostring(RawEventData.ClientAppId)
| extend AppName = tostring(RawEventData.ClientAppName)
| summarize MessagesRead = count(), Threads = dcount(tostring(RawEventData.ChatThreadId))
by AccountDisplayName, AppId, AppName, bin(Timestamp, 1h)
| where MessagesRead > 50
| sort by MessagesRead descRather than download files, an attacker builds a Power Automate flow or adds an outbound connector that copies mail or files to an external endpoint automatically. It is exfiltration with no interactive download to catch, and it persists as an automation. The detection watches for new flow creation and connector additions, especially those touching mail or files and pointing at an external destination.
CloudAppEvents
| where Timestamp > ago(7d)
| where ActionType has_any ("Created flow", "Edited flow", "Created connection", "PutConnection")
| extend flow = tostring(RawEventData.ObjectName)
| project Timestamp, AccountDisplayName, ActionType, flow, IPAddress
| sort by Timestamp descBeyond the Golden SAML case, an attacker who steals a token-signing or AD CS certificate can forge authentication material directly. On-premises, watch for access to the AD FS DKM key and certificate export events; in the cloud, the forged tokens surface as the same claims-versus-reality mismatch described in the Golden SAML section. This technique overlaps with hybrid identity abuse, and the two are best hunted together.
SecurityEvent
| where TimeGenerated > ago(7d)
| where EventID == 4662 // operation on AD object; watch DKM container access
| where ObjectName has "CN=ADFS" or Properties has "DKM"
| where AccountType == "User"
| project TimeGenerated, Account, ObjectName, AccessMask
| sort by TimeGenerated descLearn to build these detections, not just run them
This reference shows you the detections. The courses teach you to engineer them for your own tenant: the data model, the tuning, the coverage measurement, and the response.
Explore the courses