Device fingerprint engine
1. Foundational mental model
Section titled “1. Foundational mental model”Guests play without an account. The only identity the server has for them is a session_token cookie, and clearing cookies or opening a private window mints a new one. That makes per-player limits on country stats easy to dodge: one script can open fifty sessions an hour and look like fifty people.
@flags/fingerprint adds a second, cookie-independent identity. It reads a set of browser signals (canvas rendering, WebGL renderer, audio processing, installed fonts, screen, navigator, storage, automation markers, a timing loop), hashes each one, and combines the hashes into two identifiers:
deviceVisitorId: built only from rendering and font signals, so it tends to survive a cookie reset and a browser update.visitorId: device hash plus browser hash, so it changes more often.
It also scores how much the signals look like automation (botScore) or like a browser that blocks fingerprinting (spoofScore), and rates its own reliability (confidence). The game server and Convex use those fields to decide whose “vote” a country-stat answer counts as, and to flag devices that cycle through many sessions.
The engine never blocks a player. No request is denied because of a fingerprint. The strongest action it drives is a quiet downgrade from device-level to session-level identity in country stats.
2. Implementation status & known gaps
Section titled “2. Implementation status & known gaps”3. Concrete implementation
Section titled “3. Concrete implementation”Signals and weights
Section titled “Signals and weights”Every signal carries a fixed entropy weight and a stable bit. Stable signals feed the device hash.
const SIGNAL_META: SignalMetaByKind = { navigator: { entropyWeight: 10, stable: false }, screen: { entropyWeight: 8, stable: false }, storage: { entropyWeight: 5, stable: false }, automation: { entropyWeight: 12, stable: false }, timing: { entropyWeight: 6, stable: false }, canvas: { entropyWeight: 20, stable: true }, webgl: { entropyWeight: 18, stable: true }, audio: { entropyWeight: 14, stable: true }, fonts: { entropyWeight: 12, stable: true },};| Signal | Weight | Stable | Counts as missing when | Risk flag it can emit |
|---|---|---|---|---|
| canvas | 20 | yes | canvas-blocked set | canvas-blocked |
| webgl | 18 | yes | webgl-unavailable set | webgl-unavailable |
| audio | 14 | yes | audio-unavailable set | audio-unavailable |
| fonts | 12 | yes | collector threw | none |
| automation | 12 | no | collector threw | automation-webdriver, automation-global |
| navigator | 10 | no | empty user agent | none |
| screen | 8 | no | width ≤ 0 | none |
| timing | 6 | no | collector threw | none |
| storage | 5 | no | both localStorage and sessionStorage fail | storage-unavailable |
The weights sum to 105. Signal version FINGERPRINT_SIGNAL_VERSION is 2026-05-15.4, and it is mixed into both visitor IDs, so bumping it resets every ID.
The storage collector sets storage-unavailable if either store fails, but marks the signal missing only if both fail. A browser that blocks only localStorage gets the spoof weight without losing entropy.
Automation markers
Section titled “Automation markers”export function collectAutomationSignal(): FingerprintSignal { const startedAt = performance.now(); const riskFlags: FingerprintRiskFlag[] = [];
if (navigator.webdriver) { riskFlags.push("automation-webdriver"); }
const detectedGlobals: string[] = [];
for (const key of AUTOMATION_GLOBAL_KEYS) { if (key in window) { detectedGlobals.push(key); } }
if (detectedGlobals.length > 0) { riskFlags.push("automation-global"); }AUTOMATION_GLOBAL_KEYS lists 14 names left behind by Playwright, Selenium, PhantomJS, Nightmare and similar drivers (__playwright, __webdriver_evaluate, callPhantom, …).
Identity hashes
Section titled “Identity hashes”Each component hash is sha256 of a stable serialization of the signal. Scope hashes sort kind:hash pairs and hash the joined string:
const browserHash = await hashScope(components, BROWSER_IDENTITY_KINDS); const deviceHash = await hashScope(components, DEVICE_IDENTITY_KINDS);
const deviceVisitorId = await sha256(`${deviceHash}|${FINGERPRINT_SIGNAL_VERSION}`);
const visitorId = await sha256( `${browserHash}|${deviceHash}|${FINGERPRINT_SIGNAL_VERSION}` );
const sessionHash = await collectSessionHash();
const botScore = calculateBotScore(riskFlags); const spoofScore = calculateSpoofScore(riskFlags); const confidence = calculateConfidence(entropyScore, riskFlags, missingSignals.length);BROWSER_IDENTITY_KINDS is navigator, screen and automation. DEVICE_IDENTITY_KINDS is canvas, webgl, audio and fonts. Storage and timing feed entropy and risk only. sessionHash comes from a UUID stored in sessionStorage under flags.fp.session, so it lasts for one tab.
The result is cached in module memory for the page’s lifetime (cache.ts); a reload collects again.
What leaves the browser
Section titled “What leaves the browser”The web app narrows the transport payload to six fields before sending it anywhere:
export function toCountryStatFingerprintForward( transport: FingerprintTransportPayload): CountryStatFingerprintForward { return { deviceVisitorId: transport.deviceVisitorId, fingerprintConfidence: transport.confidence, botScore: transport.botScore, spoofScore: transport.spoofScore, riskFlags: transport.riskFlags, fingerprintVersion: transport.collection.version, };}CountryStatFingerprintForwardSchema on the server validates it: at most 16 risk flags, each from FINGERPRINT_FORWARD_RISK_FLAGS_ALLOWLIST, scores in 0–100. Raw canvas pixels, the font list and the WebGL renderer string never leave the device.
Where the server consumes it
Section titled “Where the server consumes it”The actor key decides whose answers a country-stat event counts toward:
export function resolveCountryStatActorKey( input: ResolveCountryStatActorKeyInput): string { const convexUserId = input.convexUserId?.trim(); if (convexUserId) { return `u:${convexUserId}`; }
const deviceVisitorId = input.deviceVisitorId?.trim(); if ( deviceVisitorId && input.fingerprintConfidence === "high" && input.identityTrustDowngraded !== true ) { return `d:${deviceVisitorId}`; }
return `s:${input.sessionUserId}`;}A device that hops sessions is caught by the cohort rollup:
let suspiciousDevices = 0; for (const [deviceSubjectKey, sessions] of sessionsByDevice) { const distinctSessionCount = sessions.size; if (distinctSessionCount < FINGERPRINT_COHORT_SUSPICIOUS_SESSIONS_PER_HOUR) { continue; }
suspiciousDevices += 1; await ensureTrustProfile(context, deviceSubjectKey); await appendTrustEvent(context, { subjectKey: deviceSubjectKey, eventType: "fingerprint_cohort_suspicious", severity: "medium", source: "fingerprint", ruleIds: ["cohort_sessions_per_hour"], details: { hourBucketUtc, distinctSessionCount, }, }); await applyDeviceIdentityDowngrade(context, deviceSubjectKey, { hourBucketUtc, distinctSessionCount, }); }FINGERPRINT_COHORT_SUSPICIOUS_SESSIONS_PER_HOUR is 50 and FINGERPRINT_HIGH_BOT_SCORE_THRESHOLD is 70, both in packages/shared/src/country-stats/fingerprint-forward.ts. The cron is rollup-fingerprint-cohorts at minute 5 of every UTC hour.
4. Internal mechanics & mathematics
Section titled “4. Internal mechanics & mathematics”Entropy score
Section titled “Entropy score”The name says entropy, but the value is weighted coverage, not Shannon entropy. For the set of signals that were collected and the subset that are present (not missing), with weights :
With every signal collected the denominator is 105. Turning off audio, fonts or timing through collection options shrinks rather than counting them as missing.
Confidence
Section titled “Confidence”Let be the number of missing signals and when privacy-mode-likely is set:
Derived flags feed back in. low-entropy is added when . privacy-mode-likely is added when at least two of canvas-blocked, webgl-unavailable, audio-unavailable are present.
| Case | Confidence | ||||
|---|---|---|---|---|---|
| Everything present | 100 | 0 | 0 | 100 | high |
| Canvas blocked only | 81 | 1 | 0 | 75 | high |
| Canvas and WebGL blocked | 64 | 2 | 1 | 37 | low |
| Canvas and audio blocked | 68 | 2 | 1 | 41 | low |
One blocked stable signal still leaves a d: key. Two drop the player to a session key. Because d: keys require high, anyone running a canvas-and-WebGL blocker is counted per session.
Bot and spoof scores
Section titled “Bot and spoof scores”Both are clamped sums over flag weights:
| Flag | Bot weight | Spoof weight |
|---|---|---|
automation-webdriver | 55 | 0 |
automation-global | 35 | 0 |
native-api-patched | 20 (never emitted) | 0 |
canvas-blocked | 0 | 12 |
webgl-unavailable | 0 | 8 |
audio-unavailable | 0 | 5 |
storage-unavailable | 0 | 10 |
low-entropy | 0 | 20 |
privacy-mode-likely | 0 | 15 |
The spoof weights sum to 70, so spoof never clamps. The only way to reach the high-bot threshold of 70 is automation-webdriver plus automation-global (90). A driver that sets navigator.webdriver but leaves none of the listed globals scores 55 and is not flagged.
Fallback hash
Section titled “Fallback hash”When crypto.subtle is missing (non-secure context), sha256 falls back to 32-bit FNV-1a and returns fallback- plus 8 hex digits, zero-padded to 32 characters. With outputs, the birthday bound puts a 50 % collision chance near distinct devices. Production is served over HTTPS, so this path should only run in local LAN dev.
5. Threat model, failure modes & edge cases
Section titled “5. Threat model, failure modes & edge cases”- Client-controlled input. Every field is computed in the browser and can be replaced. A script can send any
deviceVisitorIdwithconfidence: "high". The schema only checks shape. The design assumes the fingerprint raises the cost of casual abuse; it proves nothing. - Canvas noise extensions. Extensions that add random noise to canvas output yield a new
deviceVisitorIdon every page load while still reporting canvas as present. Each load looks like a new high-confidence device, which is the one case where the device key is weaker than the session key. - Shared hardware. School labs and identical managed laptops can share a
deviceHash. Fifty students on one image in one hour trip the cohort downgrade for all of them, and their answers fall back to session keys. Nobody is blocked, so the cost is statistical, not visible. - Soft store cap.
session-fingerprint-store.tsprunes only expired entries when it reaches 1000. If all 1000 are fresh, it inserts anyway, so the cap is a pruning trigger rather than a hard limit. The store is per process and resets on deploy. - Retention throughput. The cron deletes at most 200 cohort rows older than 48 hours per run. If more than 200 new rows land per hour on average, the table grows without bound.
- Clock skew in the cohort bucket. Hour buckets use the event’s
observedAt. The cron scans the previous hour at minute 5, so rows that arrive late for a closed bucket are counted by the per-insertwouldDowngradelog but never by the rollup. - Privacy. The engine hashes locally and forwards six fields, and the device hash excludes IP and cookies. It is still device fingerprinting in the regulatory sense, and the privacy copy does not say so explicitly.
6. Architectural tradeoffs & non-goals
Section titled “6. Architectural tradeoffs & non-goals”Use the fingerprint for aggregate-quality decisions: collapsing many guest sessions into one voice in country stats, and spotting devices that churn sessions.
Do not use it for blocking, banning, or anything a player would notice. A false positive there is a real person locked out, and the inputs are forgeable.
| Alternative | Why it was not chosen |
|---|---|
| Commercial fingerprint SaaS | Adds a third-party script and data processor to every page load for a free game with low-value targets. |
Hard-block on botScore >= 70 | Legitimate test automation and accessibility tooling can set navigator.webdriver. Logging first gives data to tune on. |
| Raw signals to the server | Better server-side models, but ships font lists and GPU strings off the device and grows the privacy surface. Hashes only. |
| Shannon entropy per signal | Needs population frequency tables the project does not have. Weighted coverage is cheap and explains itself. |
| Persist the fingerprint in localStorage | Would survive reloads, but also survives the user’s attempt to reset. Memory-only cache matches the “observe, don’t track” posture. |