Geo and Device Targeting from Request Headers
This how-to is part of Context Enrichment Strategies for Targeting. You want to roll a feature to one country first, or gate it to mobile devices, and the data you need is already sitting in the request headers — a CDN geo header and a user agent. This how-to derives clean geo and device attributes from those headers, adds them to the evaluation context, and sidesteps the spoofing and privacy pitfalls that header-derived data carries.
The reason headers are attractive is that they arrive for free on every request — you pay no extra latency and make no extra network call to enrich the context, unlike a profile lookup that hits a database. But that convenience hides two sharp edges. First, most headers can be forged by whoever sends the request, so the provenance of a value matters as much as the value itself. Second, the moment you turn an IP or a device string into a stored attribute, you may have created personal data that carries retention and consent obligations. The steps below treat both edges as first-class: derive only from trusted hops, and keep the derived set as coarse as the rollout allows.
Prerequisites
Step-by-Step Procedure
Step 1 — Read the geo attribute from a trusted header only
Use the country your CDN or edge injected, never one the client can set. The CDN populates its geo header after the client can no longer influence it — Cloudflare’s CF-IPCountry, App Engine’s X-Appengine-Country, and Fastly’s geo VCL variables are all written at a hop the request originator cannot reach. The critical detail is that these headers are overwritten at the edge even if the client already sent a header of the same name, so the edge value wins. If you terminate TLS yourself behind the CDN, confirm your ingress does not accidentally forward or merge a client-supplied duplicate; the safest posture is to strip any inbound CF-IPCountry at your own boundary and re-inject only the trusted one. Note the two sentinel values worth special-casing: XX means the edge could not determine a country, and T1 marks traffic exiting the Tor network — both should collapse to “no geo” rather than a real country, because targeting on them would be meaningless.
# geo.py — trust only the edge-populated header
def country_from_request(headers) -> str | None:
# CF-IPCountry / X-Appengine-Country are set by the edge, not the client
c = headers.get("CF-IPCountry")
return c if c and c not in ("XX", "T1") else None # unknown / Tor -> no geo
Step 2 — Classify the device from client hints
Prefer Sec-CH-UA-Mobile and related client hints over parsing the user-agent string, which is brittle and increasingly frozen. Client hints are a boolean-and-token protocol rather than a free-text string: Sec-CH-UA-Mobile is exactly ?1 or ?0, which removes the guesswork of regex-matching a hundred device substrings. The catch is that hints are not sent by default — the browser only emits the low-entropy set (mobile, platform, brand) automatically, and even those can be absent on the first request before the server has advertised them. If you need a hint reliably, send a Accept-CH response header (for example Accept-CH: Sec-CH-UA-Mobile, Sec-CH-UA-Platform) so the browser includes it on subsequent requests. Because the first request in a session may lack the hint, keep the user-agent fallback below — but treat it as a coarse last resort, matching only broad keywords rather than parsing version numbers that browsers deliberately clamp.
# device.py — client hints first, UA as fallback
def device_class(headers) -> str:
mobile = headers.get("Sec-CH-UA-Mobile")
if mobile is not None:
return "mobile" if mobile == "?1" else "desktop"
ua = headers.get("User-Agent", "").lower()
return "mobile" if any(k in ua for k in ("iphone", "android", "mobile")) else "desktop"
Step 3 — Build a clean context, excluding raw headers
Add only the normalized country and deviceClass to the evaluation context — never the raw IP or full user-agent, which are higher-entropy and more sensitive. The discipline here is data minimization by construction: the context object is the surface that flows into logs, telemetry, and any flag-management vendor you send evaluation reasons to, so every field you add is a field that leaks somewhere downstream. A two-letter country code and a mobile/desktop token carry almost no re-identification risk; a full user-agent plus a raw IP is close to a fingerprint. Keeping the derived set small also stabilizes your targeting rules — a rule written against deviceClass in {mobile} keeps working when a new browser ships, whereas a rule that peeked at a UA substring breaks silently the next time that string changes. Return null for an absent attribute rather than an empty string, so downstream rules can distinguish “unknown” from a real value and fall through deliberately.
# context.py — normalized, minimal
def build_context(headers, targeting_key: str) -> dict:
return {
"targetingKey": targeting_key,
"country": country_from_request(headers),
"deviceClass": device_class(headers),
# deliberately NOT including raw IP or full UA string
}
Step 4 — Target on the derived attributes
Write the flag’s targeting rule against country and deviceClass, and confirm a missing attribute falls through to a safe default rather than matching unexpectedly. The rule below returns "off" as the else branch, which is the conservative choice: if either attribute is missing or unexpected, the user sees the old behavior instead of the new flow. That asymmetry matters — when geo is a hint rather than a fact, you want the failure mode to under-target (miss a few eligible users) rather than over-target (expose the feature to a region you meant to exclude). If you later widen the rollout, prefer adding countries to an in list over stacking nested and/or branches; a flat membership test is far easier to reason about and to diff in review than a deeply nested boolean tree. See handling missing context attributes gracefully for the full pattern on defaulting.
# flag rule (flagd-style) — roll to Portugal on mobile first
"web.checkout.new-flow":
targeting:
if:
- and:
- { "==": [{ var: "country" }, "PT"] }
- { "==": [{ var: "deviceClass" }, "mobile"] }
- "on"
- "off"
Verification
Send requests with controlled headers and confirm the derived context and the resulting variant match expectations, including the fall-through when geo is absent. Do not stop at the happy path — the cases that catch real bugs are the adversarial and empty ones: a request that sets its own CF-IPCountry: PT from the client but arrives with a spoofed geo should still be governed by the edge value, and a request with a garbage user-agent should classify as desktop (the safe default) rather than throwing. Assert on the derived context itself, not only the final variant, so a regression in the normalize step is visible even when the flag rule happens to mask it. Wiring these curls into an integration test that runs on every deploy turns “geo targeting works” from a manual spot-check into a standing guarantee.
# Portugal + mobile hint -> on; missing geo -> off (safe fall-through)
curl -s localhost:8080/eval -H "CF-IPCountry: PT" -H "Sec-CH-UA-Mobile: ?1" | jq .variant # "on"
curl -s localhost:8080/eval -H "Sec-CH-UA-Mobile: ?1" | jq .variant # "off"
Gotchas & Edge Cases
- Only a trusted hop’s header is safe. A
Countryheader the client can set is spoofable, so anyone can select the targeted region. Use the header your CDN or load balancer populates, and strip any client-supplied duplicate at the edge. - Geo is approximate. VPNs, proxies, and mobile carrier routing make the country a strong hint, not ground truth. Do not use header geo for anything requiring legal certainty (tax, compliance); use it for rollout targeting where approximate is fine.
- Derived attributes can be personal data. Fine-grained geo and device fingerprints may be PII under some regimes. Keep the derived set coarse (country, device class — not city, not full UA) and follow masking PII in evaluation context. The re-identification risk is combinatorial: country alone is harmless, but country plus city plus a precise device model plus a timestamp can narrow to a single person, so it is the set you store, not any one field, that determines your exposure.
- Cached responses can pin the wrong geo. If a CDN or shared cache stores a response computed for one country and serves it to another, you get cross-region leakage of the targeted variant. Any response whose body depends on the geo attribute must vary on it — set
Varyappropriately or key the cache on the derived country — or move the evaluation behind the cache entirely so each user re-evaluates. - A trailing edge hop can rewrite the header. In multi-CDN or CDN-plus-reverse-proxy topologies, the last trusted hop before your app is the authority, not the first. If an internal proxy re-derives geo from a now-internal IP, it can clobber the correct edge value with a datacenter country. Pin which single hop is allowed to set the header and reject the attribute if it arrives from anywhere else.
Troubleshooting & FAQ
Users in the targeted country are not getting the feature. Why?
Check that the geo header actually reaches your evaluation service — a proxy hop between the CDN and your app can strip it. Log the country attribute on the evaluation and confirm it is populated; a null country means the header never arrived, not that targeting is broken.
Should I use IP geolocation instead of the CDN header?
The CDN header is derived from the same IP geolocation but computed at the edge and harder to spoof from the client. Doing your own IP lookup duplicates that work and requires trusting the client’s forwarded IP, which is exactly the spoofable path to avoid. Prefer the edge header.
The user-agent is frozen on newer browsers. How do I classify devices?
Use client hints (Sec-CH-UA-Mobile, Sec-CH-UA-Platform), which are the modern replacement for UA parsing and are explicitly requested. Fall back to coarse UA keyword matching only where hints are unavailable, and never depend on fine-grained UA details that browsers are actively reducing.
How do I test a rollout to a country I am not physically in?
Do not rely on a VPN — inject the header directly. Because targeting reads the derived country attribute, a request with CF-IPCountry: PT produces exactly the evaluation a real Portuguese user would get, which is why the verification curls above set the header by hand. In production, keep this path closed: the edge overwrites any client-set CF-IPCountry, so a real user cannot use the same trick to select a region. For staging, gate the header-injection endpoint behind auth so it stays a test tool, not a bypass.
Should the geo lookup happen at the edge, in my app, or in the flag service?
Compute the country as early as possible — at the edge — and pass it forward as a trusted attribute. Deriving it in your app means trusting a forwarded IP and duplicating work the CDN already did; deriving it inside the flag service couples evaluation to network topology it should not know about. The clean separation is: the edge decides what country this is, your app assembles the evaluation context, and the flag rule only reads the finished country attribute.
What device classes should I target beyond mobile and desktop?
Start with just mobile and desktop; add a third value only when a rule genuinely needs it. Tablets are the common third case, but client hints do not report “tablet” directly, so you would infer it from platform plus a large-viewport hint, which is fuzzy — only take that on if a feature truly renders differently there. Bots and crawlers are worth a separate class if you want to exclude them from a rollout, since counting bot traffic in a percentage ramp distorts your exposure numbers. Keep the vocabulary small and closed so every targeting rule enumerates the same finite set.