Entra IDAzureSentinelSplunk October 2026

Five Entra ID detections to deploy first

This is the Microsoft side of Five CloudTrail detections to deploy first. In a Microsoft cloud, identity is the perimeter, so the first detections belong on Entra ID. These five are the ones I would put in place first. Each is cheap to run, maps to a technique attackers actually use, and covers a control an auditor will ask about.

Each detection has a Microsoft Sentinel query and a Splunk query. The Sentinel queries need the Microsoft Entra ID connector sending AuditLogs and SigninLogs to your workspace. The Splunk queries assume the Splunk Add-on for Microsoft Cloud Services collecting Entra ID diagnostic logs through an Event Hub (sourcetype=azure:monitor:aad). Replace <azure_index> with your own index. Either way, sign-in logs require an Entra ID P1 or P2 license.

# Detection ATT&CK NIST 800-53
1 Privileged role assigned T1098.003 AC-2(7)
2 MFA method registered from an unfamiliar address T1556.006 IA-2(1)
3 Consent granted to an app with broad permissions T1528 AC-6
4 Conditional Access policy changed T1556.009 AC-3
5 Credentials added to an application T1098.001 IA-5

First: is the log source alive?

A detection that never fires looks exactly like a quiet tenant. Before trusting any of the queries below, alert on the feed itself going silent.

Microsoft Sentinel:

union withsource = TableName SigninLogs, AuditLogs
| where TimeGenerated > ago(1d)
| summarize LastEvent = max(TimeGenerated) by TableName
| extend MinutesSilent = datetime_diff("minute", now(), LastEvent)
| where MinutesSilent > 60

Splunk:

index=<azure_index> sourcetype=azure:monitor:aad earliest=-24h category IN (AuditLogs, SignInLogs)
| stats latest(_time) as last_event by category
| eval minutes_silent = round((now() - last_event) / 60)
| where minutes_silent > 60

As with CloudTrail, a table or category that has been silent for longer than the search window drops out of the results entirely, so compare against a list of the tables you expect. A small tenant can also go an hour without an audit event, so set the threshold per table from your own data.

1. Privileged role assigned

Adding an account to a privileged role is the most direct way to turn one compromised account into control of the tenant.

Microsoft Sentinel:

let privileged_roles = dynamic(["Global Administrator", "Privileged Role Administrator",
    "Privileged Authentication Administrator", "Security Administrator",
    "Application Administrator", "Cloud Application Administrator",
    "Exchange Administrator", "User Administrator"]);
AuditLogs
| where TimeGenerated > ago(1h)
| where OperationName has "member to role" and Result == "success"
| mv-apply Prop = TargetResources[0].modifiedProperties on (
    summarize RoleName = take_anyif(trim(@'"', tostring(Prop.newValue)), tostring(Prop.displayName) == "Role.DisplayName"))
| where RoleName in (privileged_roles)
| extend Actor = coalesce(tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName)),
         Target = tostring(TargetResources[0].userPrincipalName)
| project TimeGenerated, OperationName, RoleName, Target, Actor

Splunk:

index=<azure_index> sourcetype=azure:monitor:aad category=AuditLogs
    operationName="*member to role*" properties.result=success
| rename properties.* as *
| eval prop = mvzip('targetResources{}.modifiedProperties{}.displayName', 'targetResources{}.modifiedProperties{}.newValue', "=")
| eval role = trim(replace(mvindex(mvfilter(match(prop, "^Role\.DisplayName=")), 0), "^Role\.DisplayName=", ""), "\"")
| search role IN ("Global Administrator", "Privileged Role Administrator",
    "Privileged Authentication Administrator", "Security Administrator",
    "Application Administrator", "Cloud Application Administrator",
    "Exchange Administrator", "User Administrator")
| eval actor = coalesce('initiatedBy.user.userPrincipalName', 'initiatedBy.app.displayName'),
       target = mvindex('targetResources{}.userPrincipalName', 0)
| table _time, operationName, role, target, actor

Tuning. Matching on "member to role" catches both permanent assignments and Privileged Identity Management eligibility and activations. If you use PIM, activations by people who are already eligible are expected. Split those into a lower-severity alert and keep permanent assignments and new eligibility at high. An assignment made by an application rather than a person deserves a closer look either way.

2. MFA method registered from an unfamiliar address

An attacker who has a password and gets through MFA once will often register their own authenticator, so they never need the victim's phone again.

Microsoft Sentinel:

let lookback = 14d;
let recent = 1h;
let Registrations = AuditLogs
    | where TimeGenerated > ago(recent)
    | where OperationName in ("User registered security info", "Admin registered security info", "User changed default security info")
    | where Result == "success"
    | extend User = tolower(tostring(TargetResources[0].userPrincipalName)),
             IPAddress = tostring(InitiatedBy.user.ipAddress)
    | project TimeGenerated, User, OperationName, ResultReason, IPAddress;
let KnownAddresses = SigninLogs
    | where TimeGenerated between (ago(lookback) .. ago(recent))
    | where ResultType == "0"
    | project User = tolower(UserPrincipalName), IPAddress
    | distinct User, IPAddress;
Registrations
| join kind=leftanti KnownAddresses on User, IPAddress

Splunk:

index=<azure_index> sourcetype=azure:monitor:aad earliest=-14d
    ( (category=AuditLogs properties.result=success
        operationName IN ("User registered security info", "Admin registered security info", "User changed default security info"))
   OR (category=SignInLogs properties.status.errorCode=0) )
| eval user = lower(coalesce(mvindex('properties.targetResources{}.userPrincipalName', 0), 'properties.userPrincipalName')),
       src_ip = coalesce('properties.initiatedBy.user.ipAddress', 'properties.ipAddress', callerIpAddress)
| stats max(eval(if(category="AuditLogs", _time, null()))) as registered
        min(eval(if(category="SignInLogs", _time, null()))) as first_signin
        values(properties.resultReason) as method
    by user, src_ip
| where registered >= relative_time(now(), "-1h")
    AND (isnull(first_signin) OR first_signin >= relative_time(now(), "-1h"))
| convert ctime(registered)

The Splunk version reads 14 days of sign-ins on every run. In production, keep a lookup of known user and address pairs updated by a scheduled search, and check new registrations against that instead.

ResultReason (properties.resultReason in Splunk) names the method that was added, such as an authenticator app or a phone number.

Tuning. New hires have no sign-in history, so every first registration will fire. Exclude accounts created in the last few days, or the network your onboarding happens from. Treat "Admin registered security info" separately: an administrator adding a method to someone else's account is rare and worth a call to the user.

3. Consent granted to an app with broad permissions

Illicit consent grants give an attacker's application a token to read mail or files, and that access survives a password reset.

Microsoft Sentinel:

let risky_permissions = dynamic(["Mail.Read", "Mail.ReadWrite", "Mail.Send",
    "Files.Read.All", "Files.ReadWrite.All", "Sites.Read.All", "Sites.ReadWrite.All",
    "Directory.ReadWrite.All", "Application.ReadWrite.All",
    "RoleManagement.ReadWrite.Directory", "full_access_as_app"]);
AuditLogs
| where TimeGenerated > ago(1h)
| where OperationName == "Consent to application" and Result == "success"
| mv-apply Prop = TargetResources[0].modifiedProperties on (
    summarize IsAdminConsent = take_anyif(tostring(Prop.newValue), tostring(Prop.displayName) == "ConsentContext.IsAdminConsent"),
              Permissions = take_anyif(tostring(Prop.newValue), tostring(Prop.displayName) == "ConsentAction.Permissions"))
| where Permissions has_any (risky_permissions)
| extend Actor = tostring(InitiatedBy.user.userPrincipalName),
         AppName = tostring(TargetResources[0].displayName),
         AppId = tostring(TargetResources[0].id)
| project TimeGenerated, AppName, AppId, Actor, IsAdminConsent, Permissions

Splunk:

index=<azure_index> sourcetype=azure:monitor:aad category=AuditLogs
    operationName="Consent to application" properties.result=success
| rename properties.* as *
| eval prop = mvzip('targetResources{}.modifiedProperties{}.displayName', 'targetResources{}.modifiedProperties{}.newValue', "=")
| eval permissions = mvindex(mvfilter(match(prop, "^ConsentAction\.Permissions=")), 0),
       admin_consent = mvindex(mvfilter(match(prop, "^ConsentContext\.IsAdminConsent=")), 0)
| where match(permissions, "(Mail\.(Read|ReadWrite|Send)|Files\.(Read|ReadWrite)\.All|Sites\.(Read|ReadWrite)\.All|Directory\.ReadWrite\.All|Application\.ReadWrite\.All|RoleManagement\.ReadWrite\.Directory|full_access_as_app)\b")
| eval app = mvindex('targetResources{}.displayName', 0),
       actor = 'initiatedBy.user.userPrincipalName'
| table _time, app, actor, admin_consent, permissions

Tuning. The best fix is upstream: restrict user consent to verified publishers and low-risk permissions, so most of these grants need an administrator. After that, this alert mostly fires on admin consent, which should be rare and tied to a change request. Leave offline_access out of the list. Almost every app asks for it.

4. Conditional Access policy changed

Conditional Access is where MFA and device requirements are enforced. Turning a policy off, switching it to report-only or adding a trusted location removes those requirements without touching a single account.

Microsoft Sentinel:

AuditLogs
| where TimeGenerated > ago(1h)
| where OperationName in ("Add conditional access policy", "Update conditional access policy",
    "Delete conditional access policy", "Add named location", "Update named location", "Delete named location")
| where Result == "success"
| extend Actor = coalesce(tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName)),
         PolicyName = tostring(TargetResources[0].displayName),
         IPAddress = tostring(InitiatedBy.user.ipAddress)
| project TimeGenerated, OperationName, PolicyName, Actor, IPAddress

Splunk:

index=<azure_index> sourcetype=azure:monitor:aad category=AuditLogs properties.result=success
    operationName IN ("Add conditional access policy", "Update conditional access policy",
        "Delete conditional access policy", "Add named location", "Update named location", "Delete named location")
| rename properties.* as *
| eval actor = coalesce('initiatedBy.user.userPrincipalName', 'initiatedBy.app.displayName'),
       policy = mvindex('targetResources{}.displayName', 0),
       src_ip = 'initiatedBy.user.ipAddress'
| table _time, operationName, policy, actor, src_ip

Tuning. These changes are infrequent in most tenants, so the volume is low to begin with. If policies are managed as code through the Graph API, allowlist that application and alert on any change made by a person. Raise the severity for deletions and for updates that set a policy's state to disabled or report-only, which shows in the policy's new value under modifiedProperties.

5. Credentials added to an application

A secret or certificate added to an application lets an attacker sign in as that application, with whatever permissions it already has, and no user account involved.

Microsoft Sentinel:

AuditLogs
| where TimeGenerated > ago(1h)
| where OperationName has "Certificates and secrets management"
     or OperationName == "Add service principal credentials"
| where Result == "success"
| extend Actor = coalesce(tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName)),
         AppName = tostring(TargetResources[0].displayName),
         AppId = tostring(TargetResources[0].id),
         IPAddress = tostring(InitiatedBy.user.ipAddress)
| project TimeGenerated, OperationName, AppName, AppId, Actor, IPAddress

Splunk:

index=<azure_index> sourcetype=azure:monitor:aad category=AuditLogs properties.result=success
    (operationName="*Certificates and secrets management*" OR operationName="Add service principal credentials")
| rename properties.* as *
| eval actor = coalesce('initiatedBy.user.userPrincipalName', 'initiatedBy.app.displayName'),
       app = mvindex('targetResources{}.displayName', 0),
       src_ip = 'initiatedBy.user.ipAddress'
| table _time, operationName, app, actor, src_ip

The application operation is matched loosely on purpose, with has in KQL and wildcards in SPL: its full name contains an en dash and a trailing space, which makes an exact match easy to get wrong.

Tuning. Secret rotation by automation is the main source of expected hits. Allowlist the rotation identity and keep the list short. Credentials added to a service principal, rather than to the application registration, are much rarer and are a known persistence technique. Keep those at high severity, and raise the severity for any application that holds the permissions from detection 3.

What comes next

Once these five are quiet and trusted, the next layer is behavior rather than single events: sign-ins that pass MFA from a new country followed by inbox rule changes, a burst of failed sign-ins across many accounts from one source, or legacy authentication from an account that never used it before. Those need baselines, which is why they come second.

These queries are starting points. Operation names and property names in the audit log change over time, so run each one against your own data before enabling it as an alert.

← All writing