DEV Community

Cover image for Build Impossible Travel Detection Into Your Login Flow
ABDULLAH AFZAL
ABDULLAH AFZAL

Posted on

Build Impossible Travel Detection Into Your Login Flow

Impossible travel is two logins to one account from places too far apart to travel between in the time that passed. New York at 9:00, Singapore at 9:40. No passenger makes that trip, so one of those sessions is almost certainly not the account owner. You can catch it in your own login flow with an IP lookup, the Haversine formula, and a velocity check, and this walks through the whole build in Node and Python.

It earns its place because login endpoints are under constant credential pressure. In Verizon's 2025 DBIR research on credential stuffing, credential stuffing was a median 19% of all authentication attempts across the SSO logs they analyzed, and stolen credentials were the initial access vector in 22% of breaches. Impossible travel is one of the cheapest ways to catch the attempts that actually succeed.

TL;DR:

  • Impossible travel flags a login when distance from the previous login divided by the time between them implies a speed no human travel can reach.
  • The build is small: geolocate the IP to coordinates, measure great-circle distance with the Haversine formula, divide by elapsed time, compare to a threshold around 1000 km/h.
  • Store each user's last login location and timestamp (Redis works well) so the next login has something to compare against.
  • The velocity check alone fires on every corporate-VPN user and frequent flyer. Security signals (is_vpn, is_relay, is_residential_proxy, threat_score) are what separate a real anomaly from noise.
  • Respond in tiers (allow, log, step-up MFA, block) and fail open: a lookup timeout should never lock a user out.

Geolocation is reliable enough at the country and region level to make this work and too rough at the street level to trust there, so the thresholds stay loose. The signal you want is "these two cities are thousands of kilometers and a few minutes apart," not "the user moved across town."

What impossible travel detection actually catches

Two terms get mixed up. A typical travel is a login from an unusual place that is still physically possible, like a user who normally signs in from Berlin appearing in Toronto after a flight. Impossible travel is a login that could not have happened given the time elapsed. The first is worth a note; the second is worth interrupting the session.

As a signal it targets account takeover. When an attacker replays stolen credentials from their own machines or a proxy pool, the source location rarely lines up with where the real user has been. Rate limiting and bot defenses thin out the automated noise at the perimeter; impossible travel catches the login that got through with a correct password. It is a strong signal, not proof. A VPN, a privacy relay, or a frequent traveler can all trip it, which is why the response matters as much as the detection. Treat a hit as "this needs more scrutiny," not "this is an attacker."

The check, before any code

The logic is short. Take the user's last login (latitude, longitude, timestamp) and the current one. Measure the great-circle distance between the two points. Divide by the hours elapsed to get an implied travel speed. If that speed is higher than anything a human can manage, flag it. Then decide what to do, using the IP's security signals to tell a real anomaly apart from a VPN exit.

Get the client IP right

The whole check depends on reading the correct client IP, and behind a load balancer or CDN the socket address is the proxy, not the user. The real address sits in X-Forwarded-For. That header is also trivially spoofable if you accept it from anywhere, so only trust it from infrastructure you control.

In Express, set the trust proxy level to the number of proxies in front of you, then read req.ip:

// One proxy/CDN in front of the app. Set the real hop count for your setup.
// Trusting X-Forwarded-For blindly lets a client spoof their location.
app.set("trust proxy", 1);

// Now req.ip is the client address as reported by your trusted proxy.
const ip = req.ip;
Enter fullscreen mode Exit fullscreen mode

In FastAPI, run Uvicorn with --proxy-headers and a trusted-host list, then read the forwarded client:

# Start with: uvicorn app:app --proxy-headers --forwarded-allow-ips="10.0.0.0/8"
# request.client.host is then the forwarded client IP, not the proxy.
ip = request.client.host
Enter fullscreen mode Exit fullscreen mode

Pitfall: if your app is reachable directly as well as through the proxy, an attacker can hit it directly and set their own X-Forwarded-For. Lock direct access down at the network layer, or the location signal is meaningless.

Geolocate the IP to coordinates

You need latitude and longitude for the IP. Any IP geolocation provider returns this: IPGeolocation, ipinfo, ip-api, MaxMind GeoIP2, and IPLocate all do. The examples here use IPGeolocation because the free tier returns coordinates, city, and country in one call with no card required, which keeps the tutorial to one dependency. Create a free key and put it in an environment variable.

The raw call is one GET request:

curl 'https://api.ipgeolocation.io/v3/ipgeo?apiKey=YOUR_KEY&ip=8.8.8.8'
Enter fullscreen mode Exit fullscreen mode

For 8.8.8.8 that comes back with location.city of "Mountain View", location.country_name of "United States", and coordinates under location. One detail that will bite you if you miss it: the coordinates are returned as strings, so parse them before doing math.

Node, with a timeout and a fallback so a slow lookup never blocks a login:

const IPGEO_API_KEY = process.env.IPGEO_API_KEY;
if (!IPGEO_API_KEY) {
  throw new Error("IPGEO_API_KEY is not set");
}

// withSecurity adds the VPN/proxy signals (paid tier). See the false-positive section.
async function locateIp(ip, { withSecurity = false } = {}) {
  const include = withSecurity ? "&include=security" : "";
  const url = `https://api.ipgeolocation.io/v3/ipgeo?apiKey=${IPGEO_API_KEY}&ip=${encodeURIComponent(ip)}${include}`;

  try {
    const res = await fetch(url, { signal: AbortSignal.timeout(1500) });
    // 423 means a bogon/private IP the API won't locate. Treat as "no location".
    if (!res.ok) return null;

    const data = await res.json();
    const loc = data?.location;
    if (!loc?.latitude || !loc?.longitude) return null;

    // Coordinates arrive as strings. Reject anything that isn't a finite number.
    const lat = Number.parseFloat(loc.latitude);
    const lon = Number.parseFloat(loc.longitude);
    if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;

    return {
      lat,
      lon,
      city: loc.city ?? null,
      country: loc.country_name ?? null,
      security: data.security ?? null,
    };
  } catch (err) {
    // Timeout or network error: fail open, log for review.
    console.error(`Geo lookup failed for ${ip}: ${err.message}`);
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Python with FastAPI, same shape:

import os
import httpx

IPGEO_API_KEY = os.environ.get("IPGEOLOCATION_API_KEY")
if not IPGEO_API_KEY:
    raise RuntimeError("IPGEOLOCATION_API_KEY is not set")

async def locate_ip(ip: str, with_security: bool = False):
    params = {"apiKey": IPGEO_API_KEY, "ip": ip}
    if with_security:
        params["include"] = "security"

    try:
        async with httpx.AsyncClient(timeout=1.5) as client:
            resp = await client.get("https://api.ipgeolocation.io/v3/ipgeo", params=params)
        if resp.status_code != 200:  # 423 for bogon/private IPs
            return None

        body = resp.json()
        loc = body.get("location") or {}
        lat, lon = loc.get("latitude"), loc.get("longitude")
        if lat is None or lon is None:
            return None

        try:                       # strings in the response; reject bad values
            lat, lon = float(lat), float(lon)
        except (TypeError, ValueError):
            return None

        return {
            "lat": lat,
            "lon": lon,
            "city": loc.get("city"),
            "country": loc.get("country_name"),
            "security": body.get("security"),
        }
    except httpx.HTTPError as err:
        print(f"Geo lookup failed for {ip}: {err}")  # fail open
        return None
Enter fullscreen mode Exit fullscreen mode

A word on accuracy

Country and region accuracy is high. City-level placement is rougher, and it gets worse on mobile carriers and carrier-grade NAT, where one tower or gateway can cover a wide area. So compare distance, not exact coordinates, and keep the threshold well above any plausible margin of error. You are looking for a jump between continents, not a wobble between neighborhoods.

Measure the distance with the Haversine formula

The Haversine formula gives the great-circle distance between two latitude/longitude points, which is the shortest path over the surface of a sphere. Close enough to real distance for this purpose.

function haversineKm(lat1, lon1, lat2, lon2) {
  const R = 6371; // Earth radius in km
  const toRad = (deg) => (deg * Math.PI) / 180;
  const dLat = toRad(lat2 - lat1);
  const dLon = toRad(lon2 - lon1);
  const a =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
  // Clamp to 1 so floating-point error can't push asin out of its domain.
  return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
}
Enter fullscreen mode Exit fullscreen mode
from math import radians, sin, cos, asin, sqrt

def haversine_km(lat1, lon1, lat2, lon2):
    r = 6371.0  # Earth radius in km
    d_lat = radians(lat2 - lat1)
    d_lon = radians(lon2 - lon1)
    a = sin(d_lat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(d_lon / 2) ** 2
    # Clamp to 1 so floating-point error can't push asin out of its domain.
    return 2 * r * asin(min(1, sqrt(a)))
Enter fullscreen mode Exit fullscreen mode

New York to Singapore is about 15,300 km. Forty minutes apart, that implies a speed near 23,000 km/h, which settles the question.

Turn distance and time into a velocity check

Speed is distance over time. The threshold is the one judgment call. A commercial jet cruises near 900 km/h, so anything materially above that is not a real trip. I use 1000 km/h to leave room for the rough edges of city-level geolocation. Two guards keep it sane: ignore short distances, which are geolocation jitter rather than travel, and handle a near-zero time gap so you do not divide by zero.

const MAX_PLAUSIBLE_KMH = 1000; // above a commercial jet (~900 km/h), with margin
const MIN_DISTANCE_KM = 100;    // below this is local movement or geo jitter

function isImpossibleTravel(prev, current) {
  const km = haversineKm(prev.lat, prev.lon, current.lat, current.lon);
  if (km < MIN_DISTANCE_KM) return false;

  const hours = (current.ts - prev.ts) / 3600;
  if (hours <= 0) return true; // same second or clock skew, but the cities are far apart

  return km / hours > MAX_PLAUSIBLE_KMH;
}
Enter fullscreen mode Exit fullscreen mode
MAX_PLAUSIBLE_KMH = 1000
MIN_DISTANCE_KM = 100

def is_impossible_travel(prev, current):
    km = haversine_km(prev["lat"], prev["lon"], current["lat"], current["lon"])
    if km < MIN_DISTANCE_KM:
        return False

    hours = (current["ts"] - prev["ts"]) / 3600
    if hours <= 0:
        return True

    return (km / hours) > MAX_PLAUSIBLE_KMH
Enter fullscreen mode Exit fullscreen mode

The first login for a user has nothing to compare against, so it always passes. That is fine; the check starts protecting from the second login onward.

Store login history

You need the previous login's location and time. Redis fits because the lookup happens on every login and you want it fast. Store one record per user keyed by user ID, with a TTL so dormant accounts age out.

import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL);
const HISTORY_TTL = 60 * 60 * 24 * 30; // 30 days

async function getLastLogin(userId) {
  try {
    const raw = await redis.get(`login:last:${userId}`);
    return raw ? JSON.parse(raw) : null;
  } catch (err) {
    console.error(`Redis read failed: ${err.message}`);
    return null; // fail open: no history means the check is skipped, not blocked
  }
}

async function saveLastLogin(userId, record) {
  try {
    await redis.set(`login:last:${userId}`, JSON.stringify(record), "EX", HISTORY_TTL);
  } catch (err) {
    console.error(`Redis write failed: ${err.message}`);
  }
}
Enter fullscreen mode Exit fullscreen mode
import json
import redis.asyncio as redis

redis_client = redis.from_url(os.environ.get("REDIS_URL", "redis://localhost:6379"))
HISTORY_TTL = 60 * 60 * 24 * 30  # 30 days

async def get_last_login(user_id: str):
    try:
        raw = await redis_client.get(f"login:last:{user_id}")
        return json.loads(raw) if raw else None
    except redis.RedisError as err:
        print(f"Redis read failed: {err}")  # fail open
        return None

async def save_last_login(user_id: str, record: dict):
    try:
        await redis_client.set(f"login:last:{user_id}", json.dumps(record), ex=HISTORY_TTL)
    except redis.RedisError as err:
        print(f"Redis write failed: {err}")
Enter fullscreen mode Exit fullscreen mode

Tip: no Redis in your stack? A row per user in your existing database works the same way. You only read and write one small record per login, so the storage choice barely matters until you are doing this at high volume.

Wire it into the login flow

The check runs after the password is verified and before you hand back a session. In Express, that is middleware mounted after authentication, where req.user exists.

async function checkImpossibleTravel(req, res, next) {
  const userId = req.user?.id;
  if (!userId) return next(); // nothing authenticated to check

  const current = await locateIp(req.ip);
  if (!current) return next(); // private IP or lookup failed: fail open

  const now = Math.floor(Date.now() / 1000);
  const record = { lat: current.lat, lon: current.lon, city: current.city, ts: now };

  const prev = await getLastLogin(userId);

  if (prev && isImpossibleTravel(prev, record)) {
    // Suspicious: hold the record. Do NOT move the baseline yet, or a blocked
    // attacker overwrites the user's trusted location. Persist it after step-up.
    req.loginRisk = { impossibleTravel: true, from: prev.city, to: current.city, currentLogin: record };
    return next();
  }

  // Clean login: this becomes the new trusted baseline.
  await saveLastLogin(userId, record);
  next();
}
Enter fullscreen mode Exit fullscreen mode

In FastAPI, call it inside the login route once credentials check out, since middleware runs before you know who the user is:

import time

async def evaluate_login(user_id: str, ip: str):
    current = await locate_ip(ip)
    if not current:
        return None  # fail open

    record = {"lat": current["lat"], "lon": current["lon"],
              "city": current["city"], "ts": int(time.time())}

    prev = await get_last_login(user_id)

    if prev and is_impossible_travel(prev, record):
        # Suspicious: hold the record, don't move the baseline yet (see note below).
        return {"impossible_travel": True, "from": prev["city"],
                "to": current["city"], "current_login": record}

    # Clean login: this becomes the new trusted baseline.
    await save_last_login(user_id, record)
    return None
Enter fullscreen mode Exit fullscreen mode

One rule makes or breaks this: only promote a login to the trusted baseline once it is actually trusted. The code above saves it right away for a clean login but holds it when the jump looks impossible. Persist the held record only after the user clears step-up authentication. Skip that and a blocked attacker overwrites the baseline, and the real user's next login then looks anomalous against the attacker's location.

// Call after the user passes step-up (correct MFA code, passkey, and so on).
async function onStepUpPassed(req) {
  const userId = req.user?.id;
  const record = req.loginRisk?.currentLogin; // the login we held back
  if (userId && record) await saveLastLogin(userId, record);
}
Enter fullscreen mode Exit fullscreen mode
async def on_step_up_passed(user_id: str, risk: dict):
    record = (risk or {}).get("current_login")  # the login we held back
    if record:
        await save_last_login(user_id, record)
Enter fullscreen mode Exit fullscreen mode

At this point a real account takeover from a fresh location gets flagged. So does every employee on a corporate VPN.

The false positives that will bite you

Run the velocity check on real traffic and the flags pile up from people who are not attackers:

  • Someone on a corporate VPN whose egress sits in another country.
  • An iPhone user behind iCloud Private Relay, which presents a relay IP, not their own.
  • A phone hopping between a mobile carrier and home WiFi, where the carrier IP geolocates hundreds of kilometers off.
  • A genuine frequent flyer who lands and logs in before your record updates.

Block on the raw velocity signal and you will lock these people out daily. The fix is to ask one more question about the IP: is it an anonymizing service, and how risky is it.

Suppress false positives with security signals

IPGeolocation's IP Security API returns risk signals for an IP: a threat_score from 0 to 100, plus flags for VPN, proxy, residential proxy, Tor, relay, and more, with provider names and confidence scores. It is a dedicated /v3/security endpoint, but you can fold it into the same geolocation call with include=security, which gets you coordinates and risk in one request. That is the withSecurity flag the lookup function already takes.

A full response looks like this (for the documented example IP 2.56.188.34):

{
  "ip": "2.56.188.34",
  "security": {
    "threat_score": 80,
    "is_tor": false,
    "is_proxy": true,
    "proxy_provider_names": ["Zyte Proxy"],
    "proxy_confidence_score": 80,
    "proxy_last_seen": "2025-12-12",
    "is_residential_proxy": true,
    "is_vpn": true,
    "vpn_provider_names": ["Nord VPN"],
    "vpn_confidence_score": 80,
    "vpn_last_seen": "2026-01-19",
    "is_relay": false,
    "relay_provider_name": "",
    "is_anonymous": true,
    "is_known_attacker": true,
    "is_bot": false,
    "is_spam": false,
    "is_cloud_provider": true,
    "cloud_provider_name": "Packethub S.A."
  }
}
Enter fullscreen mode Exit fullscreen mode

The distinction that matters here is residential proxy versus commercial VPN. A commercial VPN or a privacy relay is overwhelmingly a real person protecting their traffic, so an impossible-travel hit from one is usually noise, especially on a device you already recognize. A residential proxy is different: those are sold to route traffic through other people's home connections specifically to look like organic users and dodge IP reputation, and they show up in credential-stuffing campaigns far more than in normal browsing. So the response leans on two things: how anonymized the IP is, and whether the device is one you have seen before.

function decideResponse(risk, security, context = {}) {
  if (!risk?.impossibleTravel) return "ALLOW";

  const s = security ?? {};
  const threatScore = Number(s.threat_score ?? 0);
  const knownDevice = Boolean(context.knownDevice);

  // Strong abuse signals win regardless of the travel jump.
  if (s.is_known_attacker || threatScore >= 80 || s.is_residential_proxy) {
    return "BLOCK";
  }
  // A commercial VPN or relay explains the jump, but attackers use them too.
  // Trust it only on a device we recognize; otherwise step up.
  if (s.is_vpn || s.is_relay || s.is_proxy) {
    return knownDevice ? "LOG" : "CHALLENGE";
  }
  // Clean IP, real geographic impossibility: step up to MFA before trusting the session.
  return "CHALLENGE";
}
Enter fullscreen mode Exit fullscreen mode
def decide_response(risk, security, context=None):
    if not risk or not risk.get("impossible_travel"):
        return "ALLOW"

    context = context or {}
    s = security or {}
    threat_score = int(s.get("threat_score") or 0)
    known_device = bool(context.get("known_device"))

    if s.get("is_known_attacker") or threat_score >= 80 or s.get("is_residential_proxy"):
        return "BLOCK"
    if s.get("is_vpn") or s.get("is_relay") or s.get("is_proxy"):
        return "LOG" if known_device else "CHALLENGE"
    return "CHALLENGE"
Enter fullscreen mode Exit fullscreen mode

The response now keys on both signals. A known device on a VPN or relay during an impossible-travel hit gets logged; the same hit from an unknown device gets a step-up challenge instead of a free pass. A residential proxy, a known attacker, or a threat score at the ceiling blocks regardless. A clean IP making a real geographic jump always challenges. The knownDevice flag is the tunable part: feed it from a device cookie or fingerprint (the same signal mentioned at the end of this article), and move the thresholds to match your own risk tolerance.

The lookups split along the free and paid line. For the free velocity check, call locateIp(req.ip). To get the VPN, relay, proxy, residential-proxy, and threat-score signals this section relies on, call locateIp(req.ip, { withSecurity: true }) (and locate_ip(ip, with_security=True) in Python), then pass the security object and the device context into the decision: decideResponse(risk, current.security, { knownDevice }). Security data is a paid-tier feature, so the velocity check stands on its own and you add this layer when you are ready to cut the false positives.

Respond, don't just block

Detection is the easy half. The response is where you protect users without alienating them. Four tiers cover it:

  • ALLOW: no anomaly. The default for almost every login.
  • LOG: a recognized device on a VPN, relay, or proxy. The jump is explained and the device is known, so record it and move on.
  • CHALLENGE: a real geographic impossibility on a clean IP, or an unknown device behind a VPN or proxy. Require a second factor before issuing the session.
  • BLOCK: anomaly plus strong abuse signals (residential proxy, known attacker, or a threat score at the ceiling). Refuse and alert.

And fail open. If the geolocation or security lookup times out, or Redis is down, allow the login and log the gap. A missed anomaly is a recoverable risk. A customer locked out at 2am because your IP provider had a slow minute is a support ticket and a churned account. Availability wins on the login path; run the check, but never let it become a hard dependency.

A few notes before you ship

Store and compare timestamps in UTC, or a user crossing a timezone will produce phantom anomalies. IPv6 works the same way; the geolocation call handles both address families, so no special casing. Pair the location signal with a device signal if you can: a recognized device cookie or fingerprint lets you soften the response for a known laptop on a new network, which is most of your real false positives.

And know when not to build this. If you are already on an identity platform, the feature probably ships in the box: WorkOS Radar, Microsoft Entra ID Protection, Okta, and Datadog all detect impossible travel. Configure theirs instead of rebuilding it. Build your own when you are not on one of those, when you want the signal feeding your own risk engine, or when you want full control over the thresholds and the response.

Start with the velocity check on the free geolocation tier and watch your logs for a week before you switch on any blocking. Add the security signals once you see how many of your "impossible" logins are just people on a VPN. Keep the threshold loose the whole time, and let the step-up prompt, not a hard block, carry the cases you are unsure about.

Top comments (0)