Advanced KQL: Mastering Join Operations.
The phishing email was easy to find.
The successful sign-in was easy to find. The PowerShell process was easy to find. The Defender alert was easy to find.
But none of those tables told the whole story on their own.
Agent Foskett needed to correlate email, identity, endpoint, cloud and alert evidence into one investigation timeline. That is where join becomes one of the most important skills in KQL.
Lesson overview
Learn how to use advanced KQL join operations to correlate Microsoft Defender XDR evidence across email, identity, endpoint, cloud and alert telemetry.
Why join operations matter
Join types defenders should understand
Step 1 — Join AlertInfo with AlertEvidence
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
let TimeFrame = 7d;
AlertInfo
| where Timestamp > ago(TimeFrame)
| project AlertId, AlertTime = Timestamp, Title, Severity, Category
| join kind=inner (
AlertEvidence
| where Timestamp > ago(TimeFrame)
| project AlertId, EvidenceTime = Timestamp, EntityType, EvidenceRole,
AccountName, DeviceName, RemoteIP, FileName, SHA256
) on AlertId
| project AlertTime, Title, Severity, Category, EntityType, EvidenceRole,
AccountName, DeviceName, RemoteIP, FileName, SHA256
| order by AlertTime descStep 2 — Join email delivery with URL clicks
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
let TimeFrame = 7d;
EmailEvents
| where Timestamp > ago(TimeFrame)
| where Subject has_any ("invoice", "payment", "password", "urgent")
| project EmailTime = Timestamp, NetworkMessageId, RecipientEmailAddress,
SenderFromAddress, Subject, DeliveryAction
| join kind=leftouter (
UrlClickEvents
| where Timestamp > ago(TimeFrame)
| project ClickTime = Timestamp, NetworkMessageId, AccountUpn, Url, ActionType
) on NetworkMessageId
| project EmailTime, ClickTime, RecipientEmailAddress, AccountUpn,
SenderFromAddress, Subject, DeliveryAction, Url, ActionType
| order by EmailTime descStep 3 — Join identity activity with endpoint logons
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
let TimeFrame = 7d;
IdentityLogonEvents
| where Timestamp > ago(TimeFrame)
| project IdentityTime = Timestamp, AccountUpn, IPAddress, Location, ActionType
| join kind=leftouter (
DeviceLogonEvents
| where Timestamp > ago(TimeFrame)
| project DeviceTime = Timestamp, AccountUpn, DeviceName, LogonType, RemoteIP
) on AccountUpn
| where abs(datetime_diff("minute", IdentityTime, DeviceTime)) <= 60
| project IdentityTime, DeviceTime, AccountUpn, DeviceName, IPAddress,
RemoteIP, Location, ActionType, LogonType
| order by IdentityTime descStep 4 — Join process activity with network connections
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
let TimeFrame = 24h;
DeviceProcessEvents
| where Timestamp > ago(TimeFrame)
| where FileName in~ ("powershell.exe", "cmd.exe", "rundll32.exe", "regsvr32.exe", "mshta.exe")
| project ProcessTime = Timestamp, DeviceName, InitiatingProcessId,
FileName, ProcessCommandLine, AccountName
| join kind=leftouter (
DeviceNetworkEvents
| where Timestamp > ago(TimeFrame)
| project NetworkTime = Timestamp, DeviceName, InitiatingProcessId,
RemoteUrl, RemoteIP, RemotePort
) on DeviceName, InitiatingProcessId
| project ProcessTime, NetworkTime, DeviceName, AccountName, FileName,
ProcessCommandLine, RemoteUrl, RemoteIP, RemotePort
| order by ProcessTime descStep 5 — Use leftanti to find missing matches
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
let TimeFrame = 7d;
let RiskyUsers =
IdentityLogonEvents
| where Timestamp > ago(TimeFrame)
| where ActionType has_any ("risk", "suspicious", "unfamiliar")
| distinct AccountUpn;
RiskyUsers
| join kind=leftanti (
AlertEvidence
| where Timestamp > ago(TimeFrame)
| where isnotempty(AccountName)
| distinct AccountName
) on $left.AccountUpn == $right.AccountName
| project AccountUpnStep 6 — Optimise joins before scaling them
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
let TimeFrame = 3d;
let SuspiciousProcesses =
DeviceProcessEvents
| where Timestamp > ago(TimeFrame)
| where FileName in~ ("powershell.exe", "rundll32.exe", "regsvr32.exe")
| project Timestamp, DeviceName, AccountName, FileName, ProcessCommandLine;
let AlertedDevices =
AlertEvidence
| where Timestamp > ago(TimeFrame)
| where EntityType =~ "Machine"
| where isnotempty(DeviceName)
| distinct DeviceName;
SuspiciousProcesses
| join kind=inner AlertedDevices on DeviceName
| order by Timestamp descCommon join mistakes
Join performance checklist
Related Agent Foskett Academy lessons
Coming next
Final thought
Advanced KQL Mastering Join Operations in Microsoft Defender XDR
Agent Foskett Academy Lesson 121 teaches defenders how to use advanced KQL join operations to correlate Microsoft Defender XDR telemetry across email, identity, endpoint, alert, network and cloud security tables.
Learn KQL joins for Microsoft Defender XDR hunting
This lesson explains inner joins, leftouter joins, leftanti joins, leftsemi joins, join keys, performance optimisation and practical multi-table investigation workflows.
Microsoft Defender XDR advanced hunting join tutorial
Security analysts can use KQL joins to connect AlertInfo, AlertEvidence, EmailEvents, UrlClickEvents, IdentityLogonEvents, DeviceProcessEvents, DeviceNetworkEvents and CloudAppEvents.
