Tools · SIEM and detection engineering · 08 / 24
Detecting impossible travel sign-ins: logic, false positives, and a working query
Compute travel speed between consecutive sign-ins for each user, understand why VPNs and mobile networks trigger false positives, and make the detection useful.
The idea
If a user signs in from Madrid and then from Singapore 30 minutes later, one of those sign-ins probably was not them. Impossible travel compares consecutive successful sign-ins per user, computes the distance between their geolocated IPs and the time between them, and flags speeds faster than a plane.
Why it is noisy
IP geolocation is approximate. Corporate VPNs and cloud proxies make users appear in the provider’s data center city. Mobile carriers route through distant gateways. Some apps sign in from servers on the user’s behalf. A useful detection filters known VPN egress ranges, ignores non-interactive sign-ins, and raises severity only when combined with other signals such as a new device or MFA failure.
- Exclude corporate VPN and SASE egress IP ranges
- Use only interactive, successful sign-ins
- Require a minimum distance, such as 500 km
- Boost severity with new device, new ASN, or risky sign-in
Lab: compute speed with KQL
In a workspace with Entra ID sign-in logs, or using a small exported CSV loaded with datatable, order sign-ins per user, use prev() to compare each with the previous one, and compute distance with geo_distance_2points. Review the top results and identify which ones are VPN artifacts.
let vpn = dynamic(["198.51.100.0/24"]);
SigninLogs
| where TimeGenerated > ago(7d) and ResultType == 0 and IsInteractive == true
| where not(ipv4_is_in_any_range(IPAddress, vpn))
| extend lat = toreal(LocationDetails.geoCoordinates.latitude), lon = toreal(LocationDetails.geoCoordinates.longitude)
| sort by UserPrincipalName asc, TimeGenerated asc
| extend pUser = prev(UserPrincipalName), pTime = prev(TimeGenerated), pLat = prev(lat), pLon = prev(lon), pIp = prev(IPAddress)
| where UserPrincipalName == pUser
| extend km = geo_distance_2points(lon, lat, pLon, pLat) / 1000, hours = datetime_diff('second', TimeGenerated, pTime) / 3600.0
| extend kmh = iff(hours > 0, km / hours, real(null))
| where km > 500 and kmh > 900
| project UserPrincipalName, pTime, pIp, TimeGenerated, IPAddress, km = round(km), kmh = round(kmh)Responding to a hit
Check both sign-ins: device, browser, MFA method, and ASN. If one is unfamiliar, revoke sessions, require password reset, and review mailbox rules and new MFA methods registered after the suspicious sign-in. Record VPN artifacts as exclusions so the next alert is more trustworthy.
Diagram
Two consecutive sign-ins for one user
Speed is distance over time.
Sign-in 1
alice@contoso.example 09:00 UTC from 203.0.113.10
Madrid, Spain
Sign-in 2
alice@contoso.example 09:35 UTC from 192.0.2.77
Singapore
Verdict
New browser, new ASN, no MFA prompt (token reuse?)
High severity
Check yourself
1. What is the most common cause of false impossible-travel alerts?
2. Which KQL function lets you compare a row with the previous row after sorting?
3. Which extra signal most increases confidence in an impossible-travel alert?