Microsoft Sentinel KQL Tutorial 2026: 20 Must-Know Queries for SOC Analysts

·

Kusto Query Language (KQL) is the single most leverage-heavy skill a Microsoft Sentinel analyst can own. Every detection rule, every hunt, every incident drill-down and every workbook tile is a KQL query underneath. Learn the language and you stop clicking through blades and start answering questions in seconds.

This tutorial is deliberately practical. We cover the six operators that carry 90% of a SOC shift, the tables you will actually query, and then 20 must-know queries spanning identity, endpoint, network and cloud — each paired with its Splunk SPL equivalent so the logic transfers no matter which SIEM you sit in front of. Every query here is read-only and safe to run in a lab or production workspace.

◈ Table of Contents

01 What KQL Is & Why It Matters 02 Prerequisites & Where to Run KQL 03 The Six Core Operators 04 Tables You'll Actually Query 05 Identity & Sign-In Queries (1–5) 06 Endpoint & Process Queries (6–10) 07 Network & Exfil Queries (11–14) 08 Cloud, Audit & Alert Queries (15–20) 09 Performance & Optimization 10 From Query to Detection Rule 11 Troubleshooting & Common Errors 12 MITRE ATT&CK Mapping 13 Sources & References
🧭

01 — What KQL Is & Why It Matters

Phase 1 / 13

Kusto Query Language is a read-only query language built for large-scale, time-series log data. It powers Microsoft Sentinel, Microsoft Defender XDR advanced hunting, Azure Monitor / Log Analytics, and Azure Data Explorer. If you can write KQL, the same syntax follows you across all four surfaces — a rare thing in the SIEM world.

The mental model is a pipeline. You start with a table, then push rows through a chain of operators separated by the pipe (|) character. Each operator takes a table in and returns a table out. Read a query top-to-bottom, left-to-right, and it narrates exactly what it does — no nested subqueries required to get started.

🔎

Triage

Pull the sign-in trail for one user, the process tree for one host, or every alert on one IP in a single query.

🎯

Threat Hunting

Ask questions no built-in rule covers — rare parent-child pairs, first-seen destinations, MFA anomalies.

🚨

Detection Rules

Any query that returns rows can become a scheduled or near-real-time analytics rule that raises incidents.

📊

Reporting

Summarize into workbooks and QuickChart-style visuals for shift handover and management metrics.

KQL is case-sensitive for table and column names but the =~ operator gives you case-insensitive string comparison. Use =~ for values like usernames and process names that show up in mixed case.

As of 2026 Microsoft Sentinel is generally available inside the unified Microsoft Defender portal, and the standalone Azure portal experience is scheduled to lose support after March 31, 2027. Your KQL does not change — only the URL you type it into. Plan your bookmarks and playbooks accordingly.

🧰

02 — Prerequisites & Where to Run KQL

Phase 2 / 13

You need a Log Analytics workspace with Microsoft Sentinel enabled and at least one data connector sending logs. If you are following along in a lab and have no data yet, the easiest path is to connect the free Azure Activity log and Microsoft Entra ID sign-in logs — both fill up quickly. The table below is the pre-flight checklist.

RequirementDetailNotes
Log Analytics workspaceSentinel enabledFree tier / trial is fine for learning min
Role — read queriesLog Analytics Reader or Microsoft Sentinel ReaderEnough for everything in this guide
Role — save rulesMicrosoft Sentinel ContributorNeeded only for Phase 10
Defender portal accessSecurity Reader (Entra role)For advanced hunting over XDR tables
Sample dataEntra sign-in + Azure Activity connectorsFree, high-volume, great for practice
BrowserAny modern browserKQL runs server-side; nothing to install

There are three practical places to run the exact same KQL. Pick the one that matches your task — the query text is identical.

A
Method 1 — Azure Portal Logs Blade
Interactive
The classic ad-hoc surface. Best for iterating on a query, checking a schema, or building a workbook tile. Portal path: portal.azure.com → Microsoft Sentinel → [workspace] → Logs. Set the time picker in the top bar — it silently overrides any ago() filter that is broader than the picker.
KUSTO — smoke test
// Confirms the workspace is alive and shows which tables have data in 24h union withsource=TableName * | where TimeGenerated > ago(24h) | summarize Rows=count() by TableName | sort by Rows desc

The union * smoke test scans every table — run it only over a short time window (24h or less) and never save it as a rule. It is a one-off orientation query.

B
Method 2 — Defender Portal Advanced Hunting
Unified
The 2026 default. Once your workspace is onboarded to the Defender portal, advanced hunting exposes your Sentinel Log Analytics tables and the Defender XDR tables (DeviceProcessEvents, EmailEvents, IdentityInfo) on one query surface — so you can join across both in a single query. Portal path: security.microsoft.com → Hunting → Advanced hunting.
KUSTO — cross-domain join only possible here
// Enrich an endpoint alert with the signed-in user's Entra risk level DeviceProcessEvents | where TimeGenerated > ago(1d) | where FileName =~ "powershell.exe" | join kind=leftouter (IdentityInfo | project AccountUpn, AccountName) on $left.AccountName == $right.AccountName | project TimeGenerated, DeviceName, AccountName, AccountUpn, ProcessCommandLine

Advanced hunting caps interactive queries at 30 minutes of compute and returns up to 30,000 rows. For heavier retrospective work, target the Sentinel data lake tier, which lets KQL run over long-retention data without the hot-tier cost.

C
Method 3 — Log Analytics REST API / CLI
Automation
Run the same KQL from a script, a pipeline, or a SOAR playbook. Useful for scheduled exports, enrichment lookups from Logic Apps, or feeding a query result into a ticketing system. Authenticate with a service principal that holds Log Analytics Reader.
BASH — Azure CLI
$ az monitor log-analytics query \ --workspace "<workspace-guid>" \ --analytics-query "SigninLogs | where TimeGenerated > ago(1h) | summarize count() by ResultType" \ -o table
BASH — raw REST call
$ curl -s -X POST \ "https://api.loganalytics.io/v1/workspaces/<guid>/query" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"query":"AzureActivity | take 10"}'
⚙️

03 — The Six Core Operators

Phase 3 / 13

Master these six and you can express almost any question a shift throws at you. Everything else is a refinement. The diagram shows how rows flow through a typical chain.

A KQL query is a left-to-right pipeline; each operator returns a table for the next.

Before the operators, the string-matching keywords you'll reach for constantly. Choosing the right one is the difference between a query that returns in a second and one that scans the whole table.

OperatorMatchesCaseSpeed / use when
==Exact whole valueSensitiveFast — IDs, event codes, exact strings
=~Exact whole valueInsensitiveFast — usernames, filenames, hostnames
hasA whole indexed termInsensitiveFastest substring-like — whole words/terms
has_anyAny term in a listInsensitiveFast — multiple keywords at once
containsAny substringInsensitiveSlow — partial matches, punctuation
in / in~Value in a setSensitive / insensitiveFast — allow-lists, block-lists
startswith / endswithPrefix / suffixInsensitiveMedium — file extensions, path anchors
matches regexRegular expressionSensitiveSlowest — only when nothing else fits
1
where — filter rows
Filter
The workhorse. Always filter on TimeGenerated first so the engine prunes data early. String matching offers has (indexed, fast, term-based), contains (substring, slower), == (exact, case-sensitive) and =~ (exact, case-insensitive).
SecurityEvent | where TimeGenerated > ago(1d) // time filter first — always | where EventID == 4625 // failed logon | where Account !endswith "$" // drop machine accounts

Prefer has over contains whenever you are matching a whole term (a hostname, a filename). has uses the term index and is dramatically faster at scale; contains forces a full substring scan.

2
project — choose & rename columns
Shape
project keeps only the columns you name (and can rename them inline); project-away drops the ones you don't want; project-rename renames without reordering. Trimming columns early makes results readable and queries cheaper.
SigninLogs | project TimeGenerated, User=UserPrincipalName, IP=IPAddress, ResultType, App=AppDisplayName
3
summarize — aggregate
Aggregate
Collapses rows into groups. The high-value aggregation functions: count(), dcount() (distinct count), countif() (conditional count), make_set() / make_list() (collect values), min()/max(), and arg_max() (the full row with the largest value of a column).
SigninLogs | summarize Total=count(), Failed=countif(ResultType != 0), Apps=make_set(AppDisplayName) by UserPrincipalName
4
extend — compute new columns
Compute
Adds a calculated column without dropping the originals. Use it to parse dynamic fields, compute a ratio, or derive a category before you filter or summarize on it.
SigninLogs | extend Country = tostring(LocationDetails.countryOrRegion) | extend IsRisky = iff(RiskLevelDuringSignIn == "high", "YES", "no")
5
bin() — bucket time (with summarize)
Time series
bin() rounds a timestamp down to a fixed interval so summarize can build a time series. Pair it with render timechart in the Logs blade for an instant trend line — invaluable for spotting beaconing or a burst of failures.
SigninLogs | where ResultType != 0 | summarize Failures=count() by bin(TimeGenerated, 15m) | render timechart
6
join / union — combine tables
Correlate
join correlates two tables on a shared key (kinds: inner, leftouter, rightouter, leftanti, rightanti). leftanti is a hunter's favourite — it returns rows in the left table with no match on the right, perfect for "seen today but never before" logic. union stacks tables with similar schemas.
// Hosts that beaconed today to a destination never seen in the prior 14 days let baseline = DeviceNetworkEvents | where TimeGenerated between (ago(14d) .. ago(1d)) | distinct RemoteUrl; DeviceNetworkEvents | where TimeGenerated > ago(1d) and isnotempty(RemoteUrl) | join kind=leftanti baseline on RemoteUrl | summarize Hits=count() by DeviceName, RemoteUrl

In a join, put the smaller table on the left. KQL loads the left side into memory; a giant left table is the most common cause of "query used too much memory" failures.

🗄️

04 — Tables You'll Actually Query

Phase 4 / 13

A workspace can hold hundreds of tables, but a working analyst lives in a dozen. Know these and their key columns and you rarely need to look anything up. Column availability depends on the connectors you've enabled.

TableWhat's in itKey columns
SigninLogsEntra ID interactive sign-insUserPrincipalName, IPAddress, ResultType, LocationDetails, AppDisplayName
AADNonInteractiveUserSignInLogsToken / service sign-insUserPrincipalName, ResultType, ClientAppUsed
AuditLogsEntra directory changesOperationName, InitiatedBy, TargetResources
SecurityEventWindows Security event log (via agent)EventID, Account, Computer, LogonType
DeviceProcessEventsDefender for Endpoint process creationFileName, ProcessCommandLine, InitiatingProcessFileName
DeviceNetworkEventsEndpoint outbound connectionsRemoteIP, RemoteUrl, RemotePort, ActionType
DeviceLogonEventsEndpoint logon activityAccountName, LogonType, RemoteIP, ActionType
AzureActivityAzure control-plane operationsOperationNameValue, Caller, CallerIpAddress, ActivityStatusValue
CommonSecurityLogCEF — firewalls, proxiesSourceIP, DestinationIP, SentBytes, ReceivedBytes, DeviceVendor
SecurityAlertAlerts from all connected productsAlertName, AlertSeverity, Entities
ThreatIntelligenceIndicatorIngested TI IOCsNetworkIP, DomainName, FileHashValue, Active

Not sure a column exists in your tenant? Run TableName | getschema to list every column and its type, or TableName | take 5 to eyeball real rows before you build the full query.

🔐

05 — Identity & Sign-In Queries (1–5)

Phase 5 / 13

Identity is the number-one attacked surface. These five queries cover the sign-in patterns that show up in nearly every intrusion: brute force, spray success, geo-anomalies, MFA fatigue and legacy-auth abuse.

01
Failed sign-ins by user & IP (brute force)
T1110
DETECTS: A single account or source IP racking up many authentication failures in a short window — the signature of password brute force.
KUSTO
SigninLogs | where TimeGenerated > ago(24h) | where ResultType != 0 | summarize Failures=count(), Reasons=make_set(ResultDescription), FirstSeen=min(TimeGenerated), LastSeen=max(TimeGenerated) by UserPrincipalName, IPAddress | where Failures > 10 | sort by Failures desc
SPLUNK SPL
index=azure sourcetype="azure:aad:signin" "properties.status.errorCode"!=0 | stats count as Failures values("properties.status.failureReason") as Reasons by "properties.userPrincipalName" "properties.ipAddress" | where Failures > 10 | sort - Failures
02
Successful login after repeated failures (spray hit)
T1110.003
DETECTS: An account that failed many times and then succeeded from the same IP — a likely successful password spray or brute force. This is the query that turns noise into an incident.
KUSTO
SigninLogs | where TimeGenerated > ago(24h) | summarize Failed=countif(ResultType != 0), Success=countif(ResultType == 0), SuccessTime=maxif(TimeGenerated, ResultType == 0) by UserPrincipalName, IPAddress | where Failed >= 10 and Success > 0 | sort by Failed desc
SPLUNK SPL
index=azure sourcetype="azure:aad:signin" | eval failed=if('properties.status.errorCode'!=0,1,0), ok=if('properties.status.errorCode'==0,1,0) | stats sum(failed) as Failed sum(ok) as Success by "properties.userPrincipalName" "properties.ipAddress" | where Failed>=10 AND Success>0
03
Sign-ins from multiple countries (geo-anomaly)
T1078
DETECTS: One account authenticating successfully from more than one country in a day — a lightweight "impossible travel" proxy that needs no ML.
KUSTO
SigninLogs | where TimeGenerated > ago(24h) and ResultType == 0 | extend Country = tostring(LocationDetails.countryOrRegion) | summarize Countries=make_set(Country), Cities=make_set(tostring(LocationDetails.city)), IPs=make_set(IPAddress), Logins=count() by UserPrincipalName | where array_length(Countries) > 1 | sort by Logins desc
SPLUNK SPL
index=azure sourcetype="azure:aad:signin" "properties.status.errorCode"=0 | stats dc("properties.location.countryOrRegion") as nCountries values("properties.location.countryOrRegion") as Countries by "properties.userPrincipalName" | where nCountries > 1
04
MFA fatigue / prompt bombing
T1621
DETECTS: A rapid burst of MFA challenges to one user — the fingerprint of push-notification "fatigue" attacks where an operator holds a valid password and spams approvals.
KUSTO
SigninLogs | where TimeGenerated > ago(6h) | where ResultType in ("50074", "500121", "50076") // MFA required / failed / satisfied-by-claim denied | summarize Prompts=count(), Results=make_set(ResultType) by UserPrincipalName, bin(TimeGenerated, 10m) | where Prompts > 5 | sort by Prompts desc
SPLUNK SPL
index=azure sourcetype="azure:aad:signin" ("properties.status.errorCode"=50074 OR "properties.status.errorCode"=500121 OR "properties.status.errorCode"=50076) | bucket _time span=10m | stats count as Prompts by "properties.userPrincipalName" _time | where Prompts > 5
05
Legacy authentication usage
T1078.004
DETECTS: Sign-ins over legacy protocols (IMAP, POP3, SMTP, ActiveSync) that bypass modern MFA — a favourite path for spray attacks against Microsoft 365.
KUSTO
union SigninLogs, AADNonInteractiveUserSignInLogs | where TimeGenerated > ago(7d) | where ClientAppUsed in ("IMAP4", "POP3", "SMTP", "Other clients", "Exchange ActiveSync") | summarize Attempts=count(), Success=countif(ResultType == 0) by UserPrincipalName, ClientAppUsed, IPAddress | sort by Attempts desc
SPLUNK SPL
index=azure sourcetype="azure:aad:signin" "properties.clientAppUsed" IN ("IMAP4","POP3","SMTP","Other clients","Exchange ActiveSync") | stats count as Attempts by "properties.userPrincipalName" "properties.clientAppUsed" "properties.ipAddress"

Legacy auth should be near-zero in a hardened 2026 tenant. If this query returns rows, treat it as both an incident lead and a hardening backlog item — Conditional Access can block legacy auth outright.

💻

06 — Endpoint & Process Queries (6–10)

Phase 6 / 13

Once an attacker lands on a host, they run commands. These queries target the execution patterns — encoded PowerShell, living-off-the-land binaries, suspicious parent-child chains, new services and log clearing — that separate an operator from a user.

06
Encoded / obfuscated PowerShell
T1059.001
DETECTS: PowerShell launched with base64-encoded commands or hidden windows — classic loader and fileless-malware behaviour.
KUSTO
DeviceProcessEvents | where TimeGenerated > ago(7d) | where FileName in~ ("powershell.exe", "pwsh.exe") | where ProcessCommandLine has_any ("-enc", "-EncodedCommand", "FromBase64String", "-w hidden", "-nop", "IEX") | project TimeGenerated, DeviceName, AccountName, ProcessCommandLine, InitiatingProcessFileName | sort by TimeGenerated desc
SPLUNK SPL (Sysmon)
index=win sourcetype="XmlWinEventLog:Microsoft-Windows-Sysmon/Operational" EventCode=1 (Image="*\\powershell.exe" OR Image="*\\pwsh.exe") | search CommandLine="*-enc*" OR CommandLine="*EncodedCommand*" OR CommandLine="*FromBase64String*" | table _time Computer User CommandLine ParentImage
07
Living-off-the-land binaries (LOLBins)
T1218
DETECTS: Trusted system binaries used for download or proxy execution — certutil pulling a file, mshta running remote script, regsvr32 with scrobj.
KUSTO
DeviceProcessEvents | where TimeGenerated > ago(7d) | where FileName in~ ("certutil.exe", "bitsadmin.exe", "mshta.exe", "regsvr32.exe", "rundll32.exe") | where ProcessCommandLine has_any ("http", "-urlcache", "javascript:", "scrobj", "/i:") | project TimeGenerated, DeviceName, AccountName, FileName, ProcessCommandLine
SPLUNK SPL (Sysmon)
index=win sourcetype="XmlWinEventLog:Microsoft-Windows-Sysmon/Operational" EventCode=1 (Image="*certutil.exe" OR Image="*mshta.exe" OR Image="*regsvr32.exe" OR Image="*bitsadmin.exe") | search CommandLine="*http*" OR CommandLine="*scrobj*" OR CommandLine="*-urlcache*" | table _time Computer User Image CommandLine
08
Office app spawning a shell
T1566
DETECTS: Word, Excel, PowerPoint or Outlook launching a command interpreter — the hallmark of a malicious macro or exploit payload firing after a phishing open.
KUSTO
DeviceProcessEvents | where TimeGenerated > ago(7d) | where InitiatingProcessFileName in~ ("winword.exe", "excel.exe", "powerpnt.exe", "outlook.exe") | where FileName in~ ("cmd.exe", "powershell.exe", "wscript.exe", "cscript.exe", "mshta.exe") | project TimeGenerated, DeviceName, InitiatingProcessFileName, FileName, ProcessCommandLine
SPLUNK SPL (Sysmon)
index=win sourcetype="XmlWinEventLog:Microsoft-Windows-Sysmon/Operational" EventCode=1 (ParentImage="*winword.exe" OR ParentImage="*excel.exe" OR ParentImage="*outlook.exe") (Image="*cmd.exe" OR Image="*powershell.exe" OR Image="*wscript.exe" OR Image="*mshta.exe") | table _time Computer ParentImage Image CommandLine
09
New service installed
T1543.003
DETECTS: Windows Security event 4697 (a service was installed) — a common persistence and lateral-movement technique, especially services whose binary path points at a temp or user directory.
KUSTO
SecurityEvent | where TimeGenerated > ago(7d) | where EventID == 4697 | extend Suspicious = ServiceFileName has_any ("\\Temp\\", "\\Users\\", "powershell", "cmd /c", "\\ProgramData\\") | project TimeGenerated, Computer, SubjectUserName, ServiceName, ServiceFileName, Suspicious | sort by Suspicious desc, TimeGenerated desc
SPLUNK SPL
index=win sourcetype="WinEventLog:Security" EventCode=4697 | eval Suspicious=if(match(Service_File_Name,"(?i)\\\\Temp\\\\|\\\\Users\\\\|powershell"),1,0) | table _time ComputerName Account_Name Service_Name Service_File_Name Suspicious
10
Security log cleared
T1070.001
DETECTS: Windows event 1102 (audit log cleared) — a high-fidelity anti-forensics signal that almost always means someone is covering tracks.
KUSTO
SecurityEvent | where TimeGenerated > ago(30d) | where EventID == 1102 | project TimeGenerated, Computer, ClearedBy=SubjectUserName, SubjectDomainName | sort by TimeGenerated desc
SPLUNK SPL
index=win sourcetype="WinEventLog:Security" EventCode=1102 | table _time ComputerName Account_Name

Event 1102 has an extremely low false-positive rate. Any hit outside a documented maintenance window deserves an immediate look at the host — attackers clear logs after they've done the damage, not before.

🌐

07 — Network & Exfil Queries (11–14)

Phase 7 / 13

Command-and-control and data theft both leave network fingerprints: regular beacons, first-seen destinations, oversized uploads and oddly long domains. These four queries surface them.

11
Beaconing — many hits to one destination
T1071
DETECTS: A host making an unusually high, steady number of connections to a single external destination — the coarse but effective first pass for C2 beacon hunting.
KUSTO
DeviceNetworkEvents | where TimeGenerated > ago(1d) | where RemoteIPType == "Public" | summarize Conns=count(), Ports=make_set(RemotePort), Buckets=dcount(bin(TimeGenerated, 5m)) by DeviceName, RemoteIP, RemoteUrl | where Conns > 100 and Buckets > 20 // spread across time = steady, not a burst | sort by Conns desc
SPLUNK SPL
index=network sourcetype="pan:traffic" | bucket _time span=5m | stats count as Conns dc(_time) as Buckets by src_ip dest_ip | where Conns > 100 AND Buckets > 20
12
Large outbound transfer (exfil)
T1048
DETECTS: Source hosts pushing an abnormal volume of bytes to a single external destination — a candidate for data exfiltration. Uses firewall/proxy CEF data where byte counts live.
KUSTO
CommonSecurityLog | where TimeGenerated > ago(1d) | where isnotempty(SentBytes) | summarize TotalMB = round(sum(SentBytes) / 1024.0 / 1024.0, 1) by SourceIP, DestinationIP | where TotalMB > 500 | sort by TotalMB desc
SPLUNK SPL
index=network sourcetype="pan:traffic" | stats sum(bytes_out) as BytesOut by src_ip dest_ip | eval TotalMB=round(BytesOut/1024/1024,1) | where TotalMB > 500 | sort - TotalMB
13
First-seen external destination
T1071
DETECTS: Destinations contacted today that were never seen in the prior two weeks — new infrastructure is where fresh C2 hides. Uses a leftanti join against a baseline.
KUSTO
let baseline = DeviceNetworkEvents | where TimeGenerated between (ago(15d) .. ago(1d)) | distinct RemoteUrl; DeviceNetworkEvents | where TimeGenerated > ago(1d) and isnotempty(RemoteUrl) | join kind=leftanti baseline on RemoteUrl | summarize Hits=count(), Hosts=dcount(DeviceName) by RemoteUrl | sort by Hits desc
SPLUNK SPL
``` requires a lookup of previously-seen domains, e.g. seen_domains.csv ``` index=endpoint sourcetype="device:network" earliest=-1d | search NOT [ | inputlookup seen_domains.csv | fields RemoteUrl ] | stats count as Hits dc(DeviceName) as Hosts by RemoteUrl
14
Suspiciously long domains (DGA)
T1568.002
DETECTS: Very long hostnames with high digit content — a cheap heuristic for domain-generation-algorithm C2 that avoids hard-coded blocklists.
KUSTO
DeviceNetworkEvents | where TimeGenerated > ago(1d) and isnotempty(RemoteUrl) | extend Host = tostring(parse_url(strcat("http://", RemoteUrl)).Host) | extend Len = strlen(Host), Digits = countof(Host, @"\d", "regex") | where Len > 30 and Digits >= 6 | summarize Hits=count() by Host, DeviceName | sort by Hits desc
SPLUNK SPL
index=endpoint sourcetype="device:network" | eval Len=len(RemoteUrl), Digits=len(replace(RemoteUrl,"[^0-9]","")) | where Len>30 AND Digits>=6 | stats count as Hits by RemoteUrl DeviceName

Domain length alone is noisy — CDNs and analytics beacons are long too. Chain this into query 13 (first-seen) or a TI join (query 19) to cut the false positives to something a shift can action.

☁️

08 — Cloud, Audit & Alert Queries (15–20)

Phase 8 / 13

The control plane is where an attacker turns a foothold into ownership. These queries watch Azure role changes, subscription takeover, secret access, and tie the whole shift together with alert summaries and threat-intel matching.

15
Azure role assignment changes
T1098
DETECTS: New RBAC role assignments in Azure — privilege grants are how a compromised account becomes durable. Watch for Owner/Contributor grants especially.
KUSTO
AzureActivity | where TimeGenerated > ago(7d) | where OperationNameValue =~ "Microsoft.Authorization/roleAssignments/write" | where ActivityStatusValue == "Success" | project TimeGenerated, Caller, CallerIpAddress, ResourceGroup, _ResourceId | sort by TimeGenerated desc
SPLUNK SPL
index=azure sourcetype="azure:activity" operationName="Microsoft.Authorization/roleAssignments/write" status=Succeeded | table _time caller callerIpAddress resourceGroup resourceId
16
Subscription elevate-access (tenant takeover)
T1078.004
DETECTS: Use of the "elevate access" operation, which grants a Global Admin User Access Administrator rights over all subscriptions — a rare, high-impact action that should always be reviewed.
KUSTO
AzureActivity | where TimeGenerated > ago(30d) | where OperationNameValue has "elevateAccess" | project TimeGenerated, Caller, CallerIpAddress, ActivityStatusValue
SPLUNK SPL
index=azure sourcetype="azure:activity" operationName="*elevateAccess*" | table _time caller callerIpAddress status

elevateAccess should fire maybe once a year in a healthy tenant, during a documented break-glass event. An unexplained hit is a candidate tenant-takeover — escalate immediately (see Phase 10 / escalation).

17
Key Vault secret access spikes
T1552.001
DETECTS: A caller pulling an unusual number of secrets or keys from Azure Key Vault — a sign of credential harvesting after a compromise.
KUSTO
AzureDiagnostics | where TimeGenerated > ago(1d) | where ResourceType == "VAULTS" | where OperationName in ("SecretGet", "KeyGet", "CertificateGet") | summarize Reads=count(), Secrets=dcount(id_s) by CallerIPAddress, identity_claim_upn_s = tostring(column_ifexists("identity_claim_upn_s", "")) | where Reads > 50 | sort by Reads desc
SPLUNK SPL
index=azure sourcetype="azure:keyvault" (operationName=SecretGet OR operationName=KeyGet OR operationName=CertificateGet) | stats count as Reads dc(id) as Secrets by callerIpAddress identity.claim.upn | where Reads > 50
18
Alert summary for shift triage
Triage
DETECTS: Nothing new — this is your shift-start dashboard in one query: every alert grouped by name and severity so you know where the fire is.
KUSTO
SecurityAlert | where TimeGenerated > ago(24h) | summarize Count=count(), Products=make_set(ProductName) by AlertName, AlertSeverity | sort by AlertSeverity asc, Count desc
SPLUNK SPL
index=notable earliest=-24h | stats count as Count values(source) as Products by rule_name urgency | sort urgency - Count
19
Threat-intel IOC match on network traffic
T1071
DETECTS: Any outbound connection to an IP flagged as malicious in your ingested threat-intel feed — a direct join of live telemetry against known-bad indicators.
KUSTO
let badIPs = ThreatIntelligenceIndicator | where Active == true and isnotempty(NetworkIP) | summarize by NetworkIP; DeviceNetworkEvents | where TimeGenerated > ago(1d) | where RemoteIP in (badIPs) | project TimeGenerated, DeviceName, RemoteIP, RemoteUrl, InitiatingProcessFileName
SPLUNK SPL
index=endpoint sourcetype="device:network" | lookup threat_intel_ip ip as RemoteIP OUTPUT threat_source | where isnotnull(threat_source) | table _time DeviceName RemoteIP RemoteUrl threat_source

Watchlists are the manual cousin of TI feeds. Load a CSV of known-bad indicators and reference it with _GetWatchlist("MyBadIPs") — handy for incident-specific IOCs you don't want to push into the whole TI pipeline.

20
Per-user risk roll-up
Triage
DETECTS: A ranked, one-row-per-user summary combining login success/failure counts and geo spread — the fastest way to spot the two or three accounts worth investigating right now.
KUSTO
SigninLogs | where TimeGenerated > ago(7d) | summarize Success=countif(ResultType == 0), Failed=countif(ResultType != 0), Countries=dcount(tostring(LocationDetails.countryOrRegion)), IPs=dcount(IPAddress) by UserPrincipalName | extend FailRatio = round(1.0 * Failed / (Success + Failed + 1), 2) | where Failed > 20 or Countries > 2 | sort by FailRatio desc, Failed desc
SPLUNK SPL
index=azure sourcetype="azure:aad:signin" earliest=-7d | eval ok=if('properties.status.errorCode'==0,1,0), fail=if('properties.status.errorCode'!=0,1,0) | stats sum(ok) as Success sum(fail) as Failed dc("properties.location.countryOrRegion") as Countries dc("properties.ipAddress") as IPs by "properties.userPrincipalName" | eval FailRatio=round(Failed/(Success+Failed+1),2) | where Failed>20 OR Countries>2
🚀

09 — Performance & Optimization

Phase 9 / 13

A correct query that times out is useless during an incident. These habits keep queries fast at enterprise log volumes, where a single table can hold billions of rows.

✓
The optimization checklist
Best practice
Order of operations that keeps queries cheap
  • 1Filter time first, filter narrow second. Put where TimeGenerated > ago() at the very top so the engine skips irrelevant partitions before doing anything else.
  • 2Filter before you summarize or join. Every row you drop early is a row the expensive operators never touch.
  • 3Use has, not contains. Term-indexed search is orders of magnitude faster than substring scanning.
  • 4Project only the columns you need before a join — narrow tables move less data.
  • 5Small table on the left of a join. The left side is materialized in memory.
  • 6Avoid search * and union * in anything you run repeatedly — they scan everything.

Drop | take 100 at the end while you iterate. You get near-instant feedback on shape and correctness, then remove it for the full run. Pair with the Logs blade's built-in query performance pane to see scanned data volume.

🛡️

10 — From Query to Detection Rule

Phase 10 / 13

Any query that returns a row when something bad happens can become an automated detection. In Sentinel these are analytics rules. Portal path: Microsoft Sentinel → Analytics → Create → Scheduled query rule.

01
Turn query 02 into a scheduled rule
Analytics
Take the "spray hit" query, add entity mapping so the incident is investigable, and schedule it. Entity mapping is what lets Sentinel build an incident graph and correlate across alerts.
KUSTO — rule logic
SigninLogs | where TimeGenerated > ago(1h) | summarize Failed=countif(ResultType != 0), Success=countif(ResultType == 0) by UserPrincipalName, IPAddress | where Failed >= 10 and Success > 0
Rule settings that matter
  • 1Run frequency & lookback: every 1h over the last 1h — keep them aligned to avoid gaps or double-counting.
  • 2Entity mapping: map UserPrincipalName → Account and IPAddress → IP.
  • 3Event grouping: group all rows into a single incident to avoid alert storms.
  • 4Suppression: stop re-alerting on the same entity for a cooldown window after it fires.

For time-critical detections like elevateAccess (query 16), use a Near-Real-Time (NRT) rule instead of scheduled — NRT runs roughly once a minute and skips the scheduling delay.

Tune before you deploy. Run any candidate rule as a plain query across 7–14 days first and count the hits. If it would have fired 300 times, it is a dashboard tile, not an alert — add thresholds or allow-lists until the volume is actionable.

🧯

11 — Troubleshooting & Common Errors

Phase 11 / 13

The errors below account for the overwhelming majority of "my query won't run" tickets. Learn the fix once and stop losing minutes during an incident.

SymptomCauseFix
SemanticError: 'X' could not be resolvedTable or column doesn't exist in this workspaceThe connector isn't enabled, or you mistyped. Run TableName | getschema
Empty results, but data existsTime picker overrides ago(); case-sensitive ==Widen the time picker; switch == to =~ for values
Query exceeded memory / too complexHuge table on the left of a joinSwap join sides; project before joining; add filters
Results truncated / partialHit the 500,000-row / 64 MB result limitAggregate with summarize; add set truncationmaxrecords only if essential
Query timed outScanning too much dataNarrow the time range; filter earlier; use has over contains
Dynamic field returns emptyAccessing a JSON field as a stringWrap with tostring(Column.subfield) before comparing
"has" returns nothing on an IP/GUIDhas is term-based; punctuation breaks termsUse ==/=~ for exact IDs, or contains for partials

KQL let statements must each end with a semicolon, and the final query after the last let must not. A stray or missing semicolon is the most common silent syntax error in multi-part queries.

🎯

12 — MITRE ATT&CK Mapping

Phase 12 / 13

Mapping each query to a technique makes coverage gaps visible and gives your detections a shared language with threat intel and IR.

QueryTechniqueIDTactic
01 Failed sign-insBrute ForceT1110Credential Access
02 Spray hitPassword SprayingT1110.003Credential Access
03 Geo-anomalyValid AccountsT1078Initial Access / Persistence
04 MFA fatigueMFA Request GenerationT1621Credential Access
05 Legacy authValid Accounts: CloudT1078.004Defense Evasion
06 Encoded PowerShellPowerShellT1059.001Execution
07 LOLBinsSystem Binary Proxy ExecutionT1218Defense Evasion
08 Office spawns shellPhishingT1566Initial Access
09 New serviceWindows ServiceT1543.003Persistence
10 Log clearedClear Windows Event LogsT1070.001Defense Evasion
11 BeaconingApplication Layer ProtocolT1071Command & Control
12 Large uploadExfiltration Over Alt ProtocolT1048Exfiltration
14 Long domainsDomain Generation AlgorithmsT1568.002Command & Control
15 Role assignmentAccount ManipulationT1098Persistence
17 Key Vault readsCredentials from Password StoresT1552.001Credential Access
You can now
  • Read and write KQL as a top-to-bottom pipeline
  • Run identical queries in the Logs blade, advanced hunting and the API
  • Triage identity, endpoint, network and cloud with 20 field-ready queries
  • Translate the same logic into Splunk SPL for a mixed-SIEM shop
  • Promote any query into a tuned, entity-mapped analytics rule
📚

13 — Sources & References

Phase 13 / 13
Microsoft Learn — Common tasks with KQL for Microsoft Sentinel Microsoft Learn — Advanced hunting with Microsoft Sentinel data in Microsoft Defender Microsoft Learn — Data tables in the Microsoft Defender XDR advanced hunting schema Microsoft Learn — Hunting capabilities in Microsoft Sentinel Microsoft Learn — Kusto Query Language reference MITRE ATT&CK — Enterprise techniques

Turn these queries into a working detection stack.

Save the 20 queries as functions, wire the high-fidelity ones into analytics rules, and pair them with the matching CyberHawk SOPs for triage and response. Explore our SOP library, hands-on courses, and the IOC Scanner to enrich query 19's threat-intel matching.

◈ Stay Connected

Follow CyberHawk Threat Intel for threat intelligence, deployment guides and hands-on SOC tooling content.

🌐 Website ▶️ YouTube ▶️ YouTube (2) 𝕏 Twitter / X ♪ TikTok ✈️ Telegram
🔍 IOC Scanner 🛠️ Live Tools 📚 Courses 🚨 Threat Intel 📝 Blog 📋 SOPs

"They can't exploit you if you are the Exploit."