Geo restrictions and the access policy

Turning countries away safely — the three screens, all 21 policy keys, the preflight that stops you locking yourself out, the rule tester, wind-down carve-outs and the evidence log.

14 min readUpdated 6 August 2026geo, compliance, sanctions, countries, audit

Geographic restriction is the one compliance control in this platform whose failure mode does not look like a failure. Every other misconfiguration produces an error — a 500, a blank page, a stack trace — and you know to go and look. This one produces a calm, well-designed notice saying the service is not available in your region. From the outside it is indistinguishable from the feature working correctly, which means you can take your entire platform offline, look straight at the evidence, and conclude everything is fine.

Read the Before you enable anything section first. The rest of the page assumes you have.

geoRestrictionFailOpen decides what happens when the engine cannot read its own rules. geoRestrictionAdminBypass decides whether staff keep the admin panel. Both ship on, and both should stay on unless counsel has told you otherwise. With failOpen off and blockUnknownCountry on, a database hiccup or a lapsed lookup provider takes the whole platform down behind a compliance notice.

The three screens

Screen Path What it holds Permission
Country rules /admin/system/geo-restriction Which countries, what scope, why, and from when access.geo.restriction
Policy /admin/system/geo-restriction/settings The 21 platform-wide switches that decide how those rules are applied access.geo.restriction
Access log /admin/system/geo-restriction/log Every decision the engine has made, as evidence access.geo.restriction.log

The rules screen carries a banner across the top answering the only question that matters on arrival — is any of this actually being applied right now? It turns amber when rules exist and enforcement is off, and red in allowlist mode with no permitted countries.

The API keys behind them are view.geo.restriction, create.geo.restriction, edit.geo.restriction and delete.geo.restriction, plus access.geo.restriction.log and delete.geo.restriction.log for the log. The rule tester is gated on access.geo.restriction.

Before you enable anything

  1. Put your own address on the allowlist. Policy → Exceptions → Always-allowed IP addresses, one address or CIDR per line. This list is checked before every country rule and before the unknown-country switch, so an address on it can always reach the platform whatever else is misconfigured. Add your office and your monitoring system.

  2. Confirm the platform can determine a country at all. Policy → Detection. Either Use CDN country headers is on and your edge really sends one (Cloudflare cf-ipcountry, CloudFront, Vercel, Fastly, an nginx GeoIP header), or an IP geolocation provider is set. ip-api.com works with no key; ipinfo.io needs a token.

  3. Check the visitor's address is actually reaching the engine. A proxy on this machine needs no configuration in .env — but it does have to send a forwarding header (Apache: RequestHeader unset X-Forwarded-For; nginx: proxy_set_header X-Forwarded-For $remote_addr;), and a proxy on a different host has to be listed in TRUST_PROXY_CIDRS. Without one of those, every visitor arrives wearing the proxy's address and every country decision is being made about the wrong machine. The preflight detects this — it checks whether the address it ends up with is still a private one, not whether an environment variable happens to be set — and refuses to enable enforcement until it is fixed.

  4. Create the rules, but leave enforcement off. Rules are stored and inert while the master switch is off.

  5. Test them. The rule tester answers "would this visitor be refused" without waiting for one. See below.

  6. Then turn enforcement on, and read the preflight dialog rather than clicking past it.

Country rules

/admin/system/geo-restriction is a table over the geo_restriction model. It is paranoid — deleting a rule soft-deletes it, because the table is the evidentiary record of what was restricted, why, by whom and when, and a retired rule still has to be provable for the period it was in force. Nothing here is hard-deleted.

Each rule carries:

Field Values Notes
countryCode ISO 3166-1 alpha-2 Validated ^[A-Z]{2}$
countryName text Filled from the code
type BLOCK, ALLOW Default BLOCK
scope FULL, PARTIAL Default FULL
restrictedActions JSON array Meaningful only when scope is PARTIAL
reason SANCTIONS, UNLICENSED, REGULATORY, HIGH_RISK, INTERNAL_POLICY, OTHER The recorded legal basis. Purely descriptive — enforcement never branches on it
legalReference text, ≤255 The notice or licence you are acting under
notes text, ≤5000
status boolean
effectiveFrom / effectiveTo dates The scheduled window
createdBy / updatedBy user ids

A rule bites only while it is both status: true and inside its effectiveFrom/effectiveTo window. That is what lets you stage a compliance date in advance instead of flipping a switch by hand at midnight.

How the two types read depends on the policy mode:

  • BLOCKLIST (default) — everyone is allowed except countries with an active BLOCK rule. An ALLOW rule is an explicit carve-out that wins over a BLOCK rule for the same country.
  • ALLOWLIST — everyone is blocked except countries with an active ALLOW rule. A BLOCK rule still blocks, which is redundant but harmless and useful while switching modes.

The nine restrictable actions

A PARTIAL rule names the activities a country loses. The whole path-to-action map lives in one file so the control can be audited; adding a surface means adding a prefix there, not sprinkling checks through handlers.

Action What it covers
REGISTER Create an account
LOGIN Sign in
TRADE Place trades
DEPOSIT Deposit funds
WITHDRAW Withdraw funds
KYC Submit identity verification
P2P Use P2P trading
INVEST Invest, stake or join token sales
SWAP Swap tokens on-chain (DEX)

Matching is by URL path prefix with the query string stripped, and it is segment-aware: /api/finance/withdraw matches /api/finance/withdraw/spot but never /api/finance/withdrawal-history.

Bulk import

Creates rules for a pasted list of countries

Compliance lists arrive as lists — a sanctions notice names twenty jurisdictions, not one. This accepts up to 300 entries as alpha-2, alpha-3 or English country names, with one type, one reason, one legalReference and one notes applied to all of them, and status deciding whether they are enforced immediately or staged inactive.

It is deliberately additive: a country that already has a rule of the requested type is reported as skipped, not overwritten. An import can never quietly widen or narrow a restriction you tuned by hand. The response separates created, skipped and invalid so you can see exactly what happened to each entry.

Lists ISO countries with their current restriction state
Enables or disables rules in bulk

The policy: 21 keys

/admin/system/geo-restriction/settings, five tabs. Every key here is refused by the main platform settings PUT — they have their own permission (edit.geo.restriction), their own validation and their own audit trail, and an admin holding plain edit.settings must not be able to switch off country blocking with a hand-rolled payload. The exact refusal message and the full key list are in Settings key reference.

Enforcement

Key Label Default What it does
geoRestrictionEnabled Enforce geographic restrictions false The master switch. Off, rules are stored and nothing is blocked. Only a Super Admin can switch it back off.
geoRestrictionMode Policy mode BLOCKLIST BLOCKLIST or ALLOWLIST
geoRestrictionAllowAccountExit Allow existing users to withdraw and close out true The wind-down carve-out — see below
geoRestrictionBlockUnknownCountry Block when the country cannot be determined false Fail-closed for unplaceable visitors
geoRestrictionFailOpen Allow access if the geo engine itself fails true What to do when the rules cannot be read at all

Detection

Key Label Default Notes
geoRestrictionTrustCdnHeaders Use CDN country headers true Free, instant, and cannot be forged by the visitor. Leave on.
geoRestrictionLookupProvider IP geolocation provider NONE NONE, IP_API, IPINFO, IPAPI_CO
geoRestrictionLookupApiKey Provider API key "" Required for ipinfo.io. Never sent back to the browser; blank keeps what is saved. Hidden while the provider is NONE.
geoRestrictionLookupCacheTtl Lookup cache lifetime 86400 Seconds. Clamped 300 – 2,592,000. Hidden while the provider is NONE.
geoRestrictionBlockAnonymizedIps Block VPN, proxy and Tor connections false Only IP_API and IPINFO report these flags
geoRestrictionTrustKycCountry Use the country on approved identity documents true The strongest signal there is
geoRestrictionTrustProfileCountry Use the country on the user's profile false Self-declared and freely editable — evidence of intent, not of location

Signals are layered. Identity leads when it is available: a verified passport beats whatever network a request happens to arrive on, so a restricted user cannot step around the rule with a VPN and a permitted user is not blocked because they are travelling. Network signals still contribute the proxy/Tor flags and the city.

There are two gates. The platform-wide HTTP middleware is synchronous and does no I/O — a uWebSockets constraint, because a middleware that awaits before the handler attaches its body reader risks losing the request body — so it evaluates network signals only, from in-memory caches. The awaitable per-handler gate is what layers KYC and profile country on top. Network enforcement is universal; identity enforcement applies where a handler asks for it.

Exceptions

Key Label Default
geoRestrictionAdminBypass Administrators are never blocked true
geoRestrictionIpAllowlist Always-allowed IP addresses ""
geoRestrictionIpBlocklist Always-blocked IP addresses ""

Both IP lists accept one address or CIDR per line, and the parser also accepts commas, semicolons and arbitrary whitespace so a pasted list works unreformatted.

Admin bypass is matched on the /api/admin path prefix, not on the role. The middleware runs before authentication, so no role is known at that point — but every /api/admin route is requiresAuth with a permission check of its own, so letting them past the geo gate is precisely "authenticated staff bypass" and nothing more. Non-staff still get 401 or 403 immediately downstream. The frontend middleware performs the equivalent bypass for admin pages, where the session token has already been decoded and the role is known.

Notice

geoRestrictionNoticeTitle (≤200 chars), geoRestrictionNoticeMessage (≤2000) and geoRestrictionContactEmail (≤255), all blank by default. The message is both the page a restricted visitor sees and the message returned by the API — have it worded by whoever owns your legal position. Blank falls back to neutral wording that names the detected country.

Audit log

Key Label Default Notes
geoRestrictionLogMode What to record BLOCKED BLOCKED (blocked and bypassed only), ALL, NONE
geoRestrictionLogDedupeSeconds Collapse repeats within 300 0 – 86400. 0 records every decision separately
geoRestrictionLogRetentionDays Keep entries for 365 0 – 3650. 0 means keep forever

Paths that are never geo-evaluated

Some prefixes are exempt unconditionally, and the reasoning is worth knowing because it is what makes the feature recoverable:

  • /api/geo, /api/settings, /uploads — a restricted visitor has to be able to load the page that explains they are restricted. Blocking these leaves a blank screen instead of a compliance notice.
  • /api/health — load-balancer and uptime probes.
  • /api/admin/system/license and /api/admin/system/geo-restriction — operator recovery. These stay reachable even with admin bypass off, precisely so a bad rule can be undone.
  • /api/docs, /api/auth/csrf.
  • The two TransFi webhooks, /api/finance/deposit/fiat/transfi/webhook and /api/finance/withdraw/fiat/transfi/webhook. These are server-to-server callbacks that settle money already taken from a customer, and the caller's geography is the payment processor's data centre — TransFi delivers from Singapore only. Without the exemption a geo rule silently stops settlement, and on the payout side the customer's wallet has already been debited.

Wind-down: letting an existing customer get their money out

geoRestrictionAllowAccountExit ships on, and the reasoning is stated in the code as a principle: a country becoming restricted is the operator's policy change, not the customer's wrongdoing.

With it on, a customer in a newly restricted country under a FULL rule can still:

  • sign in, sign out, reset a password, use one-time codes, delete their account
  • see their profile, notifications, sessions and activity
  • submit and complete identity verification — because that is frequently a precondition of withdrawing, and closing it would close the exit it exists to enable
  • contact support
  • see their balances and transactions, and withdraw — spot and ecosystem

What they lose is registration, deposits, and taking on new exposure. /api/auth/register is deliberately absent from the carve-out list: signing up from a restricted country is the exact thing being prevented, and there is no existing balance to wind down.

With geoRestrictionAllowAccountExit off, a customer who already holds a balance cannot sign in, verify, contact support or withdraw. Their money stays on the platform with no route out — usually a far larger legal problem than the one the restriction solves. It also closes the sign-in route several of the recovery paths in this feature depend on. The preflight raises ACCOUNT_EXIT_CLOSED when you do it.

The rule tester

Simulates a decision against a hypothetical visitor. Writes nothing.

Test a rule on the rules screen opens it. Give it any of: a countryCode (omit it and the country is resolved from the IP instead), an ip, a path (default /api/exchange/order), an action to force instead of deriving one from the path, and isProxy / isTor to simulate an anonymised connection.

It runs the real evaluator, not a second copy of the logic, and reloads both the policy and the rules first so a rule you saved a moment ago is honoured rather than a 30-second-old snapshot. You get back allowed, decision, reasonCode, the message the visitor would see, the resolved location and the rules that matched. Nothing is logged — it is a simulation, not an access event, and it is explicitly excluded from the admin audit trail.

Use it twice: before enabling, to prove a restricted country is refused and your own country is not; and when a customer complains they were blocked, to reproduce the decision instead of guessing.

The preflight dialog

Previews the effect of a policy change before it is saved

Saving the policy runs ten checks and can refuse the save. This is asked before the settings are written because afterwards is too late: your next request goes through the policy you just saved, and if that policy refuses you, you have lost the screen you would use to undo it.

Findings are LOCKOUT or WARNING. A lockout stops the save; re-submitting with "force": true clears the ones that are judgement calls. Two are not forceable — the configurations that refuse 100% of traffic as a matter of arithmetic rather than of judgement.

Code Severity Forceable Fires when
NO_COUNTRY_SOURCE LOCKOUT no Block-unknown is on and this install has no way to determine anyone's country. Every request would be refused, and your country rules would never be reached.
ALLOWLIST_EMPTY LOCKOUT no Allowlist mode with no active ALLOW rules. "Block everyone except this list" over an empty list.
COUNTRY_SOURCE_UNVERIFIED LOCKOUT yes Block-unknown is on and detection is configured but has never actually resolved a country
ACTOR_BLOCKED LOCKOUT yes Your own address would be blocked from the public site, but admin bypass still lets you in
ACTOR_LOCKED_OUT LOCKOUT yes Your own address would be blocked and the admin exemption is off in the same change
UNTRUSTED_PROXY LOCKOUT on enable, else WARNING yes Forwarding headers or a loopback peer while TRUST_PROXY is not set
ADMIN_BYPASS_REMOVED WARNING yes You are turning off the staff exemption
ACCOUNT_EXIT_CLOSED WARNING yes You are turning off the wind-down carve-out
NO_IP_ESCAPE_HATCH WARNING yes Enforcement on with an empty always-allowed list
ANONYMIZER_BLOCK_INERT WARNING yes VPN blocking is on with a provider that does not report those flags — a switch that is on and does nothing
DOUBLE_FAIL_CLOSED WARNING yes failOpen off and block-unknown on
ENFORCEMENT_DISABLED_WITH_RULES WARNING yes You are switching enforcement off while rules exist

The actor check is the one to respect. It evaluates your address through the real engine as an ordinary visitor, and when it fails it tells you the reason code and the remedy: add your address to the always-allowed list before saving. Non-blocking warnings travel back with the successful save so you see them at the moment they are actionable.

Two more things happen on save. Switching enforcement off requires a Super Admin — that removes a legal control from the whole platform, whereas turning it on or tightening any other value does not. And forcing past a lockout is logged by name, with the finding codes and the administrator's id, because an override is a deliberate act with no other record of itself.

The self-rescue circuit breaker

The preflight stops you saving a total-lockout configuration. It cannot stop a saved-and-safe configuration from becoming one later, and the ways that happens are ordinary: a Cloudflare plan lapses so the country header stops arriving, a lookup provider's key is rotated, a redeploy drops TRUST_PROXY. Detection quietly stops working and "be careful with the handful of people I cannot place" becomes "refuse everyone".

Nobody is watching the settings screen when that happens, and your own view of your platform is the same notice your customers see. So the engine notices for itself. The bar is deliberately extreme — not one single country resolved, across at least fifty decisions, at least two minutes after start-up. When it trips it switches geoRestrictionBlockUnknownCountry off and nothing else: not the master switch, not the mode, not one country rule. It logs AUTOMATIC DISARM at error level and writes the corrected value back to the database.

If you see that line, country detection is broken. Fix it, verify with the rule tester, then turn the switch back on. Saving the policy yourself clears the override — at that point you have seen the state of the world and your intent supersedes the engine's guess.

The evidence log

/admin/system/geo-restriction/log is append-only and read-only in the admin panel by design: no create, no edit, no per-row delete. It is written by the enforcement engine and trimmed by the retention job, and nothing in the product may add to it or alter it. Unlike the rules table it is not paranoid — a soft-deleted audit row looks like evidence but is filtered out of every query.

Each row records the IP, the resolved country, region and city, the source the country came from (CDN_HEADER, IP_LOOKUP, KYC, PROFILE, MANUAL, NONE), the decision (BLOCKED, ALLOWED, BYPASSED), a machine-readable reasonCode such as COUNTRY_BLOCKED or ADMIN_BYPASS, the human reasonDetail, the matched restrictionId, the action, path, method, user id, user agent, the proxy/hosting/Tor flags, and hitCount — how many repeats the dedupe window collapsed into that one row.

Writes are best-effort: a blocked crawler retrying fifty times a second must not fill the disk, and a logging failure must never turn into a request failure.

Export last 90 days downloads a CSV. It is a direct browser download rather than an API call through the normal client, so the bytes reach the file intact.

Lists decisions
CSV export, filtered by a from timestamp
Purges entries past the retention window

Retention runs nightly

The purgeGeoAccessLog cron job runs every 24 hours, reads the policy fresh rather than off a possibly stale snapshot, and deletes entries older than geoRestrictionLogRetentionDays. With retention set to 0 it does nothing at all and logs that it kept everything — expiring compliance evidence is always a deliberate choice, never a silent default. Check what your jurisdiction requires before shortening it; this log is the proof that restricted traffic was actually turned away. See Scheduled jobs.

If you have locked yourself out

In order of preference:

  1. Reach the policy screen anyway. /api/admin/system/geo-restriction is permanently exempt from the gate, so the rules and policy screens answer even under a full block — as long as you can sign in. With geoRestrictionAllowAccountExit on, /api/auth/login is a carve-out path, so you usually can.

  2. Connect from an allowlisted address, if you added one.

  3. Run the doctor on the server. backend/scripts/geo-doctor.mjs talks straight to the database, so it works with no backend boot, no login, no HTTP and no admin session.

    node scripts/geo-doctor.mjs                        # diagnose, read-only
    node scripts/geo-doctor.mjs --fix                  # minimum repair to make the platform reachable
    node scripts/geo-doctor.mjs --allow-ip 203.0.113.4 --fix
    node scripts/geo-doctor.mjs --disable              # switch geo restrictions off entirely

    It needs no restart afterwards, and that is the reason to use it rather than editing the rows in a SQL client: a plain UPDATE on the settings table appears to do nothing at all, because two caches sit in front of it. Every process holds the settings in memory and only polls the __cacheVersion row while Redis pub/sub is down, and the shared Redis settings hash has no expiry. The script deals with both. The same trap, and the endpoints that clear the caches, are described in Settings key reference.

The first two entries are why the checklist at the top of this page starts with an IP allowlist entry.