effect-auth

GeoIP and IP Reputation

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.

Trusted Cloudflare metadata and optional provider facts feed explicit login policy

Trust the request boundary

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

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

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.

OptionProduction decision
geoIpLookupFailureExact ignore or fail; malformed provider/cache/key data follows this policy
geoIpTtlMillisInteger 1 second through 24 hours; 15-minute default
coordinatePrecisionInteger 0 through 6; default 1; invalid values reject rather than clamp
cacheKeyBuilt-in HMAC factory, or expert callback returning an equally private canonical digest
memory cache capacityInteger 1 through 10,000; default 1,000 with deterministic LRU eviction
GeoIpCacheBounded 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

Bind credentials with Alchemy:

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") },
});
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

  • 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.

On this page