Detecting Entra ID Identity Attacks: KQL and SPL for Device Code Phishing, OAuth Consent Abuse, and MFA Fatigue (T1528, T1621, T1078.004)
Most detection libraries are built around a process tree. That model breaks the moment an attacker stops touching endpoints entirely. A modern identity intrusion against Microsoft Entra ID (formerly Azure AD) can run end to end — initial access, collection, exfiltration, and persistence — without a single binary executing on a managed device. There is no parent process to correlate, no command line to regex, no EDR agent in the path. The only telemetry you get is SigninLogs and AuditLogs, and if you are not writing rules against them, that entire attack chain is invisible.
This post covers four detections that consistently produce true positives in real tenants: device code phishing, illicit OAuth consent grants, MFA request fatigue, and post-compromise credential persistence on service principals. Each includes working KQL for Microsoft Sentinel and equivalent SPL for Splunk, plus the tuning notes that decide whether the rule survives its first week.
The identity attack chain
The pattern is consistent enough to model as a chain. Initial access comes through a credential-free phish — a device code prompt or a consent screen — which yields an OAuth refresh token rather than a password (T1528). If a password is phished instead, the attacker still has to get past MFA, which is where push bombing comes in (T1621). From there the token itself becomes the credential (T1550.001), the account is used as a valid cloud account (T1078.004), mailboxes are collected via Graph API, and persistence is established by adding credentials to an application or service principal (T1098.001) so that revoking the user's session changes nothing.
The critical property: steps two through five generate no endpoint telemetry at all. Your coverage is entirely a function of how well you query the identity plane.
1. Device code phishing (T1528)
The device code flow exists so that input-constrained devices — TVs, conference room panels, CLI tools — can authenticate. The user visits a Microsoft-owned URL and types a short code. Because the sign-in page is genuinely Microsoft's, the phish survives URL inspection, brand-impersonation checks, and user suspicion. The attacker initiates the flow, sends the code to the victim with an urgent pretext, and collects the resulting refresh token when the victim completes it.
The detection opportunity is that legitimate device code usage in a corporate tenant is rare and highly clustered around a handful of users and applications. Anything outside that cluster deserves a look.
// Sentinel KQL - anomalous device code authentication
let lookback = 14d;
let baseline = SigninLogs
| where TimeGenerated between (ago(90d) .. ago(lookback))
| where AuthenticationProtocol == 'deviceCode'
| distinct UserPrincipalName;
SigninLogs
| where TimeGenerated > ago(lookback)
| where AuthenticationProtocol == 'deviceCode'
| where ResultType == 0
| where UserPrincipalName !in (baseline)
| extend Country = tostring(LocationDetails.countryOrRegion),
City = tostring(LocationDetails.city),
OS = tostring(DeviceDetail.operatingSystem),
Compliant = tostring(DeviceDetail.isCompliant)
| project TimeGenerated, UserPrincipalName, AppDisplayName, ResourceDisplayName,
IPAddress, Country, City, OS, Compliant, UserAgent
| order by TimeGenerated descTwo refinements matter. First, join against AADNonInteractiveUserSignInLogs — the token refreshes that follow the phish land there, not in SigninLogs, and they are what reveal how long the attacker held access. Second, treat AppDisplayName as a strong signal: device code flows against Microsoft Authentication Broker or Microsoft Office from a residential ASN are far more suspicious than the same flow against a legitimately deployed kiosk app.
index=azure sourcetype="azure:aad:signin" authenticationProtocol="deviceCode"
"status.errorCode"=0
| eval country='location.countryOrRegion'
| stats earliest(_time) as first_seen latest(_time) as last_seen count
values(appDisplayName) as apps values(ipAddress) as src_ips
dc(country) as country_count values(country) as countries
by userPrincipalName
| search NOT [| inputlookup entra_devicecode_baseline.csv | fields userPrincipalName]
| convert ctime(first_seen) ctime(last_seen)Build entra_devicecode_baseline.csv from a 90-day historical search and refresh it on a schedule. A hardcoded allowlist rots; a generated one does not.
2. Illicit OAuth consent grants (T1528)
Consent phishing skips authentication entirely. The victim is shown a real Microsoft consent dialog for an attacker-registered application requesting scopes like Mail.ReadWrite and offline_access. Clicking Accept issues a refresh token scoped to the attacker's app. Password resets do not revoke it. MFA does not apply to it. The grant persists until someone explicitly removes it.
The high-fidelity approach is not to alert on all consent — that generates noise in any tenant where users can consent to apps — but to alert on consent to high-impact scopes.
// Sentinel KQL - consent granted to sensitive Graph scopes
AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName in ('Consent to application', 'Add delegated permission grant',
'Add app role assignment grant to user')
| extend Actor = tostring(InitiatedBy.user.userPrincipalName),
ActorIP = tostring(InitiatedBy.user.ipAddress),
TargetApp = tostring(TargetResources[0].displayName),
TargetAppId= tostring(TargetResources[0].id)
| mv-expand mp = TargetResources[0].modifiedProperties
| extend PropName = tostring(mp.displayName), NewValue = tostring(mp.newValue)
| where PropName in ('ConsentAction.Permissions', 'ConsentContext.IsAdminConsent')
| where NewValue has_any ('Mail.Read', 'Mail.ReadWrite', 'Mail.Send',
'MailboxSettings.ReadWrite', 'Files.ReadWrite.All',
'Directory.ReadWrite.All', 'User.ReadWrite.All',
'Application.ReadWrite.All', 'offline_access')
| project TimeGenerated, Actor, ActorIP, TargetApp, TargetAppId, PropName, NewValue, Result
| order by TimeGenerated descApplication.ReadWrite.All and Directory.ReadWrite.All combined with admin consent should be a P1, not a hunting result — that combination is effectively tenant takeover. Delegated Mail.ReadWrite plus offline_access is the classic business email compromise pairing.
index=azure sourcetype="azure:aad:audit"
(operationName="Consent to application*" OR operationName="Add delegated permission grant")
| spath output=scopes path="targetResources{}.modifiedProperties{}.newValue"
| spath output=app path="targetResources{}.displayName"
| eval actor='initiatedBy.user.userPrincipalName'
| search scopes="*Mail.ReadWrite*" OR scopes="*Mail.Send*" OR scopes="*offline_access*"
OR scopes="*Files.ReadWrite.All*" OR scopes="*Directory.ReadWrite.All*"
| table _time actor app scopes result
| sort - _timeEnrich the alert with the application's registration age. An app registered hours before the consent event is a different problem from an app your organisation has used for two years.
3. MFA request fatigue (T1621)
When an attacker holds a valid password but faces push-based MFA, the cheapest bypass is repetition — fire approval prompts until the user taps Approve to make them stop. The signature is a burst of denials or timeouts followed closely by a success. The follow-on success is what separates a real attack from a user with a flaky phone.
// Sentinel KQL - MFA push bombing followed by successful sign-in
let window = 10m;
let threshold = 5;
let denials = SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType in ('500121', '50074', '50076') // denied / timeout / MFA required
| summarize DeniedPushes = count(),
DenyIPs = make_set(IPAddress, 10),
FirstDeny = min(TimeGenerated),
LastDeny = max(TimeGenerated)
by UserPrincipalName, bin(TimeGenerated, window)
| where DeniedPushes >= threshold;
denials
| join kind=inner (
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == 0
| project SuccessTime = TimeGenerated, UserPrincipalName,
SuccessIP = IPAddress, AppDisplayName,
SuccessCountry = tostring(LocationDetails.countryOrRegion)
) on UserPrincipalName
| where SuccessTime between (FirstDeny .. (LastDeny + 15m))
| project UserPrincipalName, DeniedPushes, FirstDeny, LastDeny,
SuccessTime, SuccessIP, SuccessCountry, AppDisplayName, DenyIPs
| order by DeniedPushes descindex=azure sourcetype="azure:aad:signin" "status.errorCode" IN (500121, 50074, 50076)
| bin _time span=10m
| stats count as denied_pushes values(ipAddress) as deny_ips by userPrincipalName _time
| where denied_pushes >= 5
| join type=left userPrincipalName
[ search index=azure sourcetype="azure:aad:signin" "status.errorCode"=0
| stats min(_time) as success_time values(ipAddress) as success_ip
by userPrincipalName ]
| where success_time > _time AND success_time < (_time + 900)
| table userPrincipalName denied_pushes deny_ips success_time success_ipTune the threshold to your tenant rather than accepting five. Also check whether the denial IPs and the success IP match — a match usually means a genuinely struggling user, while a mismatch means the attacker's session is the one that got approved. If your tenant runs number matching, expect this rule's volume to drop sharply; that is the control working, and the rule then becomes a detection for the accounts still on legacy push.
4. Service principal credential persistence (T1098.001)
This is the step most teams miss. After gaining admin-equivalent access, the attacker adds a client secret or certificate to an existing application or service principal. That credential authenticates non-interactively, bypasses conditional access policies scoped to users, and survives every user-focused remediation your IR team performs.
// Sentinel KQL - new credentials added to an application or service principal
AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName in ('Add service principal credentials',
'Update application - Certificates and secrets management',
'Update application')
| extend Actor = tostring(InitiatedBy.user.userPrincipalName),
ActorApp = tostring(InitiatedBy.app.displayName),
ActorIP = tostring(InitiatedBy.user.ipAddress),
TargetApp = tostring(TargetResources[0].displayName)
| mv-expand mp = TargetResources[0].modifiedProperties
| extend PropName = tostring(mp.displayName)
| where PropName has_any ('KeyDescription', 'PasswordCredentials', 'KeyCredentials')
| project TimeGenerated, Actor, ActorApp, ActorIP, TargetApp,
PropName, NewValue = tostring(mp.newValue), Result
| order by TimeGenerated descCorrelate every hit against your change management record. Legitimate secret rotation is scheduled, performed by a known identity or pipeline, and predictable. A secret added at 03:00 by an account that has never touched app registrations before is an incident. Pair this with monitoring for federation changes to catch T1606.002 — a new or modified domain federation is one of the highest-severity events in the entire identity plane.
Validating the coverage
These rules are testable without a red team. Register a test application in a non-production tenant, request a delegated Mail.Read scope, and consent as a test user — rule two should fire within the ingestion delay. Initiate a device code flow with a test account that has never used one, and confirm rule one fires. Add and immediately delete a client secret on a test app registration to validate rule four. Any rule you have not fired deliberately is a rule you do not actually have.
Watch ingestion latency while you test. Entra ID sign-in and audit logs typically arrive on a delay measured in minutes, not seconds, and correlation rules with tight time windows can silently miss events that land out of order. If your join windows are shorter than your worst-case ingestion lag, widen them.
Finally, treat these four as a set rather than four independent rules. Individually each has a plausible benign explanation. Together — an anomalous device code sign-in, a consent grant to a new app, and a secret added to a service principal within the same day for the same tenant — they describe a single intrusion with high confidence, and that correlated view is what turns identity telemetry from a compliance archive into a detection surface.