Microsoft Foundry Subscription Key Impossible Travel


Description

Detects the same API Management subscription key calling Microsoft Foundry from source IPs in two or more countries, far enough apart and fast enough that the hop is not physical travel. Calls are grouped by apim_subscription_id. Requests with no subscription key are ignored, because those are unauthenticated calls and share no key.

Query · esql

from logs-azure_ai_foundry.logs-*
| where
    data_stream.dataset == "azure_ai_foundry.logs" and
    azure.ai_foundry.category == "GatewayLogs" and
    azure.ai_foundry.properties.apim_subscription_id is not null and
    source.ip is not null and
    source.geo.location is not null and
    source.geo.country_name is not null
| eval
    Esql.source_geo_lat = st_y(source.geo.location),
    Esql.source_geo_lon = st_x(source.geo.location)
| where Esql.source_geo_lat is not null and Esql.source_geo_lon is not null
| stats
    Esql.first_lat = first(Esql.source_geo_lat, @timestamp),
    Esql.first_lon = first(Esql.source_geo_lon, @timestamp),
    Esql.last_lat = last(Esql.source_geo_lat, @timestamp),
    Esql.last_lon = last(Esql.source_geo_lon, @timestamp),
    Esql.event_count = count(*),
    Esql.country_count = count_distinct(source.geo.country_name),
    Esql.source_ip_values = values(source.ip),
    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(source.geo.city_name),
    Esql.source_as_organization_name_values = values(source.as.organization.name),
    Esql.azure_ai_foundry_properties_user_agent_values = values(azure.ai_foundry.properties.user_agent),
    Esql.azure_ai_foundry_properties_backend_response_code_values = values(azure.ai_foundry.properties.backend_response_code),
    Esql.azure_ai_foundry_properties_apim_subscription_id = min(azure.ai_foundry.properties.apim_subscription_id),
    Esql.timestamp_first_seen = min(@timestamp),
    Esql.timestamp_last_seen = max(@timestamp)
  by
    azure.ai_foundry.properties.apim_subscription_id,
    azure.ai_foundry.properties.api_id,
    azure.ai_foundry.properties.operation_id,
    azure.resource.name,
    azure.resource.group,
    url.domain,
    url.path
| where Esql.event_count >= 2 and Esql.country_count >= 2
| eval
    Esql.p1 = to_geopoint(concat("POINT(", to_string(Esql.first_lon), " ", to_string(Esql.first_lat), ")")),
    Esql.p2 = to_geopoint(concat("POINT(", to_string(Esql.last_lon), " ", to_string(Esql.last_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),
    source.ip = MV_FIRST(Esql.source_ip_values)
| where Esql.distance_km >= 500 and Esql.travel_kmh >= 800
| keep
    azure.ai_foundry.properties.apim_subscription_id,
    azure.ai_foundry.properties.api_id,
    azure.ai_foundry.properties.operation_id,
    azure.resource.name,
    azure.resource.group,
    url.domain,
    url.path,
    source.ip,
    Esql.*

Investigation fields

Pivot points the source recommends for triage.

  • azure.ai_foundry.properties.apim_subscription_id
  • azure.ai_foundry.properties.api_id
  • azure.ai_foundry.properties.operation_id
  • azure.resource.name
  • azure.resource.group
  • url.domain
  • url.path
  • Esql.distance_km
  • Esql.travel_kmh
  • Esql.window_minutes
  • Esql.country_count
  • Esql.event_count
  • Esql.source_ip_values
  • 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.azure_ai_foundry_properties_user_agent_values
  • Esql.azure_ai_foundry_properties_backend_response_code_values
  • Esql.first_lat
  • Esql.first_lon
  • Esql.last_lat
  • Esql.last_lon
  • Esql.timestamp_first_seen
  • Esql.timestamp_last_seen

Implementation guide

This rule needs the Microsoft Foundry integration collecting Azure API Management GatewayLogs with the client IP. The subscription key name is apim_subscription_id, and that is the value the rule groups on. Foundry RequestResponse logs do not record the key, and the caller address there is masked.

  • Use an API Management tier that emits resource logs. Developer or higher works. Consumption does not.
  • Send the GatewayLogs category to the Event Hub the integration reads.
  • Log the client IP in API diagnostics so source.ip and source.geo are on the event. The rule uses source.geo.location for the distance.

https://www.elastic.co/docs/reference/integrations/azure_ai_foundry

Known false positives

  • A subscription key used from a VPN, a cloud VM, or a corporate proxy whose egress geo is far from the developer's network. A home ISP and a cloud build agent on the same key look like impossible travel.
  • One shared subscription key in front of users in different countries. Split that key per application, or raise the distance and speed thresholds in the query.

Analyst notes

Investigating Microsoft Foundry Subscription Key Impossible Travel

One API Management subscription key was used from source IPs in at least two countries, and the first and last locations in the lookback are too far apart for the time between them. The alert is grouped by that subscription, the API, the operation, the API Management resource, and the URL. IP, country, ASN, and user agent stay as value lists because those change between the two locations. Distance and speed thresholds are in the query.

A return to the first country can keep the first and last points close, so the alert may not fire. Sort the raw GatewayLogs for that subscription before closing it.

Possible investigation steps

  • Read Esql.distance_km, Esql.travel_kmh, Esql.window_minutes, and the country, city, IP, and ASN lists. Two countries and a high speed is the case this rule is for.
  • Compare user agents across the IPs. A browser or script on the distant IP that does not match the usual client for that subscription is the higher priority hop.
  • Confirm the distant IP is not an approved egress for this subscription: a build VM, a NAT, or a second office.
  • Look for other GatewayLogs on the same subscription in the same window: refusals, HTTP 400 jailbreak blocks, or a jump in call volume.

False positive analysis

  • VPN and cloud egress. The second IP often belongs to the cloud provider's ASN rather than a residential ISP.
  • A shared key used by design from more than one country. Exclude that subscription or raise the thresholds in the query.

Response and remediation

  • If the distant IP is not approved, regenerate the API Management subscription key and update the applications that use it.
  • Review Foundry calls for that subscription after the distant event for prompts, refusals, and token volume.
Raw source Microsoft Foundry Subscription Key Impossible Travel · Elastic TOML
Esc
Published by elastic/detection-rules ↗, licensed under Elastic License 2.0 ↗. Reproduced here unmodified.
[metadata]
creation_date = "2026/10/01"
integration = ["azure_ai_foundry"]
maturity = "production"
min_stack_version = "9.4.0"
min_stack_comments = "The Microsoft Foundry integration is compatible with 9.3 and above. ES|QL FIRST/LAST aggregations and st_distance require 9.4.0+."
updated_date = "2026/10/05"

[rule]
author = ["Elastic"]
description = """
Detects the same API Management subscription key calling Microsoft Foundry from source IPs in two or more countries,
far enough apart and fast enough that the hop is not physical travel. Calls are grouped by apim_subscription_id.
Requests with no subscription key are ignored, because those are unauthenticated calls and share no key.
"""
false_positives = [
    """
    A subscription key used from a VPN, a cloud VM, or a corporate proxy whose egress geo is far from the developer's
    network. A home ISP and a cloud build agent on the same key look like impossible travel.
    """,
    """
    One shared subscription key in front of users in different countries. Split that key per application, or raise the
    distance and speed thresholds in the query.
    """,
]
from = "now-4h"
language = "esql"
license = "Elastic License v2"
name = "Microsoft Foundry Subscription Key Impossible Travel"
note = """## Triage and analysis

### Investigating Microsoft Foundry Subscription Key Impossible Travel

One API Management subscription key was used from source IPs in at least two countries, and the first and last
locations in the lookback are too far apart for the time between them. The alert is grouped by that subscription,
the API, the operation, the API Management resource, and the URL. IP, country, ASN, and user agent stay as value
lists because those change between the two locations. Distance and speed thresholds are in the query.

A return to the first country can keep the first and last points close, so the alert may not fire. Sort the raw
GatewayLogs for that subscription before closing it.

#### Possible investigation steps

- Read Esql.distance_km, Esql.travel_kmh, Esql.window_minutes, and the country, city, IP, and ASN lists. Two countries
  and a high speed is the case this rule is for.
- Compare user agents across the IPs. A browser or script on the distant IP that does not match the usual client for
  that subscription is the higher priority hop.
- Confirm the distant IP is not an approved egress for this subscription: a build VM, a NAT, or a second office.
- Look for other GatewayLogs on the same subscription in the same window: refusals, HTTP 400 jailbreak blocks, or a
  jump in call volume.

### False positive analysis

- VPN and cloud egress. The second IP often belongs to the cloud provider's ASN rather than a residential ISP.
- A shared key used by design from more than one country. Exclude that subscription or raise the thresholds in the
  query.

### Response and remediation

- If the distant IP is not approved, regenerate the API Management subscription key and update the applications that
  use it.
- Review Foundry calls for that subscription after the distant event for prompts, refusals, and token volume.
"""
references = [
    "https://www.elastic.co/docs/reference/integrations/azure_ai_foundry",
    "https://learn.microsoft.com/en-us/azure/api-management/diagnostic-logs-reference",
]
risk_score = 47
rule_id = "49fcbc29-675b-487e-88a2-655ec067a1cc"
setup = """## Setup

This rule needs the Microsoft Foundry integration collecting Azure API Management GatewayLogs with the client IP.
The subscription key name is apim_subscription_id, and that is the value the rule groups on. Foundry
RequestResponse logs do not record the key, and the caller address there is masked.

- Use an API Management tier that emits resource logs. Developer or higher works. Consumption does not.
- Send the GatewayLogs category to the Event Hub the integration reads.
- Log the client IP in API diagnostics so source.ip and source.geo are on the event. The rule uses source.geo.location for the distance.

https://www.elastic.co/docs/reference/integrations/azure_ai_foundry
"""
severity = "medium"
tags = [
    "Data Source: Microsoft Foundry",
    "Use Case: Threat Detection",
    "Mitre Atlas: AML.T0091",
    "Mitre Atlas: AML.T0012",
    "Resources: Investigation Guide",
    "Tactic: Credential Access",
    "Tactic: Initial Access",
    "Rule Type: ES|QL",
    "Platform: Azure",
    "Domain: Cloud",
    "Domain: GenAI",
    "Service: Azure API Management",
    "Threat: Impossible Travel"
]
timestamp_override = "event.ingested"
type = "esql"

query = '''
from logs-azure_ai_foundry.logs-*
| where
    data_stream.dataset == "azure_ai_foundry.logs" and
    azure.ai_foundry.category == "GatewayLogs" and
    azure.ai_foundry.properties.apim_subscription_id is not null and
    source.ip is not null and
    source.geo.location is not null and
    source.geo.country_name is not null
| eval
    Esql.source_geo_lat = st_y(source.geo.location),
    Esql.source_geo_lon = st_x(source.geo.location)
| where Esql.source_geo_lat is not null and Esql.source_geo_lon is not null
| stats
    Esql.first_lat = first(Esql.source_geo_lat, @timestamp),
    Esql.first_lon = first(Esql.source_geo_lon, @timestamp),
    Esql.last_lat = last(Esql.source_geo_lat, @timestamp),
    Esql.last_lon = last(Esql.source_geo_lon, @timestamp),
    Esql.event_count = count(*),
    Esql.country_count = count_distinct(source.geo.country_name),
    Esql.source_ip_values = values(source.ip),
    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(source.geo.city_name),
    Esql.source_as_organization_name_values = values(source.as.organization.name),
    Esql.azure_ai_foundry_properties_user_agent_values = values(azure.ai_foundry.properties.user_agent),
    Esql.azure_ai_foundry_properties_backend_response_code_values = values(azure.ai_foundry.properties.backend_response_code),
    Esql.azure_ai_foundry_properties_apim_subscription_id = min(azure.ai_foundry.properties.apim_subscription_id),
    Esql.timestamp_first_seen = min(@timestamp),
    Esql.timestamp_last_seen = max(@timestamp)
  by
    azure.ai_foundry.properties.apim_subscription_id,
    azure.ai_foundry.properties.api_id,
    azure.ai_foundry.properties.operation_id,
    azure.resource.name,
    azure.resource.group,
    url.domain,
    url.path
| where Esql.event_count >= 2 and Esql.country_count >= 2
| eval
    Esql.p1 = to_geopoint(concat("POINT(", to_string(Esql.first_lon), " ", to_string(Esql.first_lat), ")")),
    Esql.p2 = to_geopoint(concat("POINT(", to_string(Esql.last_lon), " ", to_string(Esql.last_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),
    source.ip = MV_FIRST(Esql.source_ip_values)
| where Esql.distance_km >= 500 and Esql.travel_kmh >= 800
| keep
    azure.ai_foundry.properties.apim_subscription_id,
    azure.ai_foundry.properties.api_id,
    azure.ai_foundry.properties.operation_id,
    azure.resource.name,
    azure.resource.group,
    url.domain,
    url.path,
    source.ip,
    Esql.*
'''


[[rule.threat]]
framework = "MITRE ATT&CK"
[[rule.threat.technique]]
id = "T1528"
name = "Steal Application Access Token"
reference = "https://attack.mitre.org/techniques/T1528/"


[rule.threat.tactic]
id = "TA0006"
name = "Credential Access"
reference = "https://attack.mitre.org/tactics/TA0006/"

[[rule.threat]]
framework = "MITRE ATT&CK"
[[rule.threat.technique]]
id = "T1078"
name = "Valid Accounts"
reference = "https://attack.mitre.org/techniques/T1078/"
[[rule.threat.technique.subtechnique]]
id = "T1078.004"
name = "Cloud Accounts"
reference = "https://attack.mitre.org/techniques/T1078/004/"


[rule.threat.tactic]
id = "TA0001"
name = "Initial Access"
reference = "https://attack.mitre.org/tactics/TA0001/"

[[rule.threat_mappings]]
framework = "MITRE ATLAS"
version = "2026.08"
[[rule.threat_mappings.threat]]
framework = "MITRE ATLAS"
[[rule.threat_mappings.threat.technique]]
id = "AML.T0091"
name = "Use Alternate Authentication Material"
reference = "https://atlas.mitre.org/techniques/AML.T0091/"
[[rule.threat_mappings.threat.technique.subtechnique]]
id = "AML.T0091.000"
name = "Application Access Token"
reference = "https://atlas.mitre.org/techniques/AML.T0091.000/"


[rule.threat_mappings.threat.tactic]
id = "AML.TA0015"
name = "Lateral Movement"
reference = "https://atlas.mitre.org/tactics/AML.TA0015/"
[[rule.threat_mappings.threat]]
framework = "MITRE ATLAS"
[[rule.threat_mappings.threat.technique]]
id = "AML.T0012"
name = "Valid Accounts"
reference = "https://atlas.mitre.org/techniques/AML.T0012/"


[rule.threat_mappings.threat.tactic]
id = "AML.TA0004"
name = "Initial Access"
reference = "https://atlas.mitre.org/tactics/AML.TA0004/"

[rule.alert_suppression]
group_by = ["source.ip"]
duration = {value = 4, unit = "h"}
missing_fields_strategy = "suppress"

[rule.investigation_fields]
field_names = [
    "azure.ai_foundry.properties.apim_subscription_id",
    "azure.ai_foundry.properties.api_id",
    "azure.ai_foundry.properties.operation_id",
    "azure.resource.name",
    "azure.resource.group",
    "url.domain",
    "url.path",
    "Esql.distance_km",
    "Esql.travel_kmh",
    "Esql.window_minutes",
    "Esql.country_count",
    "Esql.event_count",
    "Esql.source_ip_values",
    "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.azure_ai_foundry_properties_user_agent_values",
    "Esql.azure_ai_foundry_properties_backend_response_code_values",
    "Esql.first_lat",
    "Esql.first_lon",
    "Esql.last_lat",
    "Esql.last_lon",
    "Esql.timestamp_first_seen",
    "Esql.timestamp_last_seen",
]

Detection rules belong to the projects that publish them and remain under their own licenses. This site indexes and links to them; it claims no rights in them.