IP Geolocation — Source-Agnostic IP → Location
Why a geolocation abstraction?
Section titled “Why a geolocation abstraction?”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 → locationkeyed 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.
Package structure
Section titled “Package structure”DirectoryGranit.IpGeolocation.Abstractions/
GeoLocationread-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
.mmdbprovider (offline, in-process)- …
| Package | Role | Depends on |
|---|---|---|
Granit.IpGeolocation.Abstractions | GeoLocation record, IpMasking utility | (none — contract-only) |
Granit.IpGeolocation | IIpGeolocationResolver, IIpGeolocationProvider, caching | Granit.Caching |
Granit.IpGeolocation.IpInfo | IpInfoIpGeolocationProvider (online HTTP lookup) | Granit.IpGeolocation |
Granit.IpGeolocation.MaxMind | MaxMindIpGeolocationProvider (offline .mmdb lookup) | Granit.IpGeolocation |
The GeoLocation read-model
Section titled “The GeoLocation read-model”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.
Resolving an IP
Section titled “Resolving an IP”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);}Providers & the fallback chain
Section titled “Providers & the fallback chain”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.
An HTTP lookup against ipinfo.io — no local data to ship or refresh, at the cost of a network round-trip and a rate limit on the tokenless tier.
builder.AddGranitIpGeolocationIpInfo();{ "IpGeolocation": { "IpInfo": { "ApiToken": "${IPINFO_TOKEN}", "Timeout": "00:00:03" } }}The token is sent as a Bearer header; lookup failures are logged without the request
URI (which carries the IP). Auto-redirect is disabled and the response is size-bounded.
BaseAddress must be HTTPS for any non-loopback host: the request carries the client IP
(personal data) and the API token, so a plaintext http URL to a remote host is rejected at
startup. http stays allowed for loopback, so local mocks and integration tests still work.
Run MaxMind first (fast, offline), fall through to ipinfo only when the local database has no answer.
builder.AddGranitIpGeolocationMaxMind();builder.AddGranitIpGeolocationIpInfo();{ "IpGeolocation": { "ProviderOrder": ["MaxMind", "IpInfo"] }}Caching & the cache-key security knob
Section titled “Caching & the cache-key security knob”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 }}Masking for exposure
Section titled “Masking for exposure”IpMasking.Mask(ip) zeros the host portion of an address — IPv4 to /24
(203.0.113.42 → 203.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.
How resolution flows
Section titled “How resolution flows”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?
Configuration reference
Section titled “Configuration reference”| Key | Type | Default | Description |
|---|---|---|---|
IpGeolocation:CacheKeySecret | string? | null | HMAC key for cache-key hashing. Unset = reversible SHA-256 (GDPR risk). |
IpGeolocation:CacheDuration | TimeSpan | 01:00:00 | TTL for positive and negative results. |
IpGeolocation:ResolvePrivateAddresses | bool | false | When false, short-circuit private/loopback/link-local IPs to null. |
IpGeolocation:ProviderOrder | string[] | [] | Provider fallback order; empty = registration order. |
IpGeolocation:MaxMind:DatabasePath | string | (required) | Path to the .mmdb file; must exist at startup. |
IpGeolocation:MaxMind:ProviderName | string | "MaxMind" | Identifier used in ProviderOrder. |
IpGeolocation:MaxMind:ReloadOnChange | bool | true | Reload the database when the file changes. |
IpGeolocation:MaxMind:FileAccess | enum | Memory | Memory (load to RAM, release handle) or MemoryMapped. |
IpGeolocation:IpInfo:ApiToken | string? | null | ipinfo.io token sent as Bearer; tokenless tier is rate-limited. |
IpGeolocation:IpInfo:BaseAddress | Uri | https://ipinfo.io | Provider 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:Timeout | TimeSpan | 00:00:03 | Per-request timeout. |
IpGeolocation:IpInfo:MaxResponseSizeBytes | long | 65536 | Max buffered response size. |
Public API summary
Section titled “Public API summary”| Category | Key types | Package |
|---|---|---|
| Read-model | GeoLocation, IpMasking | Granit.IpGeolocation.Abstractions |
| Resolver | IIpGeolocationResolver, IIpGeolocationProvider | Granit.IpGeolocation |
| Options | GranitIpGeolocationOptions | Granit.IpGeolocation |
| ipinfo provider | AddGranitIpGeolocationIpInfo(), IpInfoIpGeolocationOptions | Granit.IpGeolocation.IpInfo |
| MaxMind provider | AddGranitIpGeolocationMaxMind(), MaxMindIpGeolocationOptions, MaxMindFileAccess | Granit.IpGeolocation.MaxMind |
Compliance
Section titled “Compliance”- GDPR Art. 5(1)(c) — data minimization:
IpMaskingand 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).
See also
Section titled “See also”- User Sessions — consumes
GeoLocationfor 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
IFusionCachebacking the geolocation cache - Vault & Encryption — where to source
CacheKeySecret