Skip to content

Search docs

Jump between documentation pages.

Browse docs

GeoIP / geo-blocking

DaloyJS ships geoBlock(), a country allow/deny middleware that maps the client IP to a country and rejects (or logs) traffic from countries you don't serve. It is the compliance/abuse counterpart to ipRestriction() and ipReputation().

  • No bundled database: Daloy ships no GeoIP data and adds no runtime dependency. You supply the IP → country mapping (a MaxMind reader, an ip2location reader, your own table) or read a country header injected by your edge.
  • Two strategies, pick one: lookupCountry(ip) when you own the lookup, or resolveCountry(ctx) when an upstream already attached the country.
  • Fail-closed allow-lists: when an allow list is configured, an unknowncountry is rejected by default (it's not on the list). Deny-only configurations fail open. Both are overridable with allowUnknownCountry.
  • Reuses trusted-proxy IP resolution: the same X-Forwarded-For / X-Real-IP handling (off by default, opt in with trustProxyHeaders) as the other network guards.
Country decision
  1. requestResolve countrylookupCountry(ip) or resolveCountry(ctx) header
  2. On deny list?a deny match always wins (least privilege)
  3. Allow list set + not listed?only listed countries pass
  4. Unknown country?allow-list -> blocked (fail closed); deny-only -> allowed
  5. Permitted -> handlercountry stamped on ctx.state.geo
Deny is evaluated first, then the allow-list gate. An unresolved country fails closed when an allow-list is configured and fails open for deny-only setups, both overridable with allowUnknownCountry. A blocked request returns 403 without echoing the country.

Strategy 1: bring your own IP → country lookup

Use any GeoIP reader as an operator dependency. Daloy resolves the client IP and hands it to lookupCountry; return an ISO 3166-1 alpha-2 code (or nothing when the IP can't be mapped).

ts
import { createApp } from "@daloyjs/core";
import { geoBlock } from "@daloyjs/core";
import maxmind, { type CountryResponse } from "maxmind"; // your dependency, not Daloy's

const app = createApp();

const reader = await maxmind.open<CountryResponse>("./GeoLite2-Country.mmdb");

app.use(
  geoBlock({
    // Block sanctioned/embargoed regions, allow everyone else.
    deny: ["KP", "IR", "SY", "CU"],
    // Only trust X-Forwarded-For when a proxy you control sets it.
    trustProxyHeaders: true,
    lookupCountry: (ip) => reader.get(ip)?.country?.iso_code,
  }),
);

Strategy 2: read an edge-injected country header

If your app runs behind a CDN or platform that already geolocates the request, skip the IP lookup entirely and read the header. No proxy-trust configuration is needed because you are not parsing X-Forwarded-For yourself.

ts
app.use(
  geoBlock({
    // Allow-list: only these countries may reach the app.
    allow: ["US", "CA", "GB", "DE", "FR"],
    resolveCountry: (ctx) => ctx.request.headers.get("cf-ipcountry"),
  }),
);

Deployment-platform country headers

Most edges expose the resolved country as a request header, which makes resolveCountry a one-liner. Common values:

ts
// Cloudflare (Workers / proxied):      CF-IPCountry
geoBlock({ allow, resolveCountry: (c) => c.request.headers.get("cf-ipcountry") });

// AWS CloudFront:                       CloudFront-Viewer-Country
geoBlock({ allow, resolveCountry: (c) => c.request.headers.get("cloudfront-viewer-country") });

// Vercel:                               x-vercel-ip-country
geoBlock({ allow, resolveCountry: (c) => c.request.headers.get("x-vercel-ip-country") });

// Fastly (configured VCL):              Fastly-Geo-Country / a header you set
geoBlock({ allow, resolveCountry: (c) => c.request.headers.get("fastly-geo-country") });

On platforms that do not inject a country header (a bare Node / Bun / Deno deployment, or a VPS), use Strategy 1 with a local MaxMind database and trustProxyHeadersmatched to your proxy chain. Cloudflare's CF-IPCountry can also be XX (unknown) or T1 (Tor). Those are treated as an unknown country unless you list them explicitly.

Allow-list vs. deny-list semantics

  • deny: listed countries are always rejected; a deny match wins over an allow match (least privilege).
  • allow: when non-empty, only listed countries pass; everything else (including an unresolved country) is rejected.
  • both: deny is evaluated first, then the allow-list gate.

Country codes are case-insensitive and validated at construction, so a typo like "USA" throws immediately rather than silently never matching.

Unknown countries

When the country can't be resolved (no IP, no mapping, empty header), the default is:

  • allow-list configuredblocked (fail closed).
  • deny-onlyallowed (fail open).

Override either way with allowUnknownCountry.

Monitoring before enforcing

Roll out safely with mode: "log": requests are never blocked, but onBlock fires for every would-be block so you can measure impact first.

ts
app.use(
  geoBlock({
    allow: ["US", "CA"],
    mode: "log", // observe only, nothing is blocked yet
    resolveCountry: (c) => c.request.headers.get("cf-ipcountry"),
    onBlock: (d) => {
      // d.reason: "denied_country" | "not_in_allowlist" | "unknown_country"
      console.warn("geo would-block", d.reason, d.country, d.ip);
    },
  }),
);

Reading the country downstream

For allowed requests, the resolved country is stamped on ctx.state.geo (rename with stateKey), so handlers can localise or audit without a second lookup.

ts
app.get(
  "/pricing",
  {
    operationId: "pricing",
    responses: { 200: { description: "ok" } },
  },
  (ctx) => {
    const country = (ctx.state.geo as { country?: string } | undefined)?.country;
    return { status: 200 as const, body: { currency: country === "GB" ? "GBP" : "USD" } };
  },
);

Rejection response

A blocked request throws ForbiddenError, rendered as RFC 9457 application/problem+json with HTTP 403 and Cache-Control: no-store. The default message ("Access from your region is not permitted") is configurable via message and deliberately does not echo the country or IP back to the client.

Security notes

  • Geo-blocking is a compliance / abuse-reduction tool, not an authentication control. VPNs and proxies defeat it; pair it with real auth.
  • Only set trustProxyHeaders when every request reaches Daloy through a proxy chain you control. Otherwise X-Forwarded-For is attacker-spoofable.
  • Keep your GeoIP database current; stale data misclassifies reassigned ranges.