Product documentation
Restrict logins by country (geo-blocking)
Configure a per-tenant country allowlist that gates Thoryn-managed credential logins — the three enforcement modes (off / warn / block), the customer-plane /api/v1/geo-policy API, the operator switches, and the self-lockout safeguards — grounded in the shipped code.
Restrict logins by country (geo-blocking)
Geo-blocking lets a tenant admin restrict where its users may sign in from: a per-tenant allowlist of ISO-3166-1 alpha-2 country codes, enforced at login. The country is resolved from the login IP by an in-process GeoIP dataset — there is no outbound call on the sign-in path.
Scope — what geo-blocking gates
Geo-blocking gates Thoryn-managed credential logins (users who authenticate against Thoryn's own identity-service). Logins federated to an enterprise IdP (Microsoft Entra ID, Okta, Google Workspace, …) authenticate at that IdP and are brokered by the hub; geo-blocking those is not in scope here — enforce location policy at the upstream IdP instead.
This page documents the configuration API (/api/v1/geo-policy) and the
enforcement it drives. A console UI for the same workflow is planned as the
thoryn-web half of the same story and is not yet shipped — until it lands,
configure geo-blocking through the API below (or the thoryn CLI).
The three modes
The policy has a single mode plus the country allowlist:
| Mode | A login from a NON-allowlisted country is… | Use it to |
|---|---|---|
off | Allowed. No geo constraint. | Turn the control off (the default). |
warn | Allowed, but audited (login.geo_warning). | Observe impact safely before enforcing — the recommended first step. |
block | Denied, and audited (login.geo_blocked). | Enforce the allowlist. |
Two rules keep the control from silently locking a tenant out:
- An empty allowlist is always inert. Whatever the configured mode, the
effective mode is
offwhen the allowlist is empty. The response echoes aneffectiveModefield so a client can warn that awarn/blockmode with no countries does nothing. blockwith an empty allowlist is rejected (it would deny every login). The API returns400 empty_block_allowlist.
Configure it — step by step
The API is reached through the public gateway at https://api.<env>.thoryn.org.
Every call carries a tenant admin's bearer token; the tenant is taken from the
token's tnt claim, so a caller only ever sees and writes its own tenant's
policy. Reads need the tenant:geo.read scope; writes need tenant:geo.write.
-
Read the current policy. An unconfigured tenant returns
mode: off, an empty allowlist, andpolicyVersion: 0.curl -s https://api.stg.thoryn.org/api/v1/geo-policy \ -H "Authorization: Bearer $TOKEN" -
Roll out in
warnfirst. Set the allowlist and observe thelogin.geo_warningaudit events for logins that would be blocked, without denying anyone.curl -s -X PUT https://api.stg.thoryn.org/api/v1/geo-policy \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"mode":"warn","allowedCountries":["NL","DE","US"]}' -
Switch to
blockonce the warn-mode audit trail confirms your allowlist covers your real users.curl -s -X PUT https://api.stg.thoryn.org/api/v1/geo-policy \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"mode":"block","allowedCountries":["NL","DE","US"]}' -
Remove the constraint at any time — reset to no geo policy:
curl -s -X DELETE https://api.stg.thoryn.org/api/v1/geo-policy \ -H "Authorization: Bearer $TOKEN"
Country codes are normalised on write (trimmed, upper-cased, de-duplicated), so
["nl"," de "] stores as ["DE","NL"].
Endpoint reference
The full generated request/response schema is on the GeoPolicy API reference page.
| Method | Path | Description | Scope |
|---|---|---|---|
GET | /api/v1/geo-policy | Read the stored policy, its effective mode, and the supported modes. 200 always. | tenant:geo.read |
PUT | /api/v1/geo-policy | Upsert the mode + allowlist. Validated; 200 with the saved state. | tenant:geo.write |
DELETE | /api/v1/geo-policy | Reset the tenant to no geo constraint. 204. | tenant:geo.write |
The GET/PUT response body carries mode, allowedCountries, effectiveMode,
supportedModes, policyVersion, and updatedAt.
Configuration (operators)
The tenant-writable policy above rides on two platform switches that gate the whole control at deploy level (identity-service). They are not tenant-writable.
oauthy:
identity:
geo-blocking:
# Master switch. true (default) → honour each tenant's stored policy.
# false → the enforcer always allows (the control is inert fleet-wide) —
# the break-glass if geo enforcement must be disabled without editing rows.
enabled: true
# What to do when the login IP cannot be resolved to a country (private /
# loopback IP, or an unmapped public range). true (default) → fail OPEN
# (allow) so a dataset gap or carrier-grade NAT is never a self-inflicted
# lockout. false → a block-mode tenant denies a login of unknown location.
fail-open-on-unresolved: trueTroubleshooting
| Symptom | Cause | Fix |
|---|---|---|
400 invalid_mode | mode was not one of off / warn / block. | Send a supported mode token. |
400 invalid_country_code (extension field: allowedCountries) | An entry was not a two-letter ISO-3166-1 alpha-2 code. | Use alpha-2 codes (NL, DE), not names or alpha-3. |
400 empty_block_allowlist | mode: block with an empty allowlist — it would deny every login. | Add at least one country, or use warn/off. |
403 on a read/write | The token lacks tenant:geo.read / tenant:geo.write. | Grant the scope to the client (see below) and re-authenticate. |
503 facade_not_configured | The deployment has not mounted the projected identity-facade token. | An operator concern — the chart mounts it; not reachable in local-dev without it. |
502 upstream_identity_error | identity-service was unreachable or errored. | Transient; retry. Persistent → check identity-service health. |
A block policy is not denying anyone | The allowlist is empty, so effectiveMode is off; or the master switch is false; or the login country is unresolved and fail-open-on-unresolved is true. | Check effectiveMode in the GET response and the operator switches. |
Enabling the scopes
The tenant:geo.read / tenant:geo.write scopes are granted to the
customer-plane clients (console BFF, thoryn CLI) by hub migration
V101__add_geo_policy_scopes_to_customer_plane_clients.sql. A client must carry
the scope for the hub to mint it into the token; requesting an un-granted scope
fails sign-in with invalid_scope.
Security notes
- Self-lockout is a real risk, and the config plane cannot fully prevent it.
A tenant that sets
blockwith an allowlist excluding its own admins' country can lock those admins out of their next credential login. The config API deliberately does not try to auto-detect and refuse that: a login's country is derived from the login IP at sign-in, not from the admin's token or this configuration request (which reaches product-api through the gateway, not from the admin's own client IP), so any config-plane country self-check would be unreliable by construction. Instead the risk is contained by rolling out inwarnfirst, by the empty-allowlist / block-with-empty-allowlist safeguards above, and — as a last-resort break-glass — the operator master switchoauthy.identity.geo-blocking.enabled=false. - The control fails open on every uncertainty. Master switch off, no policy,
empty allowlist, a store-read error, and (by default) an unresolved country all
allow the login. Only an explicit
block-mode policy with a resolved, non-allowlisted country denies. Geo-blocking is a hardening layer, never the platform's only defence. - Cross-tenant access is structural, not a check. The tenant is taken from the
tntclaim and scoped into every query, so a caller cannot read or write another tenant's policy — there is no cross-tenant identifier to supply. - Error responses are sanitised. Validation
detailstrings echo only your own input; they never leak another tenant's stored countries or token contents.