Skip to content

IP Geolocation — Source-Agnostic IP → Location

Knowing roughly where a request comes from powers several Granit features: flagging suspicious sessions (“logged in from Brussels, then from São Paulo eight minutes later”), enriching the session list a user sees in their security settings, and feeding fraud heuristics. But “resolve an IP to a place” hides two traps:

  • Provider lock-in. ip-api.com, MaxMind GeoIP2, DB-IP, IP2Location — each returns a different JSON shape, different field names, different granularity. Coding against one provider’s response means rewriting every consumer when you switch.
  • Privacy. An IP address is personal data under GDPR. The naive cache — ip → location keyed by the raw IP, or by an unkeyed hash of it — is reversible back to the IP from a cache dump. Geolocation that leaks the very identifier it was meant to soften is worse than none.

Granit.IpGeolocation answers both. Consumers depend only on IIpGeolocationResolver and the source-agnostic GeoLocation record; providers are opt-in plug-ins behind a fallback chain; and the cache is keyed with HMAC-SHA-256 once you supply a secret.

  • DirectoryGranit.IpGeolocation.Abstractions/ GeoLocation read-model, IpMasking — zero dependencies, safe to reference from contracts
  • DirectoryGranit.IpGeolocation/ IIpGeolocationResolver, fallback chain, FusionCache caching, the cache-key security knob
  • DirectoryGranit.IpGeolocation.IpInfo/ ipinfo.io HTTP provider (online, no local data)
  • DirectoryGranit.IpGeolocation.MaxMind/ MaxMind GeoIP2 / DB-IP .mmdb provider (offline, in-process)
PackageRoleDepends on
Granit.IpGeolocation.AbstractionsGeoLocation record, IpMasking utility(none — contract-only)
Granit.IpGeolocationIIpGeolocationResolver, IIpGeolocationProvider, cachingGranit.Caching
Granit.IpGeolocation.IpInfoIpInfoIpGeolocationProvider (online HTTP lookup)Granit.IpGeolocation
Granit.IpGeolocation.MaxMindMaxMindIpGeolocationProvider (offline .mmdb lookup)Granit.IpGeolocation

GeoLocation is a sealed record with every member optional — a provider populates only what its data source supports. An offline country-only database leaves City and the coordinates null; an online provider may fill them all.

public sealed record GeoLocation
{
public string? City { get; init; } // "Brussels"
public string? Region { get; init; } // "Brussels-Capital"
public string? Country { get; init; } // "Belgium"
public string? CountryCode { get; init; } // ISO 3166-1 alpha-2 — "BE"
public double? Latitude { get; init; }
public double? Longitude { get; init; }
}

The shape is deliberately provider-agnostic so future, non-IP geocoding modules (address → coordinates) can reuse the same contract.

Inject IIpGeolocationResolver. It short-circuits absent, private, loopback, and link-local addresses to null, never throws, and degrades to the next provider when one fails.

public sealed class SessionEnricher(IIpGeolocationResolver resolver)
{
public async Task<GeoLocation?> LocateAsync(string? ip, CancellationToken ct)
=> await resolver.ResolveAsync(ip, ct); // null for private / unparseable IPs
}
public interface IIpGeolocationResolver
{
Task<GeoLocation?> ResolveAsync(string? ipAddress, CancellationToken cancellationToken = default);
}

Register one or both providers. The resolver tries them in ProviderOrder (or registration order if unset) and returns the first non-null hit.

Best default: a local .mmdb file, no external calls, no per-request latency or rate limit.

builder.AddGranitIpGeolocationMaxMind();
{
"IpGeolocation": {
"MaxMind": {
"DatabasePath": "/var/lib/geoip/GeoLite2-City.mmdb",
"ReloadOnChange": true,
"FileAccess": "Memory"
}
}
}

The file must exist at startup (validated). FileAccess: "Memory" loads the whole database into RAM and releases the OS handle — recommended, because it lets you atomically replace the file on disk while the app runs. "MemoryMapped" trades a lower RAM footprint for a held file handle.

The MaxMind provider reads any MaxMind-DB-format (.mmdb) file — not only MaxMind’s own GeoIP2. That includes DB-IP databases, published by a Belgian (EU) company.

Results (positive and negative) are cached via IFusionCache for CacheDuration (default 1 hour). Lookups go through GetOrSetAsync, so concurrent requests for the same uncached IP are coalesced into a single provider call — a burst on one address resolves once, not once per request (per instance; a shared cache still resolves once per node on first miss). When Granit.Caching.StackExchangeRedis is installed, that cache is shared across pods — which is exactly where the privacy trap lives.

When the resolver starts with providers registered but no secret configured, it logs this warning once:

warn: IP geolocation cache keys use an unkeyed SHA-256 hash. Over a shared cache this is
reversible to the originating IPv4 address from a cache dump (GDPR personal data). Set
'IpGeolocation:CacheKeySecret' (sourced from configuration/Vault, stable across
instances) to key the hash with HMAC-SHA-256.
{
"IpGeolocation": {
"CacheKeySecret": "${IPGEO_CACHE_KEY_SECRET}",
"CacheDuration": "01:00:00",
"ResolvePrivateAddresses": false
}
}

IpMasking.Mask(ip) zeros the host portion of an address — IPv4 to /24 (203.0.113.42203.0.113.0), IPv6 to /48. Use it for data minimization when an IP must appear on a contract or API surface, distinct from log redaction.

sequenceDiagram
    participant C as Consumer
    participant R as IIpGeolocationResolver
    participant Cache as IFusionCache
    participant P1 as MaxMind (offline)
    participant P2 as ipinfo (online)

    C->>R: ResolveAsync("203.0.113.42")
    R->>R: parse — private/loopback? → null
    R->>Cache: GetOrSet(HMAC-keyed cache key)
    alt cache hit
        Cache-->>R: GeoLocation
    else cache miss
        R->>P1: ResolveAsync (in-process .mmdb)
        alt found
            P1-->>R: GeoLocation
        else not found / error
            R->>P2: ResolveAsync (HTTP)
            P2-->>R: GeoLocation or null
        end
    end
    R-->>C: GeoLocation?
KeyTypeDefaultDescription
IpGeolocation:CacheKeySecretstring?nullHMAC key for cache-key hashing. Unset = reversible SHA-256 (GDPR risk).
IpGeolocation:CacheDurationTimeSpan01:00:00TTL for positive and negative results.
IpGeolocation:ResolvePrivateAddressesboolfalseWhen false, short-circuit private/loopback/link-local IPs to null.
IpGeolocation:ProviderOrderstring[][]Provider fallback order; empty = registration order.
IpGeolocation:MaxMind:DatabasePathstring(required)Path to the .mmdb file; must exist at startup.
IpGeolocation:MaxMind:ProviderNamestring"MaxMind"Identifier used in ProviderOrder.
IpGeolocation:MaxMind:ReloadOnChangebooltrueReload the database when the file changes.
IpGeolocation:MaxMind:FileAccessenumMemoryMemory (load to RAM, release handle) or MemoryMapped.
IpGeolocation:IpInfo:ApiTokenstring?nullipinfo.io token sent as Bearer; tokenless tier is rate-limited.
IpGeolocation:IpInfo:BaseAddressUrihttps://ipinfo.ioProvider base address. Must be HTTPS for non-loopback hosts — the request carries the client IP and the API token, so a plaintext http URL to a remote host is rejected at startup (loopback allowed for dev/mocks).
IpGeolocation:IpInfo:TimeoutTimeSpan00:00:03Per-request timeout.
IpGeolocation:IpInfo:MaxResponseSizeByteslong65536Max buffered response size.
CategoryKey typesPackage
Read-modelGeoLocation, IpMaskingGranit.IpGeolocation.Abstractions
ResolverIIpGeolocationResolver, IIpGeolocationProviderGranit.IpGeolocation
OptionsGranitIpGeolocationOptionsGranit.IpGeolocation
ipinfo providerAddGranitIpGeolocationIpInfo(), IpInfoIpGeolocationOptionsGranit.IpGeolocation.IpInfo
MaxMind providerAddGranitIpGeolocationMaxMind(), MaxMindIpGeolocationOptions, MaxMindFileAccessGranit.IpGeolocation.MaxMind
  • GDPR Art. 5(1)(c) — data minimization: IpMasking and the country/city-only read-model keep raw IPs off exposed surfaces.
  • GDPR Art. 5(1)(f) — integrity and confidentiality: HMAC-keyed cache keys stop a cache dump from being reversed to the originating IP (one control — supports the principle, not standalone compliance).
  • User Sessions — consumes GeoLocation for impossible-travel and new-country anomaly detection
  • Identity — the session list enriched with geolocation and risk
  • BFF — the SPA-facing session endpoint that surfaces location per session
  • Caching — the IFusionCache backing the geolocation cache
  • Vault & Encryption — where to source CacheKeySecret