Microsoft Sentinel KQL Threat Hunting: 12 Queries That Surface Attacker Behavior Across Azure, M365, and On-Premises Logs

Retool's new app builder is where AI-generated code ships safely
Building apps with AI is easy. Getting them to production safely is another story.
Threat hunting in Sentinel starts with a hypothesis about attacker behavior and a KQL query designed to surface evidence of that behavior. These 12 queries cover the most frequently observed cloud attacker TTPs across Entra ID, Azure Resource Manager, Microsoft 365, and on-premises Windows events forwarded to Sentinel. Each query includes the MITRE ATT&CK technique it targets and the false positive sources to tune out.
Entra ID Sign-In Anomaly Queries
Query 1: Impossible travel (same user, different country within 1 hour)
let timeWindow = 1h;
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == "0" // Successful sign-ins only
| extend Country = tostring(LocationDetails.countryOrRegion)
| summarize
Countries = make_set(Country),
IPs = make_set(IPAddress),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by UserPrincipalName, bin(TimeGenerated, timeWindow)
| where array_length(Countries) > 1
| project UserPrincipalName, Countries, IPs, FirstSeen, LastSeen
// MITRE: T1078 Valid Accounts | FP: VPN users, travelers near borders
Query 2: Sign-in from new country (not seen in last 30 days for this user)
let baseline = SigninLogs
| where TimeGenerated between (ago(37d) .. ago(7d))
| where ResultType == "0"
| summarize KnownCountries = make_set(tostring(LocationDetails.countryOrRegion)) by UserPrincipalName;
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == "0"
| extend Country = tostring(LocationDetails.countryOrRegion)
| join kind=leftouter baseline on UserPrincipalName
| where Country !in (KnownCountries)
| project TimeGenerated, UserPrincipalName, Country, IPAddress, AppDisplayName
// MITRE: T1078 | FP: New hires, business travel
Query 3: Token replay -- device code authentication from unusual IP after long delay
SigninLogs
| where TimeGenerated > ago(7d)
| where AuthenticationProtocol == "deviceCode"
| where ResultType == "0"
| project TimeGenerated, UserPrincipalName, IPAddress, DeviceDetail, AppDisplayName, LocationDetails
| join kind=inner (
SigninLogs
| where AuthenticationProtocol != "deviceCode" and ResultType == "0"
| summarize NormalIPs = make_set(IPAddress) by UserPrincipalName
) on UserPrincipalName
| where IPAddress !in (NormalIPs)
// MITRE: T1528 Steal Application Access Token | FP: Legitimate device code logins from new devices
Azure Resource and Privilege Escalation Queries
Query 4: Privileged role added outside business hours or from new IP
AuditLogs
| where TimeGenerated > ago(7d)
| where OperationName in ("Add member to role","Add eligible member to role")
| extend InitiatedByIP = tostring(InitiatedBy.user.ipAddress)
| extend TargetUser = tostring(TargetResources[0].userPrincipalName)
| extend RoleName = tostring(TargetResources[1].displayName)
| extend Hour = datetime_part("Hour", TimeGenerated)
| where Hour !between (8 .. 18) // Outside 8am-6pm local -- adjust to your timezone
or RoleName has_any ("Global Administrator","Privileged Role Administrator","Security Administrator")
| project TimeGenerated, InitiatedBy, InitiatedByIP, TargetUser, RoleName
// MITRE: T1078.004 Cloud Accounts | FP: Legitimate after-hours admin actions
Query 5: Azure resource creation burst (potential cryptomining or data processing infrastructure)
AzureActivity
| where TimeGenerated > ago(1d)
| where OperationNameValue endswith "/write" and ActivityStatusValue == "Success"
| where ResourceProviderValue in ("MICROSOFT.COMPUTE","MICROSOFT.CONTAINERINSTANCE","MICROSOFT.BATCH")
| summarize
ResourcesCreated = count(),
ResourceTypes = make_set(ResourceProviderValue),
Regions = make_set(ResourceGroup)
by Caller, bin(TimeGenerated, 1h)
| where ResourcesCreated > 10
| project TimeGenerated, Caller, ResourcesCreated, ResourceTypes, Regions
// MITRE: T1578.002 Create Cloud Instance | FP: Auto-scaling, Infrastructure as Code deployments
Query 6: Service principal added to Owner or Contributor on high-value subscription
AzureActivity
| where TimeGenerated > ago(7d)
| where OperationNameValue == "MICROSOFT.AUTHORIZATION/ROLEASSIGNMENTS/WRITE"
| where ActivityStatusValue == "Success"
| extend Properties = parse_json(Properties)
| extend RoleDefinitionId = tostring(Properties.requestbody.properties.roleDefinitionId)
| extend PrincipalType = tostring(Properties.requestbody.properties.principalType)
| where RoleDefinitionId has_any ("8e3af657-a8ff-443c-a75c-2fe8c4bcb635","b24988ac-6180-42a0-ab88-20f7382dd24c") // Owner, Contributor GUIDs
| where PrincipalType == "ServicePrincipal"
| project TimeGenerated, Caller, SubscriptionId, ResourceGroup, Properties
// MITRE: T1098.003 Additional Cloud Roles | FP: Legitimate automation deployment
Briefings like this, every morning before 9am.
Threat intel, active CVEs, and campaign alerts, distilled for practitioners. 50,000+ subscribers. No noise.
Microsoft 365 Behavioral Queries
Query 7: Inbox rule created and immediately followed by email forwarding
OfficeActivity
| where TimeGenerated > ago(7d)
| where Operation in ("New-InboxRule","Set-InboxRule","UpdateInboxRules")
| extend RuleParams = parse_json(Parameters)
| extend ForwardTo = tostring(RuleParams[?(@.Name == 'ForwardTo')].Value)
| extend DeleteMsg = tostring(RuleParams[?(@.Name == 'DeleteMessage')].Value)
| where isnotempty(ForwardTo) or DeleteMsg == "True"
| project TimeGenerated, UserId, Operation, ForwardTo, DeleteMsg, ClientIP
// MITRE: T1114.003 Email Forwarding Rule | FP: User-created legitimate forwarding rules
Query 8: Mass SharePoint file access in short time window
OfficeActivity
| where TimeGenerated > ago(7d)
| where RecordType == "SharePointFileOperation"
| where Operation in ("FileDownloaded","FileAccessed")
| summarize
FileCount = count(),
UniqueFiles = dcount(SourceFileName),
Sites = dcount(Site_Url)
by UserId, bin(TimeGenerated, 30m)
| where FileCount > 100 or UniqueFiles > 50
| project TimeGenerated, UserId, FileCount, UniqueFiles, Sites
// MITRE: T1530 Data from Cloud Storage Object | FP: Migration scripts, backup tools
Query 9: OAuth app granted high-privilege delegated permissions
AuditLogs
| where TimeGenerated > ago(7d)
| where OperationName in ("Consent to application","Add delegated permission grant")
| extend AppName = tostring(TargetResources[0].displayName)
| extend Scopes = tostring(TargetResources[0].modifiedProperties[?(@.displayName == 'DelegatedPermissionGrant.Scope')].newValue)
| where Scopes has_any ("Mail.ReadWrite","Files.ReadWrite.All","Directory.ReadWrite.All","offline_access")
| project TimeGenerated, InitiatedBy, AppName, Scopes
// MITRE: T1550.001 Use Alternate Authentication Material | FP: Legitimate OAuth integrations
On-Premises Windows Event Forwarding Queries
These queries assume Windows Security event logs are forwarded to Sentinel via the Log Analytics agent or Azure Monitor Agent.
Query 10: Kerberoasting detection (TGS request for service account SPN)
SecurityEvent
| where TimeGenerated > ago(7d)
| where EventID == 4769 // Kerberos service ticket request
| where AccountName !endswith "$" // Exclude computer accounts
| extend ServiceName = tostring(TargetServerName)
| extend TicketEncryptionType = tostring(TicketEncryptionType)
| where TicketEncryptionType == "0x17" // RC4 encryption -- weak, target of Kerberoasting
| summarize
RequestCount = count(),
Services = make_set(ServiceName)
by Account, IpAddress, bin(TimeGenerated, 5m)
| where RequestCount > 5 // Multiple service ticket requests suggests enumeration
| project TimeGenerated, Account, IpAddress, RequestCount, Services
// MITRE: T1558.003 Kerberoasting | FP: Legitimate service connections with RC4 required
Query 11: DC Sync attack pattern (multiple DRSUAPI RPC calls from non-DC)
SecurityEvent
| where TimeGenerated > ago(7d)
| where EventID == 4662 // Operation performed on AD object
| where AccessMask == "0x100" // Replication rights
| where Properties has "1131f6aa-9c07-11d1-f79f-00c04fc2dcd2" // DS-Replication-Get-Changes GUID
| where SubjectUserName !endswith "$" // Exclude computer accounts (DCs replicate legitimately)
| project TimeGenerated, SubjectUserName, SubjectLogonId, ObjectName, IpAddress
// MITRE: T1003.006 DCSync | FP: Backup products with replication rights, AD management tools
Query 12: New local admin account created on a domain-joined server
SecurityEvent
| where TimeGenerated > ago(7d)
| where EventID == 4720 // User account created
| join kind=inner (
SecurityEvent
| where EventID == 4732 // Member added to security-enabled local group
| where TargetUserName == "Administrators"
) on Computer, $left.SubjectLogonId == $right.SubjectLogonId
| project TimeGenerated, Computer, SubjectUserName, TargetUserName, IpAddress
// MITRE: T1136.001 Create Local Account | FP: Legitimate local admin provisioning via GPO or automation
Operationalizing Hunting Queries in Sentinel
Convert successful hunting queries into scheduled analytics rules for continuous monitoring:
- In Sentinel, open Hunting > Queries
- Create new query, paste KQL, add MITRE ATT&CK mapping, entity mapping (Account, IP, Host), and description
- Run the query against the last 30 days to baseline the expected result volume
- For queries with consistent and actionable results, promote to Analytics rule: from the query, click 'Create detection rule'
- Set the query schedule (5-15 minutes for high-priority, hourly for lower-priority)
- Set the alert threshold (1 result = alert for high-confidence queries, higher threshold for noisy queries)
- Configure entity mapping to automatically extract entities (accounts, IPs, hosts) from results for correlation
For tuning false positives, use watchlists:
// Exclude known VPN exit IPs from impossible travel detection
let VPN_IPs = _GetWatchlist('known-vpn-ips') | project SearchKey;
SigninLogs
| where IPAddress !in (VPN_IPs)
| [rest of impossible travel query]
Sentinel watchlists allow tuning without modifying query logic -- add IP addresses, user accounts, or hostnames to a watchlist and reference it in queries to exclude known-safe entities.
The bottom line
These 12 queries cover the most behaviorally consistent attacker patterns across Entra ID, Azure, M365, and on-premises Windows environments in Sentinel. Run them as hunting queries first to tune out false positives, then promote to scheduled analytics rules. Use MITRE ATT&CK mappings in Sentinel to track coverage and identify which techniques in your threat model are not yet covered by hunting queries or detection rules.
Frequently asked questions
What log tables do I need to enable for these queries to work in Sentinel?
The queries in this guide require: SigninLogs and AADNonInteractiveUserSignInLogs (Entra ID sign-in logs -- enable via Entra ID diagnostic settings), AuditLogs (Entra ID audit -- same diagnostic settings), AzureActivity (Azure resource activity -- enable via subscription diagnostic settings), OfficeActivity (M365 -- enable via the Sentinel M365 connector), and SecurityEvent (Windows event logs -- requires Log Analytics agent or Azure Monitor Agent on Windows servers). Check your workspace Tables list to confirm which tables are populated before running queries.
How do I handle the Sentinel free 10 GB/day ingestion tier limit without truncating critical log sources?
Prioritize the high-fidelity log sources: Entra ID sign-in and audit logs, AzureActivity, OfficeActivity, and Security events from Tier 0 servers are the minimum for effective threat hunting. Windows Security events from all domain-joined workstations are the highest-volume source. To reduce workstation event volume, configure the Windows Security Event collection level in Sentinel to 'Common' rather than 'All Events' -- this captures the critical event IDs (4624, 4625, 4648, 4688, 4698, 4702, 4720, 4732, 4769) without collecting low-value events like object access noise.
How should I structure a threat hunting session using these queries?
Start with a hypothesis based on current threat intelligence or a recent incident type in your industry. Select the relevant queries (e.g., if the hypothesis is 'cloud credential theft via phishing', run the device code auth, impossible travel, and OAuth consent queries). Run each query over a 7-30 day window. Investigate any results that deviate from expected baselines. Bookmark suspicious findings in Sentinel. If a finding is confirmed as malicious, escalate to incident. If confirmed as benign, document the tuning action (add IP to watchlist, add user to exclusion group). End the session with a hunting log entry noting what was checked, what was found, and tuning changes made.
What is the difference between Sentinel hunting queries and analytics rules?
Hunting queries are manually executed -- a threat hunter runs them interactively as part of a proactive investigation. They have no automated alerting or scheduling. Analytics rules are automated -- they run on a schedule (as frequent as every 5 minutes), generate alerts when results exceed a threshold, create incidents, and integrate with playbooks for automated response. The workflow is: write a query as a hunting query first to validate it and tune false positives, then promote it to an analytics rule once it consistently produces actionable signal.
How do I use KQL to build a baseline and detect anomalies rather than fixed thresholds?
Use the KQL `percentile()` or `stdev()` aggregation functions over a historical window to establish a baseline, then compare the current period against it. Example pattern: calculate each user's average hourly sign-in count over the past 30 days with `summarize avg_logins=avg(LoginCount) by UserPrincipalName` in a `let` statement, then join the current hour's count against the baseline and flag users whose current count exceeds `avg_logins + (2 * stdev_logins)`. The `series_decompose_anomalies()` function provides built-in anomaly detection on time series data and is well-suited for metrics like authentication volume, data download size, or API call rate per identity.
How do I write a KQL query to detect password spray attacks in Entra ID sign-in logs?
Password spray produces many failed sign-ins with error code 50126 (invalid credentials) across multiple accounts from a single or small number of source IPs, often at a low rate per account to avoid lockout. KQL query for Microsoft Sentinel: `SigninLogs | where ResultType == '50126' | summarize FailedAttempts=count(), DistinctAccounts=dcount(UserPrincipalName) by IPAddress, bin(TimeGenerated, 1h) | where DistinctAccounts > 10 and FailedAttempts > 20 | order by DistinctAccounts desc`. A high ratio of distinct accounts to failed attempts from a single IP in a short window is the key spray indicator. Also check for error code 50034 (user does not exist) spikes from the same IP, which indicates username enumeration preceding the spray.
Sources & references
Free resources
Critical CVE Reference Card 2025–2026
25 actively exploited vulnerabilities with CVSS scores, exploit status, and patch availability. Print it, pin it, share it with your SOC team.
Ransomware Incident Response Playbook
Step-by-step 24-hour IR checklist covering detection, containment, eradication, and recovery. Built for SOC teams, IR leads, and CISOs.
Get threat intel before your inbox does.
50,000+ security professionals read Decryption Digest for early warnings on zero-days, ransomware, and nation-state campaigns. Free, daily, no spam.
Unsubscribe anytime. We never sell your data.

Founder & Cybersecurity Evangelist, Decryption Digest
Cybersecurity professional with expertise in threat intelligence, vulnerability research, and enterprise security. Covers zero-days, ransomware, and nation-state operations for 50,000+ security professionals every morning.
Win a $2,495 Black Hat pass.
Full-access to Black Hat USA 2026 in Las Vegas. Subscribe free to enter.
