---
title: "GeoIP and IP Reputation"
url: "https://effect-auth.itsbroly.com/authentication/abuse-protection/geoip-ip-reputation/"
description: "Enrich login risk with trusted edge metadata and bounded provider facts."
---



`GeoIp` supplies coarse location facts. `IpReputation` supplies proxy, VPN, Tor, datacenter, and risk facts. Neither decides authentication policy.

```mermaid title="Trusted Cloudflare metadata and optional provider facts feed explicit login policy"
flowchart LR
  R[Cloudflare Request] --> M[Trusted metadata]
  M --> E[Risk enrichment]
  E --> G[GeoIP facts]
  E --> I[IP reputation facts]
  G --> P[Application risk policy]
  I --> P
```

## Trust the request boundary [#trust-the-request-boundary]

`CloudflareRequestMetadata` reads structured `request.cf`, ignores arbitrary geo headers, and omits `CF-Connecting-IP` unless explicitly trusted:

```ts title="src/server/request-metadata.ts"
import { readCloudflareRequestMetadata } from "@effect-auth/core/CloudflareRequestMetadata";

export const readAuthMetadata = (request: Request) =>
  readCloudflareRequestMetadata(request, {
    coordinatePrecision: 1,
    trustCloudflareConnectingIp: true,
  });
```

Enable IP trust only when code runs behind a verified Cloudflare boundary. The `request` projection can contain transient IP/city. The bounded `durable` projection excludes them but still contains potentially sensitive country, region, coordinates, and location keys; apply purpose, consent, access, and retention controls.

Generic request metadata defaults `requestMetadata.ipSource` to `None`. Select exactly one tagged source, for example `{ _tag: "CloudflareConnectingIp" }`, only behind the matching controlled proxy/service binding that strips or overwrites client values. For `XForwardedFor`, `trustedHops` is the number of trusted entries at and after the selected client position in the XFF list; the current trusted peer is not in the header. Core selects `addresses.length - trustedHops`, requires 1 through 16, and omits IP when the list is shorter or any entry is malformed.

## Enrich login context [#enrich-login-context]

`LoginRiskEnricher` can query an app-provided `GeoIp`, cache by HMAC-derived key, coarsen coordinates, derive device/location keys, and filter reason codes. There is no first-party GeoIP database.

| Option                | Production decision                                                                     |
| --------------------- | --------------------------------------------------------------------------------------- |
| `geoIpLookupFailure`  | Exact `ignore` or `fail`; malformed provider/cache/key data follows this policy         |
| `geoIpTtlMillis`      | Integer 1 second through 24 hours; 15-minute default                                    |
| `coordinatePrecision` | Integer 0 through 6; default 1; invalid values reject rather than clamp                 |
| `cacheKey`            | Built-in HMAC factory, or expert callback returning an equally private canonical digest |
| memory cache capacity | Integer 1 through 10,000; default 1,000 with deterministic LRU eviction                 |
| `GeoIpCache`          | Bounded shared implementation for distributed Workers; validate rows and expiry         |

The bundled memory cache is process-local, bounded, and allocated per service/Layer build; it is not a distributed production cache. Construction snapshots callbacks/options and fails with value-free `SecurityConfigurationError`. Provider results require exact plain rows, bounded canonical country/region/city, finite in-range coordinates, exact booleans/risk levels, and bounded reason codes; malformed results are never cached. Callback typed failures and defects become value-free enrichment errors, while interruption is preserved. The built-in key factory provides bounded, domain-separated HMAC-SHA-256; custom factories must preserve that privacy and return a canonical digest.

Concurrent misses for the same key are not single-flight coalesced. This is a documented performance residual, not a correctness or privacy guarantee; distributed production adapters should add bounded request coalescing when provider cost or quota requires it.

`deviceKey` is always a canonical digest. `locationKey` is separate bounded coarse context such as `geo:US:CA`, not an HMAC/cache key; it can be retained in risk history, so never place an IP, coordinates, provider identifiers, or arbitrary request text in it. `CloudflareRequestMetadata` is not automatically connected to `LoginRiskEnricher`; project edge facts into login assessment explicitly instead of repeating a remote GeoIP lookup.

## Configure IPQualityScore [#configure-ipqualityscore]

Bind credentials with Alchemy:

```ts title="alchemy.run.ts"
import * as Cloudflare from "alchemy/Cloudflare";
import * as Config from "effect/Config";

const AuthWorker = Cloudflare.Worker("AuthWorker", {
  main: "./src/workers/auth-backend.ts",
  env: { IPQS_API_KEY: Config.redacted("IPQS_API_KEY") },
});
```

```ts title="src/server/ip-reputation.live.ts"
import * as IpQualityScore from "@effect-auth/core/IpQualityScore";
import {
  IpReputation,
  LoginRiskEngine,
  makeIpReputationRiskRule,
  makeLoginRiskEngine,
} from "@effect-auth/core/LoginRisk";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Redacted from "effect/Redacted";

export const makeLoginRiskLive = (apiKey: string) => {
  const IpReputationLive = IpQualityScore.layer({
    apiKey: Redacted.make(apiKey),
    afterRequestFailure: "ignore",
    timeoutMs: 2_000,
    scoreThresholds: { medium: 50, high: 75, critical: 90 },
  });

  return Layer.effect(
    LoginRiskEngine,
    Effect.map(IpReputation, (provider) =>
      makeLoginRiskEngine({
        emptyLevel: "low",
        rules: [
          makeIpReputationRiskRule({
            ipReputation: provider,
            options: { lookupFailure: "ignore" },
          }),
        ],
      })
    )
  ).pipe(Layer.provide(IpReputationLive));
};
```

The adapter validates IPs, uses fixed HTTPS transport, bounds responses, redacts the key, and returns compact facts. It does not cache, expose raw fraud score, set `blocked`, or decide allow/deny. The generic rule skips missing IP and defaults to ignoring all typed provider errors; use app policy when outages must differ from invalid credentials/quota.

## Privacy and tests [#privacy-and-tests]

* Keep raw IP, user agent, city, provider payload, request IDs, and diagnostics transient.
* Derive cache/device keys with an application-held HMAC key; device fingerprints are heuristics, not proof.
* Persist only required coarse facts with explicit retention.
* Allowlist, deduplicate, and cap reason codes before persistence.
* Test spoofed forwarding headers, Cloudflare IP opt-in, coordinate precision, no-IP behavior, provider auth/quota/outage errors, and absence of raw inputs from logs/stores.

Risk facts should drive an explicit outcome such as continue, MFA, review, or deny. Vendor score changes must not silently change authentication policy.

