Concepts

Geo policy

Block sign-ins from specific countries — or allow only a curated list — and override per-user when teams travel. Lives alongside the risk engine; the geo gate runs first, the risk score runs second.

The geo policy was built for a specific operator pain point: 2FA/authenticator push-spam from countries your users have never visited. The hard truth is that brute-force credential-stuffing traffic is geographically lopsided — and the cheapest mitigation is a per-project “don't even let these countries try” switch. The travel-grant escape hatch covers the legitimate “but my CTO is on a vacation in Tokyo” case without making operators choose between security and availability.

The three modes

project_geo_policies.mode is the single most important field. It accepts three values:

ModeBehaviourWhen to use
offGeo checks skipped entirely. The risk engine still uses country as a feeder signal for new_country / impossible_travel.Default for new projects.
blockcountries[] is a deny-list. Everything else is allowed.You see attack traffic from a handful of countries you don't do business in. Cap is 50 entries.
allow_onlycountries[] is the allow-list. Everything else is denied, including unknown countries.Hard-lockdown tenants. Fintech, defense, healthcare with strict residency requirements.
allow_only is strict about unknowns. If our geo-IP lookup returns an empty country for the request (e.g. brand-new IP allocation we haven't indexed yet), the request is denied. Keep this in mind during a global product launch — populate the list defensively.

Alert-only mode

Setting alert_only = true demotes a would-be block to an annotated auth.geo_alert event plus a contribution to the risk score (default +20, named country_in_policy_alert in the signal catalog). The request still completes; nothing visible breaks. Use this to shadow-deploy a new country list and watch the dashboard's “Recent geo-blocks” table for false positives before flipping the kill switch.

Flow scoping

Each row has an applies_to_* flag per auth flow. Defaults are sensible:

  • applies_to_passkey, applies_to_magic_link,applies_to_oauth, applies_to_step_up → all true by default.
  • applies_to_session_refreshfalse by default. Users mid-session shouldn't be ejected the moment their plane lands in a new country; this is the single most-asked-for default after the feature shipped.

Travel grants

A user_travel_grant is a per-user, time-bounded override. When the geo gate would block, it queries user_travel_grants for an active row covering the request country (or allow_any_country = true). A match demotes the block to allow — and stamps a geo_grant_used: tgt_… annotation on the risk_decisions row so operators can audit which grant covered which attempt.

Grant constraints:

  • Max 365 days. Issuing a longer window returns 400. Renewal is a fresh grant.
  • Either a countries[] list OR allow_any_country = true. Empty countries + allow_any_country = false is rejected.
  • Revoke is one-way. A revoked grant cannot be un-revoked — issue a new one if you change your mind.
Travel grants do not bypass the risk engine. If a user with an active grant signs in from Tokyo on a brand-new device with impossible-travel timing, the risk score will still be high enough to demand a passkey step-up. The grant only short-circuits the hard geo block.

Order of evaluation

For every primary-authentication request:

  1. Resolve the country via our MaxMind / DB-IP lookup. If Cloudflare also stamped CF-IPCountry and they agree, use that; if they disagree, prefer our lookup and log geoip.cloudflare_disagreement.
  2. Run the geo policy. If mode = off or the flow is out of scope, skip. Else evaluate block/allow_only + anonymous-proxy / satellite-provider trip-wires.
  3. If the geo policy says block AND there is no active travel grant covering this country → return 403 { error: "blocked_by_geo_policy", country } immediately. No risk score computed, no step-up issued.
  4. Otherwise, compute the normal risk score with all signals. The geo evaluator's outcome (allow / alert / grant-used) is annotated on risk_decisions.signals for the dashboard.
  5. Decision routes to allow → mint session, step-up → persist challenge, block → 403 (the existing risk-policy path).

Geo-IP data source

auth-core ships with a small embedded country fixture so it boots even when the upstream MaxMind / DB-IP feed is unreachable. Production picks up real data via:

  • AUTHIO_GEOIP_DB_PATH — operator pre-staged .mmdb file. Highest priority.
  • MAXMIND_LICENSE_KEY — auth-core downloads GeoLite2-Country from MaxMind weekly and caches it to /tmp/geoip-country.mmdb.
  • DB-IP free country-lite — fallback when no MaxMind key. Monthly cadence, no account required.

A loud geoip.fallback_to_fixture warning lands in the auth-core logs when both sources fail. The fixture covers the top ten countries by Authio MAU and exists only to keep the service answering — not as a substitute for real data.

See also

  • Risk engine — the per-sign-in scoring layer that the geo policy sits on top of.
  • Audit log — every geo event lands as auth.geo_blocked, auth.geo_alert, or auth.geo_grant_used for export.