Impossible travel detection without VPN false positives
A practical guide for developers building or tuning sign-in anomaly detection in an XDR pipeline. It shows how to compute impossible travel and related sign-in risk signals without paging on every corporate VPN exit node, mobile carrier NAT hop, or IPv6 privacy address.
TL;DR — Impossible-travel alerts are only useful if you score them as one signal among several and suppress known network artifacts like corporate VPN egress, mobile carrier NAT, IPv6 prefix churn, and identity-provider session reuse. The single biggest fix is to stop comparing raw IP-to-IP hops blindly: normalize sign-ins into actor + session + network context, classify trusted egress, then score travel speed only when the session and device evidence say the user actually moved. Reading time: ~7 min
What it is and where it sits
Impossible travel detection answers a narrow question: given two sign-ins for the same actor, is the implied movement physically implausible for the elapsed time? In an XDR pipeline, it sits after authentication telemetry collection and before response automation. It is not an auth control by itself; it is a detector that enriches sign-in events with risk and sends findings to your SIEM, case system, or response worker.
What talks to it:
- Identity providers and apps emitting sign-in events: OIDC/SAML IdP logs, app auth logs, VPN concentrator logs, reverse proxy access logs.
- Asset and identity context stores: device inventory, user directory, known corporate egress IP ranges, ASN allowlists.
- GeoIP and network enrichment: IP-to-lat/lon, ASN, hosting-provider classification, VPN/proxy intelligence if you have it.
- Downstream XDR/SIEM/queue consumers: alerting, step-up MFA, session revocation, analyst triage.
What it replaces:
- Naive "same user, different country within 1 hour" rules.
- Per-product impossible-travel alerts that do not understand your VPN topology or app session semantics.
- Manual analyst correlation across auth logs and proxy logs.
Where it lives in the flow:
- Usually in your detection/enrichment pipeline, not inline on the login request path.
- Optionally, a lightweight inline policy engine can consume the detector's output to trigger step-up MFA on the next request.
View as text
[User Device] ---- 1. sign-in ----> [App or IdP]
|
| 2. auth event
v
[Event Stream] ---- 3. enrich IP/ASN/device ----> [Detector]
|
| 4. risk score
v
[XDR/SIEM]
|
| 5. action
v
[MFA/Session Control]The architectural point that matters: impossible travel should consume normalized events, not raw web logs. If you feed it only user, ip, timestamp, it will drown in false positives. Feed it user, session_id, auth_method, device_id, source_ip, x_forwarded_for_chain, ASN, geo, vpn_classification, user_agent_hash, result, and it becomes useful.
How it actually works
The mechanism is straightforward but the quality comes from the suppressions.
- Ingest sign-in events and normalize fields.
- Resolve source IP correctly, especially behind proxies. Pick one canonical client IP extraction path and stick to it.
- Enrich with GeoIP, ASN, hosting/mobile/residential classification, device identity, and whether the IP belongs to your own egress ranges.
- For each successful sign-in, fetch the previous successful sign-in for the same actor, usually within a bounded lookback like 7-30 days.
- Compute distance and implied speed.
- Apply suppressions and score modifiers:
- Same session token or refresh-token family reused through a different edge? Lower score.
- Same managed device and same browser fingerprint? Lower score.
- Source IP in corporate VPN or ZTNA egress CIDRs? Suppress or heavily discount.
- Mobile ASN or CGNAT-heavy carrier? Lower confidence.
- IPv6 privacy address changed but
/48or/56prefix stayed stable? Treat as same network neighborhood. - Prior event was impossible itself or low-confidence geo? Do not chain bad data into a new alert.
- Emit a finding only if the total score crosses threshold, ideally with reasons attached.
One realistic end-to-end example
User alice@example.com signs in at 09:02 UTC from a laptop on home broadband in Berlin. At 09:18 UTC there is another successful sign-in that appears from New York.
Raw events after normalization:
{"user":"alice@example.com","ts":"2026-10-30T09:02:11Z","result":"success","source_ip":"91.23.44.18","session_id":"s_7f3","device_id":"mdm-8c1d","ua_hash":"ua_2ab1","auth_method":"webauthn","asn":3320,"geo":{"lat":52.52,"lon":13.40,"country":"DE","city":"Berlin"},"network_type":"residential","trusted_egress":false}
{"user":"alice@example.com","ts":"2026-10-30T09:18:44Z","result":"success","source_ip":"198.51.100.24","session_id":"s_9aa","device_id":"mdm-8c1d","ua_hash":"ua_2ab1","auth_method":"refresh_token","asn":64512,"geo":{"lat":40.71,"lon":-74.00,"country":"US","city":"New York"},"network_type":"hosting","trusted_egress":true,"trusted_egress_name":"corp-vpn-us-east"}
Distance Berlin↔New York is roughly 6,385 km. Over 16.5 minutes, implied speed is absurd. A naive rule alerts.
A useful detector does not, because:
device_idis identical.ua_hashis identical.- Second event uses
refresh_token, not a fresh interactive login. - The second IP is in a CIDR you tagged as
trusted_egress=truebecause it is your corporate VPN exit.
So the score might look like:
- Base impossible-travel score:
80 - Hosting network:
+10 - Same managed device:
-25 - Same UA hash:
-10 - Refresh-token reuse:
-20 - Trusted corporate egress:
-60 - Final:
0→ no alert, maybe retain as low-risk telemetry.
Now change one field: the second event is a password login from an unmanaged device, different UA hash, residential ASN in New York, and not a trusted egress. Then the same travel computation becomes meaningful and should likely trigger step-up MFA or analyst review.
The key design choice is that impossible travel is not a binary detector. It is a feature in a risk model.
When to use it (and when not to)
Use it when you already have enough identity and network context to explain normal movement. Do not start here if your auth telemetry is still messy.
| Scenario | Recommendation |
|---|---|
| Centralized IdP logs, device inventory, known VPN/ZTNA egress ranges | Use impossible travel as a scored signal |
| Mostly workforce identities, managed devices, strong MFA | Use it; tune aggressively to suppress trusted egress |
| Consumer app with sparse device identity and lots of mobile users | Use cautiously; weight ASN/device/session more than geo |
Only raw app logs with user, ip, ts and no proxy normalization | Do not use yet; fix telemetry first |
| Users commonly roam through mobile networks and privacy relays | Prefer new-device, impossible-ASN, and session anomalies over travel speed |
| You want an inline block on every suspicious login | Usually no; use step-up MFA, not hard deny, unless confidence is very high |
You probably don't need this if:
- Your environment is small enough that new-device plus MFA fatigue detection catches nearly all account misuse.
- Most sign-ins come through one or two fixed enterprise egress points, making geo nearly meaningless.
- You cannot reliably distinguish client IP from proxy IP.
Trade-offs
Every benefit has a cost.
- Better detection of stolen-session or stolen-credential use across regions
- Cost: enrichment infrastructure, GeoIP data quality issues, and tuning time.
- Fewer analyst hours than naive country-change alerts
- Cost: you must maintain trusted egress CIDRs, ASN exceptions, and mobile-network heuristics.
- Useful risk signal for step-up MFA
- Cost: if you run it inline, every enrichment dependency adds latency and failure modes.
- Good XDR correlation with device and session evidence
- Cost: schema discipline. If teams emit different user IDs or session IDs, scoring degrades fast.
- Reduced VPN false positives
- Cost: operational burden of tagging every corporate VPN, proxy, ZTNA connector, and cloud NAT range you own.
- Vendor independence if you build it yourself
- Cost: lock-in shifts from product to your own data model and tuning logic; you now own the detector.
Two edge cases experienced teams ask about:
- IPv6: comparing full addresses is noisy because privacy extensions rotate. Compare stable prefixes where appropriate and keep separate logic for v4/v6.
- Session reuse: many "sign-ins" are token refreshes or federated assertions, not fresh human logins. Treat them differently or you'll alert on backend token plumbing.
In practice
Example 1: Postgres query to compute candidate impossible-travel pairs
This query finds consecutive successful sign-ins per user, computes elapsed time, and uses the earthdistance extension for rough distance. It is a starting point for offline tuning, not the final detector.
⚠️
CREATE EXTENSIONrequires elevated privileges and changes the database. Run it in a non-production clone first if your DB change process is strict.
CREATE EXTENSION IF NOT EXISTS cube;
CREATE EXTENSION IF NOT EXISTS earthdistance;
WITH ordered AS (
SELECT
user_id,
ts,
source_ip,
session_id,
device_id,
ua_hash,
trusted_egress,
network_type,
ll_to_earth(lat, lon) AS loc,
LAG(ts) OVER (PARTITION BY user_id ORDER BY ts) AS prev_ts,
LAG(source_ip) OVER (PARTITION BY user_id ORDER BY ts) AS prev_ip,
LAG(session_id) OVER (PARTITION BY user_id ORDER BY ts) AS prev_session_id,
LAG(device_id) OVER (PARTITION BY user_id ORDER BY ts) AS prev_device_id,
LAG(ua_hash) OVER (PARTITION BY user_id ORDER BY ts) AS prev_ua_hash,
LAG(ll_to_earth(lat, lon)) OVER (PARTITION BY user_id ORDER BY ts) AS prev_loc
FROM signins
WHERE result = 'success'
)
SELECT
user_id,
prev_ts,
ts,
prev_ip,
source_ip,
ROUND((EXTRACT(EPOCH FROM (ts - prev_ts)) / 60.0)::numeric, 1) AS minutes,
ROUND((earth_distance(prev_loc, loc) / 1000.0)::numeric, 1) AS km,
CASE
WHEN EXTRACT(EPOCH FROM (ts - prev_ts)) > 0
THEN ROUND(((earth_distance(prev_loc, loc) / 1000.0) / (EXTRACT(EPOCH FROM (ts - prev_ts)) / 3600.0))::numeric, 1)
ELSE NULL
END AS kmh,
trusted_egress,
network_type,
(device_id = prev_device_id) AS same_device,
(ua_hash = prev_ua_hash) AS same_ua,
(session_id = prev_session_id) AS same_session
FROM ordered
WHERE prev_ts IS NOT NULL
AND ts - prev_ts < INTERVAL '24 hours'
AND earth_distance(prev_loc, loc) > 500000
ORDER BY ts DESC
LIMIT 50;
Gotcha: this will still overcount false positives if your lat/lon comes from low-quality GeoIP or if sign-ins are token refreshes. Add event_type = 'interactive_login' or equivalent if your schema supports it.
Example 2: Python scoring function with VPN suppression and IPv6 prefix handling
This example shows the detector logic you can drop into a stream processor or batch job. It uses ipaddress for CIDR checks and keeps the score explainable.
from ipaddress import ip_address, ip_network
from math import radians, sin, cos, sqrt, atan2
TRUSTED_EGRESS = [
ip_network("198.51.100.0/24"),
ip_network("203.0.113.0/24"),
ip_network("2001:db8:100::/48"),
]
MOBILE_ASNS = {21928, 7922, 22394}
def haversine_km(a, b):
r = 6371.0
lat1, lon1, lat2, lon2 = map(radians, [a[0], a[1], b[0], b[1]])
dlat = lat2 - lat1
dlon = lon2 - lon1
x = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
return 2 * r * atan2(sqrt(x), sqrt(1 - x))
def is_trusted_egress(ip):
addr = ip_address(ip)
return any(addr in net for net in TRUSTED_EGRESS)
def same_ipv6_neighborhood(ip1, ip2, prefix=56):
a1, a2 = ip_address(ip1), ip_address(ip2)
if a1.version != 6 or a2.version != 6:
return False
n1 = ip_network(f"{a1}/{prefix}", strict=False)
n2 = ip_network(f"{a2}/{prefix}", strict=False)
return n1.network_address == n2.network_address
def score_impossible_travel(prev_evt, evt):
reasons = []
score = 0
km = haversine_km((prev_evt["lat"], prev_evt["lon"]), (evt["lat"], evt["lon"]))
hours = max((evt["ts"] - prev_evt["ts"]).total_seconds() / 3600.0, 1 / 3600.0)
kmh = km / hours
if km > 500 and kmh > 900:
score += 80
reasons.append(f"impossible_travel km={km:.1f} kmh={kmh:.1f}")
if is_trusted_egress(evt["source_ip"]):
score -= 60
reasons.append("trusted_egress")
if evt.get("asn") in MOBILE_ASNS:
score -= 20
reasons.append("mobile_asn")
if evt.get("device_id") and evt.get("device_id") == prev_evt.get("device_id"):
score -= 25
reasons.append("same_device")
if evt.get("ua_hash") == prev_evt.get("ua_hash"):
score -= 10
reasons.append("same_ua")
if evt.get("session_id") == prev_evt.get("session_id"):
score -= 30
reasons.append("same_session")
if same_ipv6_neighborhood(prev_evt["source_ip"], evt["source_ip"]):
score -= 30
reasons.append("same_ipv6_prefix")
return max(score, 0), reasons
Gotcha: do not hardcode public VPN or mobile ASN lists and forget them. Put them in data files or a table with an update process, or your tuning will silently rot.
Example 3: nginx logging to preserve the real client IP for downstream auth telemetry
If your app sits behind a load balancer or CDN, bad client IP extraction will poison the detector. This nginx snippet trusts only your proxy ranges and logs both the resolved client and the forwarding chain.
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 192.168.0.0/16;
set_real_ip_from 203.0.113.0/24;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
log_format auth_json escape=json
'{"ts":"$time_iso8601","remote_addr":"$remote_addr",'
'"realip_remote_addr":"$realip_remote_addr",'
'"xff":"$http_x_forwarded_for","request":"$request",'
'"status":$status,"request_id":"$request_id"}';
access_log /var/log/nginx/auth_access.log auth_json;
Gotcha: if you trust X-Forwarded-For from the public internet, attackers can spoof client IPs and generate fake travel anomalies. Only set set_real_ip_from for actual upstream proxies you control.
Further reading
- OWASP Cheat Sheet Series: Session Management Cheat Sheet
- NIST SP 800-63B Digital Identity Guidelines: Authentication and Lifecycle Management
- PostgreSQL documentation for
earthdistanceandcube - nginx documentation for
ngx_http_realip_module - The "HTTP access logging" and proxy sections of the MDN HTTP docs
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI