Every impossible-travel detection works on day one and drowns by week two. The logic is rarely the problem — the problem is that corporate egress makes users appear to teleport, and the usual fix is to bury exclusions inside the query where nobody can audit them.
Keep the exclusions in a watchlist instead. The rule then stops changing, and adding an egress range becomes a data edit rather than a detection edit.
The logic
Two successful sign-ins for one account, from two countries, close enough together that the implied speed is impossible. The rule pairs each sign-in with the account’s previous one, so it measures actual consecutive events rather than comparing everything to a daily first-seen.
let window = 1d;
let maxPlausibleKph = 900.0;
// Anything egressing through corporate infrastructure. Maintained as data.
let corporateEgress =
_GetWatchlist('CorporateEgressIPs')
| project IPAddress = tostring(SearchKey);
SigninLogs
| where TimeGenerated > ago(window)
| where ResultType == "0"
| where isnotempty(LocationDetails)
| where IPAddress !in (corporateEgress)
| extend
Country = tostring(LocationDetails.countryOrRegion),
City = tostring(LocationDetails.city),
Latitude = todouble(LocationDetails.geoCoordinates.latitude),
Longitude = todouble(LocationDetails.geoCoordinates.longitude)
| where isnotnull(Latitude) and isnotnull(Longitude)
| sort by UserPrincipalName asc, TimeGenerated asc
| extend
PrevTime = prev(TimeGenerated),
PrevUser = prev(UserPrincipalName),
PrevLat = prev(Latitude),
PrevLon = prev(Longitude),
PrevCity = prev(City),
PrevCountry = prev(Country)
| where PrevUser == UserPrincipalName
| where Country != PrevCountry
| extend
Hours = datetime_diff('second', TimeGenerated, PrevTime) / 3600.0,
Km = geo_distance_2points(PrevLon, PrevLat, Longitude, Latitude) / 1000.0
| where Hours > 0
| extend ImpliedKph = round(Km / Hours, 1)
| where ImpliedKph > maxPlausibleKph
| project UserPrincipalName, PrevCountry, PrevCity, Country, City,
PrevTime, TimeGenerated, Km = round(Km, 0), Hours = round(Hours, 2),
ImpliedKph, IPAddress, AppDisplayName
| sort by ImpliedKph desc
Why a watchlist
_GetWatchlist is evaluated at query time, so the suppression list is live. The
practical consequences:
- Adding an egress range is a row, reviewed by whoever owns the range, with a timestamp on it. No detection change, no redeploy, no approval queue.
- The exclusion list is greppable. When somebody asks in six months why a country pair never alerts, the answer is a row rather than an archaeology dig through query history.
- The rule stays readable. Suppression logic inlined into a
whereclause grows until the detection’s actual intent is unrecoverable.
Keep SearchKey as the address column so !in stays an indexed lookup.
False positives
| Cause | What it looks like | Handling |
|---|---|---|
| Mobile roaming | Country flips, same user, minutes apart, mobile carrier ASN | Expected. Suppress by ASN, not by user. |
| Consumer VPN on a laptop | Plausible home country, then an exit node country | Real risk signal. Triage, do not suppress. |
| Geo-IP drift near borders | Two countries, tiny distance, absurd implied speed | Raise maxPlausibleKph, or floor on Km. |
| Automation with stored creds | Same account, datacentre ASN both sides | Suppress the identity, and ask why it holds a user credential. |
The geo-IP drift case is the one that catches people out. A 30 km hop across a
border in 40 seconds yields an implied speed in the thousands and looks dramatic
in the results. Add | where Km > 500 if that noise dominates; it costs you
nothing real, because a genuine impossible-travel case is never 30 km.
Tuning
maxPlausibleKph at 900 is roughly commercial cruise speed, which means a
legitimate flight will not trigger but will sit close to the line. Lower it and
you start alerting on connecting flights; raise it much beyond 1000 and you
begin missing the slower end of genuinely anomalous cases.
Do not tune this rule by raising the threshold in response to noise. Almost every time, the noise is a missing watchlist row.