KQL (Kusto Query Language) is the query language for Microsoft Sentinel, Microsoft Defender, and Azure Monitor Log Analytics. It’s also the basis for Azure Data Explorer. KQL is increasingly important as Microsoft security products dominate the enterprise market.
KQL Fundamentals
KQL is a read-only query language — you can only retrieve data, not modify it. The query starts with a table name and is built using pipe operators:
SecurityEvent // table (= source)
| where EventID == 4625 // filter
| where TimeGenerated > ago(1h) // time filter
| where Account !contains "ANONYMOUS" // string filter
| summarize FailCount=count() by Account, Computer // aggregate
| where FailCount > 10 // filter on aggregate
| order by FailCount desc // sort
| project Computer, Account, FailCount // select columns
Time is special in KQL
// Relative time
TimeGenerated > ago(1h) // last hour
TimeGenerated > ago(24h) // last 24 hours
TimeGenerated > now() - 7d // last 7 days
// Absolute time range
TimeGenerated between (datetime(2024-03-15) .. datetime(2024-03-16))
Always include a time filter. Without one, Sentinel will query potentially years of data.
The Essential KQL Operators
where — filter
SecurityEvent
| where EventID == 4688
| where NewProcessName has "powershell" // case-insensitive contains
| where CommandLine contains_cs "Invoke-Mimikatz" // case-sensitive
| where NewProcessName matches regex @"\\cmd\.exe$" // regex
| where Process in ("cmd.exe", "powershell.exe", "wscript.exe") // multi-value
| where isnotempty(CommandLine) // null check
String operators:
contains/!contains— substring match (case-insensitive)contains_cs— case-sensitive containsstartswith/endswith— position-specificmatches regex— full regexhas— whole-word matchin/!in— membership in list
summarize — aggregate
SecurityEvent
| where EventID == 4625
| where TimeGenerated > ago(1h)
| summarize
FailCount = count(),
UniqueAccounts = dcount(Account),
UniqueHosts = dcount(Computer),
FirstAttempt = min(TimeGenerated),
LastAttempt = max(TimeGenerated)
by tostring(IpAddress)
| where FailCount > 20 and UniqueAccounts > 5
Key aggregate functions:
count()— total rowsdcount(field)— distinct count (approximate)countif(condition)— conditional countavg(field),min(field),max(field),sum(field)make_set(field)— list of unique valuesmake_list(field)— list of all values
extend — add computed fields
SecurityEvent
| where EventID == 4688
| extend ProcessName = tostring(split(NewProcessName, "\\")[-1])
| extend IsScriptEngine = ProcessName in ("powershell.exe", "wscript.exe", "cscript.exe", "mshta.exe")
| where IsScriptEngine
| project TimeGenerated, Computer, Account, ProcessName, CommandLine, ParentProcessName
project — select columns
// project: select specific columns (drops the rest)
| project TimeGenerated, Computer, Account, EventID
// project-rename: select and rename
| project-rename Timestamp=TimeGenerated, Host=Computer
// project-away: drop specific columns
| project-away TenantId, SourceSystem, _ResourceId
Joining for Correlation
Inner join: accounts that failed then succeeded
let Failures = SecurityEvent
| where EventID == 4625 and TimeGenerated > ago(1h)
| summarize FailCount=count() by Account, Computer;
let Successes = SecurityEvent
| where EventID == 4624 and TimeGenerated > ago(1h)
| project Account, Computer, SuccessTime=TimeGenerated;
Failures
| join kind=inner Successes on Account, Computer
| where FailCount > 5
| project Account, Computer, FailCount, SuccessTime
| order by FailCount desc
Left anti-join: find accounts that ONLY failed (never succeeded)
let Failures = SecurityEvent
| where EventID == 4625 and TimeGenerated > ago(24h)
| summarize FailCount=count() by Account;
let Successes = SecurityEvent
| where EventID == 4624 and TimeGenerated > ago(24h)
| distinct Account;
Failures
| join kind=leftanti Successes on Account
| where FailCount > 50
This finds accounts with many failures but no successes — likely locked out or non-existent accounts being brute-forced.
Detection Pattern: Impossible Travel
// Same account logging in from two geographically distant IPs within a short time
let LoginEvents = SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType == 0 // success
| project TimeGenerated, UserPrincipalName, IPAddress, Location;
LoginEvents
| join kind=inner LoginEvents on UserPrincipalName
| where TimeGenerated1 > TimeGenerated
| where abs(datetime_diff('minute', TimeGenerated1, TimeGenerated)) < 60
| where IPAddress != IPAddress1
| where Location != Location1
| project
Account = UserPrincipalName,
Login1_Time = TimeGenerated, Login1_IP = IPAddress, Login1_Location = Location,
Login2_Time = TimeGenerated1, Login2_IP = IPAddress1, Login2_Location = Location1,
MinutesBetween = abs(datetime_diff('minute', TimeGenerated1, TimeGenerated))
| where MinutesBetween < 60
| order by MinutesBetween asc
KQL Reference— Microsoft Docs
Sentinel KQL Cheat Sheet— Microsoft