Skip to content

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.

Kind
Format
Flag served from the opaque URL
  1. 1 · Payload (24 code units, NUL-separated)
    fg.media.v1\0 full\0 webp\0 cn
  2. 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. 3 · CDN object key https://cdn.flags.games/images/flags/webp/o/2d7e4d35da503ecf.webp
  4. 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.

The whole algorithm, as it runs in production:

packages/shared/src/utils/flag-asset-token.ts
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:

packages/shared/src/utils/flag-asset-token.ts
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:

Quiz usage (apps/web)
<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:

apps/web/src/lib/components/game/FlagImage.svelte
const inspectTease = $derived(hideCountryInAlt && !iconChip && !decorative);
const decoyCountryCode = $derived(
inspectTease ? decoyQuizCountryCode(countryCode) : undefined
);

For payload code units b0…bn−1b_0 \ldots b_{n-1}, with offset basis h0=0xcbf29ce484222325h_0 = \texttt{0xcbf29ce484222325} and prime p=240+28+0xb3=0x100000001b3p = 2^{40} + 2^{8} + \texttt{0xb3} = \texttt{0x100000001b3}:

hi+1=((hi⊕bi)×p) mod 264h_{i+1} = \big( (h_i \oplus b_i) \times p \big) \bmod 2^{64}

The & FNV_MASK step is the  mod 264\bmod 2^{64}. JavaScript number has 53 bits of integer precision, so the loop runs on BigInt; a Number port would silently diverge after the first multiply.

SegmentExampleCode units
Saltfg.media.v111
Separator\01
Kindfull / icon / preview4–7
Separator\01
Formatwebp / svg3–4
Separator\01
Code (lowercased)cn2+

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.

The assets that exist form a domain of at most N≈242×5=1,210N \approx 242 \times 5 = 1{,}210 (kind, format, code) triples. For a uniform 64-bit hash the birthday bound gives

P(any collision)≈N22⋅264≈1.46×1063.69×1019≈4×10−14P(\text{any collision}) \approx \frac{N^2}{2 \cdot 2^{64}} \approx \frac{1.46 \times 10^{6}}{3.69 \times 10^{19}} \approx 4 \times 10^{-14}

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/, …).

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.

decoyQuizCountryCode runs a Java-style string hash (h←31h+ch \leftarrow 31h + c, unsigned 32-bit) over the uppercase code, then walks gameCountries from index h mod ∣pool∣h \bmod |\text{pool}| 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.ts uploads both webp/fr.webp and 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.v1 changes all tokens. The service worker’s copy in sw.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.js cannot import from @flags/shared, so it carries its own copy. flag-url.test.ts covers 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.

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.

AlternativeWhy it was not chosen
Signed, expiring URLsNeeds 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 questionCouples a static asset path to game state; breaks offline and SW precache.
Ship a token→code map for debuggingWould hand the answer key to every player. Offline tooling hashes forward instead.
SHA-256 via SubtleCryptoAsync 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.