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.
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
-
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.
-
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.comworks with no key;ipinfo.ioneeds a token. -
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 inTRUST_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. -
Create the rules, but leave enforcement off. Rules are stored and inert while the master switch is off.
-
Test them. The rule tester answers "would this visitor be refused" without waiting for one. See below.
-
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
BLOCKrule. AnALLOWrule is an explicit carve-out that wins over aBLOCKrule for the same country. - ALLOWLIST — everyone is blocked except countries with an active
ALLOWrule. ABLOCKrule 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
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.
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/licenseand/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/webhookand/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
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
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.
from timestampRetention 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:
-
Reach the policy screen anyway.
/api/admin/system/geo-restrictionis permanently exempt from the gate, so the rules and policy screens answer even under a full block — as long as you can sign in. WithgeoRestrictionAllowAccountExiton,/api/auth/loginis a carve-out path, so you usually can. -
Connect from an allowlisted address, if you added one.
-
Run the doctor on the server.
backend/scripts/geo-doctor.mjstalks 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 entirelyIt needs no restart afterwards, and that is the reason to use it rather than editing the rows in a SQL client: a plain
UPDATEon 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__cacheVersionrow while Redis pub/sub is down, and the shared Redissettingshash 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.