Opaque flag tokens
1. Foundational mental model
Section titled “1. Foundational mental model”A flag quiz leaks its answer if the image URL does. https://cdn.flags.games/images/flags/webp/fr.webp tells anyone with the Network tab open that the answer is France. The fix is a one-way name: hash the country code with a fixed salt, and publish the image under the hash.
The function is a map from a small, public domain (≈ 242 country codes × 3 asset kinds × 2 formats) to a 64-bit space. Nothing in the web bundle maps back. To recover the country from a URL, you would have to rebuild the forward map yourself.
The same element also carries a decoy. FlagImage.svelte sets data-country on the wrapper and the <img> to a different quiz country, so the first thing an inspecting player finds is a wrong answer.
Interactive
Opaque flag token hasher
Runs the production flagAssetToken() from @flags/shared in your browser. Pick a country and asset kind; the CDN path never contains the code.

- 1 · Payload (24
code units, NUL-separated) fg.media.v1\0 full\0 webp\0 cn
- 2 · FNV-1a 64-bit → token 2d7e4d35da503ecf
Highlighted cells: 8 of 64 bits differ from the token for
CO, the next country alphabetically. FNV-1a mixes the final character with a single multiply, so neighbouring codes share many bits. That is fine for an unlinkable name; it would not be for a MAC. - 3 · CDN object key
https://cdn.flags.games/images/flags/webp/o/2d7e4d35da503ecf.webp - 4 · What DevTools shows during a quiz
<div data-country="DZ" data-hint="Nice try :)"> <img src="…/webp/o/2d7e4d35da503ecf.webp" alt="Flag" data-country="DZ"> </div>
Decoy attribute points at Algeria, never the real answer.
2. Implementation status & known gaps
Section titled “2. Implementation status & known gaps”3. Concrete implementation
Section titled “3. Concrete implementation”The whole algorithm, as it runs in production:
export type FlagAssetKind = "full" | "icon" | "preview";export type FlagAssetFormat = "svg" | "webp";
const FLAG_ASSET_TOKEN_SALT = "fg.media.v1";const FNV_OFFSET = 0xcbf29ce484222325n;const FNV_PRIME = 0x100000001b3n;const FNV_MASK = 0xffffffffffffffffn;
export function flagAssetToken( kind: FlagAssetKind, format: FlagAssetFormat, countryCode: string): string { const payload = `${FLAG_ASSET_TOKEN_SALT}\0${kind}\0${format}\0${countryCode.trim().toLowerCase()}`; let hash = FNV_OFFSET; for (let index = 0; index < payload.length; index++) { hash ^= BigInt(payload.charCodeAt(index)); hash = (hash * FNV_PRIME) & FNV_MASK; } return hash.toString(16).padStart(16, "0");}The token is placed under a prefix per kind, so icon, preview, and full assets can never collide with each other:
export function flagOpaqueCdnObjectKey(kind, format, countryCode): string { const token = flagAssetToken(kind, format, countryCode); if (kind === "icon") { return format === "webp" ? `icons/webp/o/${token}.webp` : `icons/o/${token}.svg`; } if (kind === "preview") { return `webp/preview/o/${token}.webp`; } return format === "webp" ? `webp/o/${token}.webp` : `o/${token}.svg`;}Application code never builds paths by hand. It calls the URL helper and renders through FlagImage:
<script lang="ts"> import FlagImage from "$lib/components/game/FlagImage.svelte";</script>
<!-- hideCountryInAlt turns on the inspect tease: alt="Flag" plus a decoy data-country --><FlagImage countryCode={question.country.code} hideCountryInAlt priority size="xl" />FlagImage derives the decoy only when the tease applies:
const inspectTease = $derived(hideCountryInAlt && !iconChip && !decorative);const decoyCountryCode = $derived( inspectTease ? decoyQuizCountryCode(countryCode) : undefined);4. Internal mechanics & mathematics
Section titled “4. Internal mechanics & mathematics”FNV-1a recurrence
Section titled “FNV-1a recurrence”For payload code units , with offset basis and prime :
The & FNV_MASK step is the . JavaScript number has 53 bits of integer precision, so the loop runs on BigInt; a Number port would silently diverge after the first multiply.
Payload layout
Section titled “Payload layout”| Segment | Example | Code units |
|---|---|---|
| Salt | fg.media.v1 | 11 |
| Separator | \0 | 1 |
| Kind | full / icon / preview | 4–7 |
| Separator | \0 | 1 |
| Format | webp / svg | 3–4 |
| Separator | \0 | 1 |
| Code (lowercased) | cn | 2+ |
The NUL separators make the encoding prefix-free: ("full", "webp", "cn") and ("fullw", "ebp", "cn") produce different payloads. Every input is ASCII, so charCodeAt and bytes agree.
Collision budget
Section titled “Collision budget”The assets that exist form a domain of at most (kind, format, code) triples. For a uniform 64-bit hash the birthday bound gives
FNV-1a is not a uniform hash, but at this size the margin covers it. Collisions would also only matter within one prefix (webp/o/, icons/o/, …).
Avalanche
Section titled “Avalanche”FNV-1a folds each character in with one XOR and one multiply. The last character therefore goes through a single multiply, and a multiply only carries changes toward higher bits. CN and CO share 56 of 64 token bits. The simulator above shows the overlap for any pair. That is acceptable for a name whose only job is to not spell the country; it would be disqualifying for a MAC or a hash table exposed to adversarial keys.
Decoy selection
Section titled “Decoy selection”decoyQuizCountryCode runs a Java-style string hash (, unsigned 32-bit) over the uppercase code, then walks gameCountries from index until it finds a code that is not the answer. It is deterministic per country, so re-renders and SSR agree, and it falls back to "UN" only if the pool has one entry.
5. Threat model, failure modes & edge cases
Section titled “5. Threat model, failure modes & edge cases”- Offline reverse map. The salt is public. Precomputing tokens for every code takes well under a millisecond. The design goal is friction for players who glance at DevTools, not secrecy. Server-side answer checks (ledger replay for solo, server-held answers in multiplayer) carry the real integrity load.
- Readable duplicates on the CDN.
upload-to-r2.tsuploads bothwebp/fr.webpand its opaque copy. Someone who fetches every readable key and compares bytes can match images. Removing the readable keys would break older clients and tooling that still reference them. - Salt rotation invalidates every cached URL. Changing
fg.media.v1changes all tokens. The service worker’s copy insw.js, the R2 upload, the CDN purge list (purge-flags-cdn.ts), and deployed clients must move together, or clients request keys that do not exist yet. - Duplicated algorithm.
sw.jscannot import from@flags/shared, so it carries its own copy.flag-url.test.tscovers the shared module; the service worker copy has no test of its own. - Accessibility. With the tease on,
alt="Flag"is intentional. Screen reader users get the same information as sighted players, which is the point of a quiz.
6. Architectural tradeoffs & non-goals
Section titled “6. Architectural tradeoffs & non-goals”Use opaque URLs when the image itself is the question: solo rounds, daily, multiplayer, challenge heroes.
Skip the tease when the flag is labelled anyway: country pages, learn cards after reveal, leaderboards. Pass hideCountryInAlt={false} (the default) so assistive tech gets Flag of France.
| Alternative | Why it was not chosen |
|---|---|
| Signed, expiring URLs | Needs a server round trip per image and defeats CDN and service worker caching for a game that preloads rounds. |
| Serve images through an API that knows the current question | Couples a static asset path to game state; breaks offline and SW precache. |
| Ship a token→code map for debugging | Would hand the answer key to every player. Offline tooling hashes forward instead. |
| SHA-256 via SubtleCrypto | Async and slower for no gain: the salt is public, so hash strength buys nothing here. |
Non-goals: preventing a motivated player from identifying flags with image recognition, hiding which files exist on the CDN, or replacing server-side answer validation.