4.3 Investigation Patterns

60 minutes · Module 4 · Free

Investigation Patterns

Pattern 1: Account Compromise Triage

When an account is reported as potentially compromised, this is the first query to run:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
let TargetUser = "j.smith@northgateeng.com";
union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(7d)
| where UserPrincipalName =~ TargetUser
| summarize
    TotalSignIns = count(),
    SuccessCount = countif(ResultType == 0),
    FailCount = countif(ResultType != 0),
    DistinctIPs = dcount(IPAddress),
    DistinctCountries = dcount(tostring(LocationDetails.countryOrRegion)),
    DistinctApps = dcount(AppDisplayName),
    InteractiveCount = countif(IsInteractive == true),
    NonInteractiveCount = countif(IsInteractive == false)

This gives you the shape of the user’s activity in one pass: how many sign-ins, how many succeeded, how many unique locations and IPs, and the split between interactive and non-interactive. A compromised account often shows a sudden spike in distinct IPs and countries compared to the user’s baseline.

Follow up with a detailed timeline:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let TargetUser = "j.smith@northgateeng.com";
union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(48h)
| where UserPrincipalName =~ TargetUser
| where ResultType == 0
| extend Country = tostring(LocationDetails.countryOrRegion)
| extend City = tostring(LocationDetails.city)
| extend OS = tostring(DeviceDetail.operatingSystem)
| extend Browser = tostring(DeviceDetail.browser)
| project TimeGenerated, IsInteractive, AppDisplayName, IPAddress, Country, City, OS, Browser, ConditionalAccessStatus, AuthenticationRequirement
| sort by TimeGenerated desc

Scan the output for: IP addresses you do not recognise, countries outside the user’s normal pattern, unusual applications (especially “Azure Portal” or “Microsoft Graph” if the user is not an IT administrator), and sign-ins where MFA was not required.

Pattern 2: Failed Sign-In Analysis (Brute Force / Password Spray)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType in (50126, 50053, 50057)
| summarize
    FailedAttempts = count(),
    DistinctUsers = dcount(UserPrincipalName),
    DistinctApps = dcount(AppDisplayName),
    Users = make_set(UserPrincipalName, 20)
    by IPAddress, bin(TimeGenerated, 1h)
| where FailedAttempts > 10
| sort by FailedAttempts desc

This groups failures by source IP per hour. The pattern tells you the attack type:

  • One IP, many users, same error → Password spray. The attacker tries one common password against many accounts.
  • One IP, one user, many errors → Brute force. The attacker is hammering one account.
  • Many IPs, one user, same error → Distributed brute force. Multiple IPs targeting a single account — harder to detect by IP alone.
  • Many IPs, many users → Large-scale credential stuffing using leaked credential databases.

Pattern 3: Impossible Travel Detection

Entra ID Protection has built-in impossible travel detection, but understanding the logic lets you write custom hunting queries and validate alerts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
let TargetUser = "j.smith@northgateeng.com";
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == 0
| where UserPrincipalName =~ TargetUser
| extend Country = tostring(LocationDetails.countryOrRegion)
| extend Latitude = todouble(LocationDetails.geoCoordinates.latitude)
| extend Longitude = todouble(LocationDetails.geoCoordinates.longitude)
| sort by TimeGenerated asc
| extend PrevTime = prev(TimeGenerated)
| extend PrevLat = prev(Latitude)
| extend PrevLon = prev(Longitude)
| extend PrevCountry = prev(Country)
| extend TimeDeltaMinutes = datetime_diff('minute', TimeGenerated, PrevTime)
| where Country != PrevCountry and TimeDeltaMinutes < 120
| project TimeGenerated, Country, City = tostring(LocationDetails.city), IPAddress, TimeDeltaMinutes, PrevCountry

This finds cases where the same user signed in from two different countries within two hours. True impossible travel — for example, London to Moscow in 45 minutes — is a strong indicator of compromise. However, VPN usage causes false positives: a user might be physically in London but their VPN exit node is in Frankfurt. Correlate with your organisation’s known VPN exit IPs before escalating.

Pattern 4: Token Replay / Session Theft Detection

AiTM phishing campaigns steal session tokens rather than passwords. The stolen token is replayed from the attacker’s infrastructure. Detecting this requires looking for sign-ins where:

  • The sign-in succeeded (ResultType == 0)
  • MFA was not challenged (because the stolen token bypasses MFA)
  • The IP or ASN does not match the user’s normal pattern
  • The sign-in appears in the non-interactive table (token refresh)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
let SuspiciousASNs = dynamic([
    "AS14061", "AS16509", "AS13335", "AS20473", "AS63949"
]); // Common hosting providers: DigitalOcean, AWS, Cloudflare, Vultr, Linode
AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(24h)
| where ResultType == 0
| where AutonomousSystemNumber in (SuspiciousASNs)
| extend Country = tostring(LocationDetails.countryOrRegion)
| summarize
    TokenRefreshes = count(),
    DistinctApps = make_set(AppDisplayName),
    Countries = make_set(Country)
    by UserPrincipalName, IPAddress, AutonomousSystemNumber
| where TokenRefreshes > 5
| sort by TokenRefreshes desc

Legitimate users rarely sign in from cloud hosting providers. If a non-interactive token refresh comes from DigitalOcean or AWS, and the user is not a developer running cloud-based tools, this warrants immediate investigation.

Pattern 5: Conditional Access Bypass Monitoring

Conditional Access policies are your enforcement layer. Monitoring for sign-ins that were not evaluated by CA — or where CA was applied but resulted in “notApplied” — identifies gaps in your policy coverage:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == 0
| where ConditionalAccessStatus == "notApplied"
| summarize
    Count = count(),
    Users = dcount(UserPrincipalName),
    Apps = make_set(AppDisplayName)
    by AuthenticationRequirement
| sort by Count desc

Successful sign-ins where no Conditional Access policy was applied represent your policy blind spots. If AuthenticationRequirement shows “singleFactorAuthentication” on these, users are accessing resources with only a password — no MFA, no device compliance check, no location restriction.

Pattern 6: Legacy Authentication Detection

Legacy authentication protocols (SMTP, IMAP, POP3, Exchange ActiveSync with basic auth) do not support MFA. Attackers target these protocols specifically because they bypass your Conditional Access MFA requirements:

1
2
3
4
5
6
7
8
9
SigninLogs
| where TimeGenerated > ago(7d)
| where ClientAppUsed in ("Exchange ActiveSync", "IMAP4", "POP3", "SMTP", "Authenticated SMTP", "Other clients")
| where ResultType == 0
| summarize
    Count = count(),
    Users = make_set(UserPrincipalName, 20)
    by ClientAppUsed
| sort by Count desc

Any successful sign-in via a legacy protocol should be investigated. Microsoft has been deprecating basic authentication, but some tenants still have it enabled for specific mailboxes or applications. If you find active legacy auth in your environment, the remediation is to block these protocols via Conditional Access.

Investigation decision: account compromise triage

You receive an alert: "Impossible travel detected for j.morrison@northgateeng.com." The user signed in from London at 09:14 and from Lagos at 09:47 — 33 minutes apart. What do you do?
Step 1: What is the most important field to check first?

Try it yourself

Write a KQL query that detects impossible travel: users who had successful sign-ins from two different countries within 60 minutes. Hint: use summarize with make_set and dcount, then filter for dcount of countries greater than 1.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
SigninLogs
| where TimeGenerated > ago(1d)
| where ResultType == 0
| extend Country = tostring(LocationDetails.countryOrRegion)
| summarize
Countries = make_set(Country),
CountryCount = dcount(Country),
IPs = make_set(IPAddress),
SignInCount = count()
by UserPrincipalName, bin(TimeGenerated, 1h)
| where CountryCount > 1
| project TimeGenerated, UserPrincipalName, Countries, IPs, SignInCount

This query groups successful sign-ins by user and hour, collects the distinct countries and IPs, and flags any user who signed in from more than one country within the same hour. In a real environment, you would exclude known VPN exit nodes and corporate IP ranges to reduce false positives.

Check your understanding

1. What does ResultType == 0 mean in a sign-in log?

Successful sign-in
Failed sign-in
Blocked by conditional access

2. You are investigating a brute force attack. Which query pattern identifies the attack?

where ResultType == 0 | summarize count() by UserPrincipalName
where ResultType != 0 | summarize FailCount = count() by IPAddress | where FailCount > threshold
where AppDisplayName == "Exchange Online"

3. How do you detect legacy authentication protocols that bypass MFA?

Check the UserAgent field for old browser versions
Check the AppDisplayName for legacy apps
Filter on ClientAppUsed for protocols like IMAP, POP3, SMTP, or ActiveSync