Microsoft Entra ID Impossible Travel Sign-in

Last updated 2 months ago on 2026-05-15
Created 2 months ago on 2026-05-15

About

Detects successful Microsoft Entra ID interactive sign-ins for the same user from two geographically separated locations within a 90-minute window, where the implied travel speed between the two points exceeds what is physically possible (>=800 km/h, faster than modern commercial airliners) and the geographic separation is at least 500 km. This pattern indicates either VPN/proxy use or an adversary signing in to a compromised account from a different location than the legitimate user. Non-interactive sign-in categories are excluded because backend token refresh activity routinely egresses through cloud regions unrelated to the user. This activity is often observed from AiTM phishing kits or successful phishing campaigns.
Tags
Domain: CloudDomain: IdentityData Source: AzureData Source: Microsoft Entra IDData Source: Microsoft Entra ID Sign-in LogsUse Case: Threat DetectionUse Case: Identity and Access AuditTactic: Initial AccessTactic: Credential AccessLanguage: esql
Severity
high
Risk Score
73
MITRE ATT&CK™

Initial Access (TA0001)(external, opens in a new tab or window)

Credential Access (TA0006)(external, opens in a new tab or window)

False Positive Examples
Users on VPN or proxy egress that geo-resolves through a region distant from the user's physical location. Mobile clients on cellular carrier networks that peer through regional hubs may geo-resolve to a different region than the user's physical location. Corporate AWS Workspaces / VDI deployments where employees interactively sign in from a cloud-provider ASN.
License
Elastic License v2(external, opens in a new tab or window)

Definition

Integration Pack
Prebuilt Security Detection Rules
Related Integrations

azure(external, opens in a new tab or window)

Query
text code block:
// successful interactive sign-ins with country + region populated. from logs-azure.signinlogs-* | where data_stream.dataset == "azure.signinlogs" and event.outcome == "success" and azure.signinlogs.category == "SignInLogs" and azure.signinlogs.properties.user_principal_name is not null and source.geo.location is not null and source.geo.region_name is not null | eval Esql.source_geo_lat = st_y(source.geo.location), Esql.source_geo_lon = st_x(source.geo.location) // collapse each (user, country, region) into one centroid + the actual lat/lon // of the first and last event in that region. FIRST/LAST lock coords to the // timestamp ordering so we can later build the honest event pair. | stats Esql.region_centroid_lat = avg(Esql.source_geo_lat), Esql.region_centroid_lon = avg(Esql.source_geo_lon), Esql.region_first_lat = first(Esql.source_geo_lat, @timestamp), Esql.region_first_lon = first(Esql.source_geo_lon, @timestamp), Esql.region_last_lat = last(Esql.source_geo_lat, @timestamp), Esql.region_last_lon = last(Esql.source_geo_lon, @timestamp), Esql.region_first_seen = min(@timestamp), Esql.region_last_seen = max(@timestamp), Esql.region_event_count = count(*), Esql.region_city_values = values(source.geo.city_name), Esql.region_asn_values = values(source.`as`.organization.name), Esql.region_ip_values = values(source.ip), Esql.region_ua_values = values(user_agent.original), Esql.region_app_id_values = values(azure.signinlogs.properties.app_id), Esql.region_app_display_name_values = values(azure.signinlogs.properties.app_display_name), Esql.region_client_app_used_values = values(azure.signinlogs.properties.client_app_used), Esql.region_resource_id_values = values(azure.signinlogs.properties.resource_id), Esql.region_resource_display_name_values = values(azure.signinlogs.properties.resource_display_name), Esql.region_browser_values = values(azure.signinlogs.properties.device_detail.browser), Esql.region_os_values = values(azure.signinlogs.properties.device_detail.operating_system) by azure.signinlogs.properties.user_principal_name, source.geo.country_name, source.geo.region_name // roll up to the user. two parallel measurements: // bbox: corners over region centroids. catches A->B->A because B is still // a centroid in the set even when first/last events are in A. | stats Esql.min_lat = min(Esql.region_centroid_lat), Esql.max_lat = max(Esql.region_centroid_lat), Esql.min_lon = min(Esql.region_centroid_lon), Esql.max_lon = max(Esql.region_centroid_lon), Esql.honest_first_lat = first(Esql.region_first_lat, Esql.region_first_seen), Esql.honest_first_lon = first(Esql.region_first_lon, Esql.region_first_seen), Esql.honest_last_lat = last(Esql.region_last_lat, Esql.region_last_seen), Esql.honest_last_lon = last(Esql.region_last_lon, Esql.region_last_seen), Esql.timestamp_first_seen = min(Esql.region_first_seen), Esql.timestamp_last_seen = max(Esql.region_first_seen), // first arrival in last region > tighter bbox window Esql.honest_last_time = max(Esql.region_last_seen), // user's actual last event > honest window Esql.region_count = count_distinct(source.geo.region_name), Esql.country_count = count_distinct(source.geo.country_name), Esql.event_count = sum(Esql.region_event_count), Esql.source_geo_country_name_values = values(source.geo.country_name), Esql.source_geo_region_name_values = values(source.geo.region_name), Esql.source_geo_city_name_values = values(Esql.region_city_values), Esql.source_as_organization_name_values = values(Esql.region_asn_values), Esql.source_ip_values = values(Esql.region_ip_values), Esql.user_agent_original_values = values(Esql.region_ua_values), Esql.app_id_values = values(Esql.region_app_id_values), Esql.app_display_name_values = values(Esql.region_app_display_name_values), Esql.client_app_used_values = values(Esql.region_client_app_used_values), Esql.resource_id_values = values(Esql.region_resource_id_values), Esql.resource_display_name_values = values(Esql.region_resource_display_name_values), Esql.device_detail_browser_values = values(Esql.region_browser_values), Esql.device_detail_operating_system_values = values(Esql.region_os_values) by azure.signinlogs.properties.user_principal_name // need at least 2 regions to have anything to compare. cap at 5 because regions // are finer-grained than countries (a traveling employee can hit 3-4 in 90m via // carrier hub bouncing) > bbox drift stays bounded below this. | where Esql.region_count >= 2 and Esql.region_count <= 5 // bbox path (primary trigger): corners over region centroids. | eval Esql.p1 = to_geopoint(concat("POINT(", to_string(Esql.min_lon), " ", to_string(Esql.min_lat), ")")), Esql.p2 = to_geopoint(concat("POINT(", to_string(Esql.max_lon), " ", to_string(Esql.max_lat), ")")) | eval Esql.distance_km = round(st_distance(Esql.p1, Esql.p2) / 1000.0, 0), Esql.window_minutes = date_diff("minute", Esql.timestamp_first_seen, Esql.timestamp_last_seen), Esql.travel_kmh = case(Esql.window_minutes > 0, round(Esql.distance_km * 60.0 / Esql.window_minutes, 0), null) // honest pair (triage signal): real coords at the user's actual first and last // events, time locked to those same two events | eval Esql.honest_p1 = to_geopoint(concat("POINT(", to_string(Esql.honest_first_lon), " ", to_string(Esql.honest_first_lat), ")")), Esql.honest_p2 = to_geopoint(concat("POINT(", to_string(Esql.honest_last_lon), " ", to_string(Esql.honest_last_lat), ")")) | eval Esql.honest_distance_km = round(st_distance(Esql.honest_p1, Esql.honest_p2) / 1000.0, 0), Esql.honest_window_minutes = date_diff("minute", Esql.timestamp_first_seen, Esql.honest_last_time), Esql.honest_travel_kmh = case(Esql.honest_window_minutes > 0, round(Esql.honest_distance_km * 60.0 / Esql.honest_window_minutes, 0), null) // 500 km separation + faster than a commercial airliner. bbox is the trigger // purely as triage signal. | where Esql.distance_km >= 500 and Esql.travel_kmh >= 800 | keep azure.signinlogs.properties.user_principal_name, Esql.source_geo_country_name_values, Esql.source_geo_region_name_values, Esql.source_geo_city_name_values, Esql.source_as_organization_name_values, Esql.source_ip_values, Esql.user_agent_original_values, Esql.app_id_values, Esql.app_display_name_values, Esql.client_app_used_values, Esql.resource_id_values, Esql.resource_display_name_values, Esql.device_detail_browser_values, Esql.device_detail_operating_system_values, Esql.country_count, Esql.region_count, Esql.event_count, Esql.timestamp_first_seen, Esql.timestamp_last_seen, Esql.window_minutes, Esql.distance_km, Esql.travel_kmh, Esql.honest_distance_km, Esql.honest_travel_kmh, Esql.honest_window_minutes

Install detection rules in Elastic Security

Detect Microsoft Entra ID Impossible Travel Sign-in in the Elastic Security detection engine by installing this rule into your Elastic Stack.

To setup this rule, check out the installation guide for Prebuilt Security Detection Rules(external, opens in a new tab or window).