# OFAC IP geo-blocking: three ways it failed, and the VPN check

Two OFAC settlements turned on IP geo-blocking that checked the wrong address or left out a region. Build the screen, the VPN check and its test in Express.

In March 2026, TradeStation paid the US Treasury's Office of Foreign Assets Control (OFAC) $1,110,661 after users in Iran, Syria and Crimea placed 481 trades through its mobile app. It had geo-blocking, and the geo-blocking checked the wrong address. In July 2025, Interactive Brokers paid $11,832,136, partly because its IP block list left out Crimea for years. OFAC's release on TradeStation then says a firm's controls "may include appropriately calibrated screening protocols, geo-blocking controls, and Virtual Private Network detection software."

This post builds those controls into an Express app: a sanctions screen at sign-in and at every withdrawal, a VPN and Tor check, a flag for improbable logins, and a test that proves the app checks the address the user came from. We ran every snippet against the live API on October 30, 2026, with Node.js 22.22, Express 5.3 and our [Node.js library](https://docs.vpndetection.io/integrations/nodejs) 5.10.2, and every output below is from those runs. This isn't legal advice. A VPN check is one control among several, and OFAC's releases, linked below, say what else it looks for.

## Three ways geo-blocking failed

**It checked the wrong address.** TradeStation ran two tiers of geo-blocking: a firewall that denied addresses in sanctioned jurisdictions, and a third-party tool that checked each user's address at login. New mobile software it shipped in 2018 broke the second: "the second-tier geo-blocking protocol detected the IP address of the U.S.-located server from which TradeStation's new mobile platform software ran." Every mobile user looked like TradeStation's own server in the US. The first tier failed in June 2021, when an employee turned the firewall control off to install an update from the cloud provider and never turned it back on. Nobody noticed for almost a year: the tool that simulated logins from sanctioned jurisdictions had been dropped in November 2021, after the ISP and cloud providers blocked its test traffic before it reached TradeStation's systems ([OFAC's release](https://ofac.treasury.gov/media/935351/download?inline), March 17, 2026).

**The list left a place out.** Interactive Brokers kept an IP block list, and "prior to May 2019, IB failed to include Crimea," then left out the addresses of Sevastopol until June 2021. Deficiencies in its geo-blocking, "including a technical bug," let a limited number of customers in Iran, Cuba and Syria into its desktop and mobile apps, and IB "did not adequately audit or test these systems" ([OFAC's release](https://ofac.treasury.gov/media/934501/download?inline), July 15, 2025). Most of the customers in sanctioned places had given a home address outside them at onboarding. The IP addresses in IB's own data put them inside.

**The address was a VPN's.** OFAC's guidance for the virtual currency industry says "analytic tools can identify IP misattribution, for example, by screening IP addresses against known virtual private network (VPN) IP addresses and identifying improbable logins (such as the same user logging in with an IP address in the United States, and then shortly after with an IP address in Japan)" ([OFAC, October 2021](https://ofac.treasury.gov/media/913571/download?inline)). FTI Consulting counted 11 of 53 OFAC enforcement actions from 2022 to 2025, about one in five, that cite missing or deficient geolocation controls ([FTI](https://www.fticonsulting.com/insights/articles/strategic-opportunity-hidden-your-sanctions-compliance-program)).

## A country list misses almost every VPN user

A VPN user's address is the VPN server's, and the server is wherever the user picked. On October 29, 2026, we counted 3,390,474 IPv4 addresses that belong to a named consumer VPN service, leaving out enterprise gateways and private relays. They geolocate to 239 countries and territories, and 23.1% of them to the US. This many sit in the places OFAC sanctions comprehensively:

| Place | Consumer VPN IPv4 addresses |
|---|---|
| Iran | 1,926 |
| Cuba | 431 |
| North Korea | 8 |
| Donetsk oblast | 80 |
| Sevastopol | 1 |
| Crimea | 0 |
| Luhansk oblast | 0 |

That's 2,446 addresses, and the other 3,388,028, 99.93%, are somewhere a country list lets in. A user in Tehran on a VPN shows up with a German or American address, and the block list waves them through. A VPN check catches them: we picked 30 of their ranges at random, one address from each, and all 30 answered `"is_vpn": true` from the live API. None of the 30 was in a sanctioned place, and 7 were in the US.

## Screen sign-ins and withdrawals

```bash
npm install express vpndetection
npm pkg set type=module scripts.test="node --test"
```

The screen needs the address's country and the Tor flag, which the API answers with a key or without one. Get an API key in the [console](https://app.vpndetection.io) for the rest: a new account tries every field, 100 lookups a day.

OFAC's FAQ 10 lists the jurisdictions under its broadest sanctions: Cuba, Iran, North Korea, the Crimea region of Ukraine, and the so-called Donetsk People's Republic and Luhansk People's Republic regions of Ukraine ([FAQ 10](https://ofac.treasury.gov/faqs/10)). Syria's sanctions program was revoked effective July 1, 2025, and Russia is "not subject to the same degree of jurisdiction-based prohibitions," the FAQ says. Check it again before you ship, since the list changes. The screen denies those places, and holds a VPN or Tor user for review:

```js
// screen.js
// Comprehensively sanctioned, in OFAC's FAQ 10 on October 30, 2026.
const COUNTRIES = new Set(['CU', 'IR', 'KP']);
// Crimea, Sevastopol, and the Donetsk and Luhansk oblasts (ISO 3166-2).
const REGIONS = new Set(['UA-43', 'UA-40', 'UA-14', 'UA-09']);

export function screen(answer, region) {
  const country = answer?.location?.country;
  if (COUNTRIES.has(country)) return { action: 'deny', reason: country };
  if (REGIONS.has(region)) return { action: 'deny', reason: region };
  if (!country) return { action: 'hold', reason: 'no location' };
  if (answer.isVpn) return { action: 'hold', reason: 'vpn' };
  if (answer.isTor) return { action: 'hold', reason: 'tor' };
  return { action: 'allow', reason: null };
}

// The last sign-in per account. Keep it in your database.
const lastLogin = new Map();
const WINDOW_MS = 6 * 60 * 60 * 1000;

export function improbable(account, country, now = Date.now()) {
  if (!country) return false;
  const last = lastLogin.get(account);
  lastLogin.set(account, { country, at: now });
  return Boolean(last && last.country !== country && now - last.at < WINDOW_MS);
}
```

The API's `location` stops at the country, so the Ukrainian regions come from `regionOf` in `geo.js`, which you write against your own geolocation provider: it returns an ISO 3166-2 code, or `null`. For this post, we backed it with a table of the tested addresses from our geolocation. The order matters: a sanctioned place is denied before anything else, and an answer with no country, from a failed lookup, is held rather than let through.

A VPN or Tor user isn't refused. FTI's article says "blanket blocking of all VPN traffic may not be a proportionate response because there are legitimate reasons for using VPNs," and most VPN users aren't in a sanctioned place. A hold lets them in, and someone looks at the account before it moves money. `improbable` follows OFAC's example from the guidance, the same account signing in from two countries in quick succession, here six hours apart or less. A sign-in whose lookup failed has no country, so it neither counts as a jump nor resets the account's last country. The app runs the screen at sign-in and again at every withdrawal, since OFAC's guidance asks for the IP addresses "associated with transactions and logins," not only the one at onboarding:

```js
// app.js
import express from 'express';
import { VPNDetection } from 'vpndetection';
import { regionOf } from './geo.js';
import { improbable, screen } from './screen.js';

const client = new VPNDetection({ apiKey: process.env.VPNDETECTION_API_KEY });
const STATUS = { allow: 200, hold: 202, deny: 451 };

export const app = express();
app.set('trust proxy', 'loopback');
app.use(express.json());

async function check(req, event) {
  const { account } = req.body;
  let answer = null;
  try {
    answer = await client.lookup(req.ip, { timeoutMs: 2000 });
  } catch (err) {
    console.error(`lookup of ${req.ip} failed: ${err.message}`);
  }
  const country = answer?.location?.country ?? null;
  const region = await regionOf(req.ip);
  let verdict = screen(answer, region);
  const jump = event === 'login' && improbable(account, country);
  if (jump && verdict.action === 'allow') {
    verdict = { action: 'hold', reason: 'improbable login' };
  }
  console.log(JSON.stringify({
    at: new Date().toISOString(), event, account, ip: req.ip, country,
    region, is_vpn: answer?.isVpn ?? null, is_tor: answer?.isTor ?? null,
    ...verdict,
  }));
  return verdict;
}

app.post('/login', async (req, res) => {
  const { action } = await check(req, 'login');
  res.status(STATUS[action]).json({ action });
});

app.post('/withdraw', async (req, res) => {
  const { action } = await check(req, 'withdraw');
  res.status(STATUS[action]).json({ action });
});
```

```js
// server.js
import { app } from './app.js';

app.listen(3000, '127.0.0.1');
```

A deny answers 451, Unavailable For Legal Reasons, and a hold 202. In a real app, a hold at sign-in signs the user in and flags the account, and a hold at a withdrawal queues the payout for review. Every check writes one log line.

## Check the address the user came from

TradeStation's bug is a common mistake in any IP check: behind a proxy or a backend, the address your code sees is your own. We put nginx 1.29.1 in front of the app, on the same host:

```nginx
server {
    listen 8000;
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
```

`app.set('trust proxy', 'loopback')` tells Express to believe `X-Forwarded-For` from nginx on the same host, and nothing else. We called the app through nginx from `141.98.252.67`, a Mullvad server in London from [Mullvad's own server list](https://mullvad.net/en/servers), and `/withdraw` answered 202 and logged this line, formatted here:

```json
{
  "at": "2026-10-30T04:44:45.802Z",
  "event": "withdraw",
  "account": "bob",
  "ip": "141.98.252.67",
  "country": "GB",
  "region": "GB-ENG",
  "is_vpn": true,
  "is_tor": false,
  "action": "hold",
  "reason": "vpn"
}
```

The same call sending `X-Forwarded-For: 1.1.1.1` of its own still looked up `141.98.252.67`, since nginx appends the address it saw and Express reads from the right. With the `trust proxy` line deleted, the app looked up nginx, `127.0.0.1`. Our library answers a private address in the process, with an empty country, so the screen held the call: `"ip":"127.0.0.1","country":"","action":"hold","reason":"no location"`. That fails safe, and every user is held. TradeStation's version doesn't: its check saw a public US server, which looks like any other American. A lookup of an AWS address in Virginia, `3.80.0.1`, answered country `US` and `is_vpn` false, and the screen let it through. If your mobile app talks to a backend that calls you, the backend has to pass the user's address along, and your app has to know which hop to believe. The [Express tutorial](https://vpndetection.io/blog/detect-vpn-users-express-js) covers `trust proxy` for a load balancer on another machine.

## Block the regions, not just the countries

A country list can't express Crimea, Sevastopol or the so-called DNR and LNR. They're in Ukraine, and in the geolocation we used they carry Ukraine's country code, `UA`, with regions `UA-43` (Crimea), `UA-40` (Sevastopol), `UA-14` (Donetsk) and `UA-09` (Luhansk). A rule on `UA` blocks all of Ukraine, and a list of country codes without it blocks none of the four. Interactive Brokers' list left out Crimea, then Sevastopol.

Two things to check in your own setup. Crimea can turn up under Russia: FTI cites a 2023 OFAC action against a crypto platform whose screening missed Crimean addresses that customers gave with Russia as their country of residence. Look up an IP address you know is in Crimea, and see which country and region your provider returns. And the Donetsk and Luhansk prohibitions don't cover the whole oblasts. OFAC's [FAQ 1009](https://ofac.treasury.gov/faqs/1009) says the order "targets the so-called Donetsk People's Republic and Luhansk People's Republic regions," and that "U.S. persons may reasonably rely on vetted information from reliable third parties, such as postal codes and maps." The screen above blocks both oblasts, which also blocks the parts outside those regions. Whether to block the oblast or only part of it is a decision for whoever owns your sanctions program.

A privacy relay keeps the region. Apple publishes the location each iCloud Private Relay address stands for ([its list](https://mask-api.icloud.com/egress-ip-ranges.csv)), and Sevastopol and Donetsk are on it: `104.28.51.254` is Sevastopol, `104.28.52.1` is Donetsk. The API answers both with `is_relay` true and `is_vpn` false, and the region check denies them. A relay that tells you the user's region isn't hiding it, so the screen doesn't hold it as it holds a VPN. Our post on [blocking iCloud Private Relay](https://vpndetection.io/blog/block-icloud-private-relay) covers relays.

## Test it the way TradeStation didn't

TradeStation's tool simulated logins from sanctioned addresses, and its providers blocked them before they arrived. Test from where your load balancer sits instead: connect from an address the app trusts, and send the header the load balancer sets. Take one address per sanctioned place, as your geolocation places it, plus a VPN server, a Tor exit and your office:

```js
// test/screen.test.js
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { after, test } from 'node:test';
import { app } from '../app.js';

// One address per sanctioned place, as your geolocation places it, a VPN
// server, a Tor exit, and your office: the status /login and /withdraw give.
const cases = [
  ['Iran', '80.66.177.54', 451, 451],
  ['Cuba', '190.92.127.202', 451, 451],
  ['North Korea', '175.45.176.71', 451, 451],
  ['Sevastopol', '104.28.51.254', 451, 451],
  ['Donetsk', '104.28.52.1', 451, 451],
  ['Mullvad, Berlin', '193.32.248.66', 202, 202],
  ['Tor exit', '185.220.101.53', 202, 202],
  ['our office', process.env.OFFICE_IP, 200, 200],
];

const server = app.listen(0, '127.0.0.1');
await once(server, 'listening');
after(() => server.close());

// The test is the load balancer: it connects from loopback, which the app
// trusts, and sends the header the load balancer sets.
async function post(path, ip, account) {
  const res = await fetch(`http://127.0.0.1:${server.address().port}${path}`, {
    method: 'POST',
    headers: { 'content-type': 'application/json', 'x-forwarded-for': ip },
    body: JSON.stringify({ account }),
  });
  return res.status;
}

for (const [place, ip, login, withdraw] of cases) {
  test(place, async () => {
    assert.equal(await post('/login', ip, place), login);
    assert.equal(await post('/withdraw', ip, place), withdraw);
  });
}

test('improbable login', async () => {
  assert.equal(await post('/login', process.env.OFFICE_IP, 'alice'), 200);
  assert.equal(await post('/login', '141.23.73.70', 'alice'), 202);
});
```

The Iran, Cuba and North Korea addresses are the web servers of the University of Tehran, Cuba's telecom operator ETECSA and North Korea's state news agency, KCNA. The Sevastopol and Donetsk ones are the Private Relay addresses above, the Tor exit is from the [Tor Project's list](https://check.torproject.org/torbulkexitlist), and `141.23.73.70` is a server of the Technical University of Berlin. We set `OFFICE_IP` to a home broadband address on a US cable network, which `203.0.113.24`, an address reserved for documentation, stands in for here. Put your office's public address there: `203.0.113.24` itself has no country, and with it the office and improbable-login tests fail. With the key in `VPNDETECTION_API_KEY`:

```bash
OFFICE_IP=203.0.113.24 npm test
```

```text
# tests 9
# suites 0
# pass 9
# fail 0
```

The test fails when the screen stops working. With the API unreachable, the answers carry no country: Iran, Cuba, North Korea, the office and the improbable-login test's first sign-in were held instead of denied or allowed, and 5 of 9 failed, after about 3 seconds a request with the client's two default retries, each lookup logging `no response from the API`. Sevastopol and Donetsk were still denied, since their regions come from your own lookup. With no key, it passed 9 of 9 on November 11, 2026: `location` and `is_tor` come back without one. Run it in CI and after every update from your geolocation provider, and make the two calls below after every deploy and firewall change: TradeStation's firewall control stayed off for almost a year after an update.

The test can't see what sits in front of your load balancer, so also call a screened route from outside: from your office, where it should answer 200, and through a VPN server, where it should answer 202. The office call fails if the app checks a private address, and the VPN call if it checks your own server.

Watch the log as well. TradeStation's provider sent a daily report of blocked access attempts, and when the subscription lapsed in 2021, nobody asked for over eight months why the reports had stopped. A screen that has denied or held no one in a week is worth a look.

## Keep the record

Both settlements turn on what the firm already had in its logs. TradeStation found its 481 trades by reviewing the IP addresses of past transactions, and Interactive Brokers placed its customers in sanctioned jurisdictions from the IP address data it held. The app's log line has what a review needs: the address it checked, the country and region, the flags, the verdict and its reason, for every sign-in and withdrawal. Keep it as long as you keep the transactions.

Paid plans start at 250,000 lookups a month ([pricing](https://vpndetection.io/pricing)). To screen a whole customer base or a transaction history, download the [VPN IP database](https://vpndetection.io/databases/vpn-ip) and the [Tor IP database](https://vpndetection.io/databases/tor-ip) and look addresses up locally instead of calling the [API](https://vpndetection.io/api). For the UK's age checks, the other place a VPN beats a location rule, see [Online Safety Act VPN detection: check every visitor, not UK IPs](https://vpndetection.io/blog/online-safety-act-vpn-detection).

