Skip to content

Impossible-travel detection on VPN logins: the GeoVelocity check

8/18/2025Backend DevelopmentOpenVPN Control Plane12 min read

Impossible-travel detection on VPN logins: the GeoVelocity check

A session in one city, the next from a country away minutes later — physically impossible, statistically a stolen credential. A minimum-distance threshold turns geography into an anomaly signal.

The brief in one paragraph

A managed VPN deployment needed to catch credential theft without leaning on a heavy SIEM. The setup was a Node and TypeScript control plane sitting in front of OpenVPN, bridging the management interface and caching an upstream API. Operators wanted a cheap, local signal: if the same client certificate connects from two places that no human could travel between in the elapsed time, raise an anomaly and act on it. No external SaaS, no streaming pipeline — the detector had to run inline on every connect event, read from the same embedded database the control plane already used, and decide in milliseconds. The piece that earned its keep was a GeoVelocityDetector in src/anomaly/detectors/GeoVelocityDetector.ts, and the detail that made it usable in production was a minimum-distance threshold added later to stop it from screaming at carrier-grade NAT.

The constraints that shaped the technical decisions

The first constraint was where the data lived. The control plane records every connection into a ConnectHistory table — defined in prisma/schema.prisma with columns for cn, trustedIp, country, and a ts epoch, indexed on [cn, ts]. That index is the whole performance budget for this detector. The query it runs is a single indexed lookup for the most recent prior row of the same identity, so detection cost stays flat as history grows.

model ConnectHistory {
  id         Int     @id @default(autoincrement())
  cn         String
  trustedIp  String? @map("trusted_ip")
  asn        Int?
  asnOrg     String? @map("asn_org")
  country    String?
  hourOfDay  Int     @map("hour_of_day")
  dayOfWeek  Int     @map("day_of_week")
  ts         Int

  @@index([cn, ts], map: "idx_connhist_cn_ts")
  @@map("connect_history")
}

cn is the OpenVPN common name — the per-client identity baked into the certificate. Pinning detection to cn rather than to an IP or a username matters: the whole point of impossible travel is comparing two sessions of the same identity, and the certificate is the only thing that survives across IP changes. The idx_connhist_cn_ts index exists precisely so "most recent prior connect for this cn" is one B-tree seek, not a table scan.

The second constraint was geolocation accuracy. IP-to-location is a probabilistic guess, not a fact. The resolver wraps MaxMind mmdb readers in src/anomaly/GeoIpResolver.ts, and every reader is optional — a missing City database silently downgrades the result to country-only. That degradation has to be handled, not assumed away, because a country-only lookup gives you no coordinates and therefore no distance.

lookupCity(ip: string | null): GeoCityLookup {
  const base = this.lookup(ip);
  if (!ip || !this.cityReader) return this.applyOverrides({ ...base, lat: null, lon: null, city: null, region: null });
  try {
    const r = this.cityReader.get(ip);
    const lat = typeof r?.location?.latitude  === 'number' ? r.location.latitude  : null;
    const lon = typeof r?.location?.longitude === 'number' ? r.location.longitude : null;
    const cityName = (r?.city?.names?.en as string | undefined) ?? null;
    const sub = Array.isArray(r?.subdivisions) && r.subdivisions.length > 0 ? r.subdivisions[0] : null;
    const region = (sub?.names?.en as string | undefined) ?? null;
    return this.applyOverrides({ ...base, lat, lon, city: cityName, region });
  } catch {
    return this.applyOverrides({ ...base, lat: null, lon: null, city: null, region: null });
  }
}

Notice every coordinate is number | null, and a malformed IP throws into a catch that returns nulls rather than crashing the connect path. The detector reads those nulls as "I cannot compute a distance here" and falls back, instead of treating a missing latitude as zero — which would place every unknown IP at the intersection of the equator and the prime meridian, off the coast of Africa, and generate spectacular false alarms.

The architecture

The detector implements a tiny Detector<ConnectContext> interface from src/anomaly/types.ts — one check method that returns an AnomalyResult or null. That shape is deliberate. GeoVelocity is one of several connect-time detectors registered with a central dispatcher, and they all share the same contract, so the dispatcher does not care what each one inspects.

export interface Detector<T> {
  readonly type: AnomalyType;
  check(input: T): Promise<AnomalyResult | null> | AnomalyResult | null;
}

The flow on each connect is: the dispatcher hands the detector a ConnectContext (cn, trustedIp, now), the detector resolves the current IP, pulls the prior connect for that cn, and decides. The query against history is the same single-row, index-backed lookup the schema was built for:

const db = await getDb();
const prev = await db.connectHistory.findFirst({
  where: { cn: input.cn, ts: { lt: input.now } },
  orderBy: { ts: 'desc' },
  select: { trustedIp: true, country: true, ts: true },
});
if (!prev || !prev.country) return null;
if (this.opts.ignoreSameCountry && prev.country === cur.country) return null;

Two early exits here carry weight. No prior connect, or a prior connect with no resolved country, means there is nothing to compare against — return null, no anomaly. And ignoreSameCountry (on by default) short-circuits the common case: a client reconnecting from the same country almost never represents impossible travel and is not worth the distance math. That single boolean removes the overwhelming majority of connect events from ever reaching the expensive path, which is the kind of cheap filter you want first when something runs on every login.

When a detector does fire, it returns an AnomalyResult. The dispatcher applies a per-(cn, type) cooldown, persists the event into the AnomalyEvent table, and emits it. A separate AnomalyActuator in src/anomaly/AnomalyActuator.ts subscribes to those events and decides what to do — which is where geography turns into enforcement. More on that below.

The hardest sub-problem and how it resolved

The math is the easy part. The detector computes a great-circle distance with a Haversine implementation that lives next to the resolver in src/anomaly/GeoIpResolver.ts:

export function greatCircleKm(a: { lat: number; lon: number }, b: { lat: number; lon: number }): number {
  const R = 6371;
  const toRad = (d: number): number => (d * Math.PI) / 180;
  const dLat = toRad(b.lat - a.lat);
  const dLon = toRad(b.lon - a.lon);
  const lat1 = toRad(a.lat);
  const lat2 = toRad(b.lat);
  const s = Math.sin(dLat / 2) ** 2 + Math.sin(dLon / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2);
  const c = 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
  return R * c;
}

Distance over elapsed time gives an implied speed in km/h. If that speed exceeds a configured maximum — default 800 km/h, roughly a commercial jet — no human did it, and you have your signal. Clean in theory.

The hard part was false positives, and they came from the geolocation layer, not the geometry. Carrier-grade NAT and mobile carrier IP ranges get misassigned by MaxMind all the time: a phone that hops between two cell towers can present IPs that the database files under two different countries, even though the subscriber never moved. The ignoreSameCountry filter does nothing here, because MaxMind genuinely reports two different countries. The City coordinates, meanwhile, often land only tens of kilometers apart — which is the tell. The crossing looks international by country label but is local by coordinate.

The fix shipped as a minimum-distance threshold. The repo history records it directly — commit c6c10fc, "feat(anomaly): GeoVelocity minimum mesafe eşiği" — and the diff is a single guarded line dropped in before the speed calculation:

if (prev.trustedIp && cur.lat !== null && cur.lon !== null) {
  const prevCity = this.geo.lookupCity(prev.trustedIp);
  if (prevCity.lat !== null && prevCity.lon !== null) {
    const km = greatCircleKm(
      { lat: prevCity.lat, lon: prevCity.lon },
      { lat: cur.lat, lon: cur.lon },
    );
    if (km < this.opts.minDistanceKm) return null;
    const speedKph = km / dtHours;
    if (speedKph <= this.opts.maxSpeedKph) return null;
    return { /* geo-velocity anomaly with from/to cities, distanceKm, speedKph */ };
  }
}

if (km < this.opts.minDistanceKm) return null; is the whole fix. The threshold defaults to 100 km, and the option's own comment in types.ts explains why that number: it is roughly the accuracy floor of GeoIP City data, so any "trip" shorter than that is more likely a geolocation artifact than a real movement. Two IPs the database places 40 km apart but in different countries no longer generate an alert. Two IPs 600 km apart in 10 minutes still do.

The ordering matters as much as the check. The distance gate sits before the speed gate. If you computed speed first, a 40 km hop in two seconds of clock skew would produce an absurd implied speed and fire anyway. By rejecting on raw distance first, the detector never trusts a speed derived from coordinates too close together to be meaningful.

There is also a fallback branch for when the City database is not loaded and you only have countries. In that case the detector cannot compute distance at all, so it applies a blunt time rule: if the same cn appears in two different countries within two hours, emit a lower-confidence geo-velocity-fallback event tagged with a note that no city coordinates were available. It is a deliberately coarser signal, and it is honest about being coarse.

What shipped and what did not

What shipped is a connect-time detector that runs on the existing embedded database, costs one indexed query plus some trigonometry per login, and feeds the same anomaly pipeline as every other detector. When it fires, the AnomalyActuator maps severity to an action:

private async handle(rec: AnomalyEventRecord): Promise<void> {
  const action = this.opts.severityActions[rec.severity] ?? 'log';
  // ... metrics, risk score bump, event bus, webhook dispatch ...
  if (action === 'kick') {
    await this.kick(rec);
    await this.detector.recordAction(rec.id, 'kick');
    return;
  }
}

GeoVelocity defaults to high severity, and the default severity-action map sends high to kick. A high kick does two things: if a firewall interface is wired in, the client's tunnel IP is added to a blocked source set for a timeout window (default 300 seconds), and then the live session is disconnected by cn. Lower severities map to log or throttle instead, so the same machinery can warn quietly or halve bandwidth rather than cut the connection. The action is configuration, not code — the severity-to-action mapping is parsed from an environment string, so an operator can dial GeoVelocity down to log-only on day one and promote it to kick once they trust it.

What did not ship is anything fancier. There is no per-user travel profile, no learned baseline of "this person flies a lot," no probability model. The detector is a pair of thresholds — minimum distance and maximum speed — plus a country shortcut and a coordinate-availability fallback. That is the right amount of machinery for a signal that is meant to be one input among several, not a verdict on its own.

Trade-off

The honest trade-off is that the minimum-distance threshold trades recall for precision. Set the floor at 100 km and you will miss a genuine impossible-travel event that happens to span only 80 km — say, two adjacent cities across a national border that a real attacker could exploit. That is a real blind spot. The bet is that for a VPN guarding remote access, a detector that fires on something believable every time is worth far more than one that fires on everything and gets muted within a week. A noisy detector is a disabled detector. The 100 km floor and the 800 km/h ceiling are both tunable per deployment, so the precision/recall dial is in the operator's hands rather than baked into the build.

Business impact

For the people paying for this, the outcome is that a stolen VPN credential connecting from an implausible location gets the session killed and the source firewalled before the attacker finishes establishing a foothold — without a SIEM subscription, a log-shipping pipeline, or an analyst watching a dashboard at 3 a.m. The detection runs inside the control plane they already operate, on the database it already writes. The threshold work is what makes that economically real: an alert stream nobody trusts costs more than it saves, because someone eventually turns it off. Tuning out the carrier-NAT false positives is the difference between a feature that survives contact with production and one that gets disabled in the second week.

What to do next

If you have an impossible-travel check already, go look at what it does when the geolocation layer is uncertain — a missing coordinate, a CGNAT range, a disputed territory. Those are the inputs that generate the alerts your operators learn to ignore. The cheapest improvement is usually not a smarter model; it is a single guard like if (km < minDistanceKm) return null; placed before the speed math, plus an honest fallback for when you only have a country and no coordinates. Pull your own detector's last hundred fired events and ask how many were a phone changing cell towers. If you do not know the answer, that is the place to start.

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox