Someone logs into an account from New York. Forty minutes later, someone logs into the same account from Lagos, roughly 8,400 km away. Nobody makes that trip in forty minutes. Whoever just logged in from Lagos almost certainly isn't the same person who logged in from New York, and there's a decent chance the app in question has no idea that just happened.
That's the whole pitch for impossible travel detection, and if you spend any time on dev.to's security tag, you've probably already read an explanation of it. The concept isn't new and it isn't complicated: geolocate the login, measure the distance from the last one, divide by the time between them, check if the result is physically possible. What's rarer is finding the tested, installable version of that logic instead of a snippet you're expected to adapt yourself. So I built one.
impossible-travel-guard is free, open source, and has zero runtime dependencies. Install it, call one function after your login check passes, done. This post covers what it actually catches, where it falls apart, and how to feed it real coordinates without writing your own geolocation client.
Why this is worth shipping, not just explaining
Account takeover isn't a fringe problem. The FBI's IC3 unit logged close to 4,700 ATO complaints in 2025 with $359.7 million in reported losses, and their own numbers show that once an attacker actually gets into an account, fifty or more transactions across different banks can fire off within minutes (source). That's not someone clicking around by hand, that's a script doing damage fast once it's past your login screen. Anything that slows down the "getting in" part earns its keep.
What makes this particular signal useful is that it doesn't need either IP address to look suspicious on its own. No VPN flag, no bad reputation score, nothing wrong with either login individually. The only thing wrong is the relationship between them. Most account security tooling checks a login against a blacklist or a reputation score. This checks a login against the user's own history, so it still catches an attacker coming from a completely clean IP, because they didn't think they needed to hide it.
That also means it will occasionally flag things that are completely fine. More on that below, because it's the part that decides whether this is actually useful in production or just a fun demo.
Installing it
npm install impossible-travel-guard
Five lines gets you a working check:
import { TravelGuard } from "impossible-travel-guard";
const guard = new TravelGuard(); // in-memory store, fine for local dev
const result = await guard.check({
userId: "user_123",
latitude: 6.5244,
longitude: 3.3792,
timestamp: Date.now(),
});
if (result.flagged) {
// require MFA, alert the user, or hold the session for manual review
// do not hard-block on this signal alone, more on that below
}
The first login for any user is never flagged. There's nothing to compare it to yet, so it just becomes the baseline everything else gets measured against.
That in-memory store is fine for poking at this locally, and it forgets everything on restart. For anything real, implement the two-method LoginStore interface against whatever you already run. Redis is a natural fit since this is one small read and one small write per login:
import { LoginStore, LoginEvent } from "impossible-travel-guard";
class RedisStore implements LoginStore {
async getLastLogin(userId: string): Promise<LoginEvent | null> {
const raw = await redis.get(`last_login:${userId}`);
return raw ? JSON.parse(raw) : null;
}
async saveLogin(event: LoginEvent): Promise<void> {
await redis.set(`last_login:${event.userId}`, JSON.stringify(event));
}
}
const guard = new TravelGuard({ store: new RedisStore() });
Picking the threshold
Two numbers control how sensitive this is:
new TravelGuard({
maxPlausibleSpeedKmh: 1000, // default
minDistanceKm: 50, // default
});
1000 km/h isn't a number I picked out of the air. Commercial jets cruise between roughly 830 and 1,050 km/h, and the fastest passenger aircraft flying today tops out around 1,136 km/h. 1000 sits above normal cruise speed with margin for geolocation being a little imprecise, without being loose enough to let a real cross-continent jump slide under it. minDistanceKm exists for a dumber reason: IP geolocation jitters within the same city all the time, and without a floor you'd flag someone whose ISP handed them a slightly different IP on the same Wi-Fi network.
Turn the speed threshold down if your users rarely fly internationally. Turn it up if you'd rather only catch the most extreme jumps and tolerate more noise.
The false positives you'll actually hit
I ran this against enough test scenarios before publishing to know where the flags actually come from in practice, and it's rarely the interesting case:
| Cause | Why it trips the check | What to do about it |
|---|---|---|
| Corporate VPN or privacy relay | Traffic exits through a server nowhere near the user | If the device is recognized, log it and move on. If not, step up |
| Mobile carrier CGNAT | The carrier's gateway can sit hundreds of km from the actual phone | Keep minDistanceKm above typical carrier drift |
| Same-city IP reissue | A new DHCP lease or repeat lookup lands a few km away | This is what minDistanceKm is for, it shouldn't reach you at all |
| An actual compromised login | Attacker logging in from their own, unmasked location | The reason this library exists. Step up first, block if it stacks with other signals |
That last row is the reason to build this at all. VPN and IP-reputation checks catch the attackers sloppy enough to route through something flagged. This catches the ones who don't bother hiding, because a stolen password from a data breach doesn't come with instructions to use a VPN.
The practical takeaway from that table: never wire a flag straight to a hard block. Log it first, watch what comes through for a week, and only start requiring MFA on a flag once you've seen your own users' actual travel patterns. Reserve an outright block for when a flag stacks with something else, a new device, a new browser, a password reset requested minutes earlier.
What this won't catch
This is one signal, not a fraud engine. It has no opinion on VPNs, proxies, device fingerprints, or IP reputation, pair it with those if you want that coverage, I didn't try to make this library do five jobs at once.
It also can't tell you why a trip is impossible, only that it is. A shared corporate VPN egress bouncing between two data centers looks exactly the same to this library as a real account takeover does. iPhones behind Apple's Private Relay cause the same kind of noise. None of that is specific to this package, it's true of any impossible-travel check anyone builds, and the honest fix is the boring one: log enough context on every flag that you can review a handful by hand until you trust the threshold you've picked.
Getting coordinates without building your own geolocation client
This library never calls a geolocation API itself. That's deliberate, so it works with whatever provider you already have. If you don't have one, IPGeolocation.io's IP Location API covers exactly what this needs on its free tier, latitude, longitude, city, and country for an IP, no card required for a key. The repo's examples/ipgeolocation-adapter.ts has a working version of that wiring, and examples/live-test.ts runs the whole flow against two real IP addresses so you're looking at actual coordinates instead of the hardcoded ones in the quick-start snippet above:
export IPGEO_API_KEY="your-key-here"
npx tsx examples/live-test.ts 8.8.8.8 197.210.28.1 40
That resolves both IPs, runs the exact check guard.check() runs, and prints the distance, elapsed time, and implied speed. One thing worth saying plainly: never call a geolocation API directly from a browser with your key in the request. That belongs on your backend, full stop.
A few things people ask
Does this replace VPN or IP reputation checks? No, and I'd be skeptical of anything that claims one signal does. It catches a different kind of attacker, one whose IP has no reputation problem at all. Run it alongside VPN and proxy detection if you want broader coverage, not instead of it.
What data does this actually need? Latitude, longitude, and a timestamp per login. That's it. Nothing paid, nothing beyond what any geolocation provider's free tier already returns.
Is the library itself free? Yes, MIT licensed, no paid tier of its own. The only cost that could show up is whichever geolocation provider you pick, and a free tier is enough for what this needs.
Will this lock out real users? Not if you respond to a flag with step-up verification instead of a hard block, which is what the rest of this post has been arguing for. Treat a flag as "ask one more question," not "deny access."
Try it before you install it
There's a live demo that runs this exact logic in your browser. Pick two cities, set a time gap, watch it flag or clear. I built that after realizing a passing test suite that only I'd seen wasn't proof of anything to anyone else, and it's a faster way to get a feel for the threshold than reading about it.
The GitHub repo has the full source, the test suite, and both example scripts above. Issues and pull requests are genuinely welcome, if you hit a false positive or false negative worth handling differently, open one with the two real login events involved. Real numbers make it easy to reason about.


Top comments (0)