Search docs

Jump between documentation pages.

Browse docs

GeoIP / geo-blocking

As of 0.37.0 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.

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 — 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", (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.