A Leaderboard That Shows Your City Without Knowing Where You Are — Anonymous Region Labels
A KeyDo postmortem: adding city-level region labels to an anonymous leaderboard, on the condition of collecting no precise location, no third-party IP geolocation, and never storing a byte of IP in the business DB. Read only Cloudflare's edge-injected country/province/city fields, degrade level by level, gate the format with a DB CHECK constraint, plus let only a complete run enter the ranking and keep ranks stable via three-segment COUNT.
KeyDo’s leaderboard is anonymous: no account, one passphrase one score. This time it gained a “city-level” region label — the board can show a “Guangdong · Shenzhen” affiliation. It sounds at odds with “anonymous,” but the approach’s core is exactly this: give a region label coarse to the city level, on the condition of collecting no precise location, no third-party IP geolocation, and never landing a single byte of IP in the business DB.
I’ve written before about the leaderboard’s server-side backstops — stopping someone from POSTing a 9999. This post is the other side: the privacy of the region label, and the integrity of ranks.
Where the city comes from: read only edge-injected fields
The region isn’t client-reported, nor a DB lookup by IP. It reads only the request.cf fields Cloudflare’s edge injects — country, regionCode, city — filled in at the reverse-proxy layer, read directly by our Worker. And any client-reported geographic field is rejected: location / country / city / ip appearing in the POST /api/score schema is judged INVALID_ARGUMENT outright. The region can only come from the edge, not from what the user claims.
Normalization is level-by-level degradation, producing a nullable location_code:
export function normalizeLocation(cf) {
const { country } = cf
if (!/^[A-Z]{2}$/.test(country)) return null
if (country !== 'CN') {
return Object.hasOwn(COUNTRY_LABELS, country) ? country : null // overseas: country only, and must be in the allowlist
}
const provinceEntry = PROVINCE_BY_REGION.get(cf.regionCode)
if (provinceEntry === undefined) return 'CN' // unknown province → just "China"
const city = normalizeCityAlias(cf.city) // NFKC + lowercase + trim
if (city !== null) {
const entry = CITY_BY_REGION_ALIAS.get(`${cf.regionCode} ${city}`)
if (entry !== undefined && entry.code.startsWith(provinceEntry.numericPrefix)) {
return `CN-${entry.code}` // hit → Shenzhen CN-440300
}
}
return `CN-${cf.regionCode}` // city miss → down to province CN-GD
}
There are only four output forms: US (overseas country), CN-440300 (a Chinese prefecture-level city’s 6-digit admin code), CN-GD (province), CN (only known to be in China), or NULL. A Tor exit, missing cf, or illegal fields are all NULL — never guessed from a header, IP, or locale. Better to label “unknown region” than to guess.
There’s a disambiguation detail: city matching uses a (regionCode, normalized city name) composite key, and requires the matched city’s admin code prefix to equal the province prefix — so two same-named “Taizhou”s can be separated by province and not confused.
IP is only a rate-limit key, never stored
This design’s privacy bottom line: the raw IP is used only as this request’s rate-limit key, and is never written to D1, never in the response, never in a custom log. Production also disables Workers Logs persistence. The region label is computed from cf to location_code in Worker memory and used there; not even the normalized city string is persisted, let alone the raw one.
And the doc honestly states the residual risk, with no boast of “never touching IP”: Cloudflare, as a reverse proxy, still handles the source IP at the infrastructure layer, and KeyDo can’t promise on Cloudflare’s behalf that it doesn’t process or retain IP. The region label’s wording is measured too — it describes “the rough network-egress location when this best score was submitted,” not the user’s residence, and may be affected by VPN, proxy, or carrier egress. What you can do is keep no IP at your own layer; what you can’t do is guarantee it for the upstream.
The DB layer adds a CHECK constraint as a backstop, so an illegal region code can’t even enter:
ALTER TABLE scores ADD COLUMN location_code TEXT CHECK (
location_code IS NULL
OR location_code GLOB '[A-Z][A-Z]'
OR location_code GLOB 'CN-[A-Z][A-Z]'
OR location_code GLOB 'CN-[0-9][0-9][0-9][0-9][0-9][0-9]'
);
Integrity: only a complete run can enter the board
Region solved “where you’re from”; there’s still “what score deserves the board.” This time a completed boolean was introduced into settlement: whether a run is complete is decided by the caller per each mode’s end condition (lesson content finished / speed wall-clock up / challenge 60s up / word rain lives to zero). Mid Esc, tab-away, or reopen passes completed: false.
The branch effects are carefully chosen:
| Complete run | Incomplete (partial) | |
|---|---|---|
| Practice time | accumulate | still accumulate |
| History record | create | don’t create |
| Session count | +1 | untouched |
| Leaderboard | that mode’s score enqueued | only cumulative time enqueued |
That is: incomplete practice time still counts in stats (you really did practice), but produces no history record, no session, no competitive score. This blocks a class of cheating — quit halfway while the instant WPM is spuriously high, then use that inflated figure to farm the board. Practice time may count, but the board recognizes only complete runs.
Two more on the server: writing to the board uses ON CONFLICT DO UPDATE ... WHERE excluded.value > scores.value, so only a strictly higher score replaces the row (the region label only updates with the better score too); the weekly board only writes when the client-reported week equals the server’s current week, else weeklyAccepted: false — preventing days-old offline scores from polluting this week’s board after reconnect.
Stable ranks: three-segment COUNT, not a window function
The rank is computed with a “three-segment COUNT plus one,” not a window function, precisely so rank and board order are strictly consistent:
SELECT 1
+ (SELECT COUNT(*) FROM scores WHERE mode=?1 AND value > s.value)
+ (SELECT COUNT(*) FROM scores WHERE mode=?1 AND value = s.value AND updated_at < s.updated_at)
+ (SELECT COUNT(*) FROM scores WHERE mode=?1 AND value = s.value AND updated_at = s.updated_at AND user_id < s.user_id)
AS rank
The three segments correspond exactly to the board’s sort keys value DESC, updated_at ASC, user_id ASC: higher value first, ties by who submitted earlier, then by user_id. So “my rank” always matches which row I’m on in the list — no “shows rank 5 but you count to 6 in the list” mismatch.
A composite index covering all sort keys (mode, value DESC, updated_at ASC, user_id ASC) backs it, with column order deliberately equal to the ORDER BY, verified via EXPLAIN QUERY PLAN that top 50 uses the index and builds no temp sort tree. On display, the identity exposes only its first 4 chars (substr(user_id, 1, 4)), and the full passphrase value never enters the response.
Takeaways
- Trust the edge for the region label, not the client. Geography comes only from Cloudflare’s proxy-injected
cffields; any client-reported geo field is rejected — what the user can change can’t be the source of a geographic fact; - Coarse-enough is fine, and label unknown when missing. Degrade level by level to city / province / country, and
NULLon a miss, never guessing from IP, header, or locale. Privacy’s first principle is “know less,” not “guess accurately”; - Keep no IP at your own layer, and honestly disclose the upstream residual risk. IP is only a rate-limit key, not stored, not logged; but don’t boast “never touch IP” — the proxy layer’s IP handling isn’t yours to control, so state it plainly rather than give the user a false absolute promise;
- Time can be lenient, the board must be strict. Incomplete practice still counts time but doesn’t enter the board — separate “encourage practice” from “competitive fairness” with a
completedboolean that splits two ledgers; - The rank algorithm must align segment by segment with the sort keys. A three-segment COUNT precisely replicating the
ORDER BYkeeps rank and list position from mismatching; and the index column order copies the sort keys too, so it uses the index and builds no temp sort tree.
Comments