Skip to content

Device fingerprint engine

Status
In progress · partial UICollection and forwarding run in production, but the output is mostly observed and logged. Only the country-stat actor key and the hourly cohort downgrade act on it, and one bot flag is never emitted.
Verified
against master

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.

Every signal carries a fixed entropy weight and a stable bit. Stable signals feed the device hash.

packages/fingerprint/src/client/collect.ts
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 },
};
SignalWeightStableCounts as missing whenRisk flag it can emit
canvas20yescanvas-blocked setcanvas-blocked
webgl18yeswebgl-unavailable setwebgl-unavailable
audio14yesaudio-unavailable setaudio-unavailable
fonts12yescollector threwnone
automation12nocollector threwautomation-webdriver, automation-global
navigator10noempty user agentnone
screen8nowidth ≤ 0none
timing6nocollector threwnone
storage5noboth localStorage and sessionStorage failstorage-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.

packages/fingerprint/src/client/signals/automation.ts
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, …).

Each component hash is sha256 of a stable serialization of the signal. Scope hashes sort kind:hash pairs and hash the joined string:

packages/fingerprint/src/client/collect.ts
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.

The web app narrows the transport payload to six fields before sending it anywhere:

apps/web/src/lib/fingerprint/game-result-forward.ts
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.

The actor key decides whose answers a country-stat event counts toward:

apps/web/src/convex/countryStat/actor.ts
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:

apps/web/src/convex/trust/cohort.ts
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.

The name says entropy, but the value is weighted coverage, not Shannon entropy. For the set CC of signals that were collected and the subset P⊆CP \subseteq C that are present (not missing), with weights wkw_k:

E=round⁡ ⁣(100⋅∑k∈Pwk∑k∈Cwk)E = \operatorname{round}\!\left(100 \cdot \frac{\sum_{k \in P} w_k}{\sum_{k \in C} w_k}\right)

With every signal collected the denominator is 105. Turning off audio, fonts or timing through collection options shrinks CC rather than counting them as missing.

Let m=∣C∖P∣m = |C \setminus P| be the number of missing signals and π=1\pi = 1 when privacy-mode-likely is set:

a=E−15π−min⁡(6m, 24)confidence={higha≥72medium45≤a<72lowa<45a = E - 15\pi - \min(6m,\ 24) \qquad \text{confidence} = \begin{cases} \text{high} & a \ge 72 \\ \text{medium} & 45 \le a < 72 \\ \text{low} & a < 45 \end{cases}

Derived flags feed back in. low-entropy is added when E<38E < 38. privacy-mode-likely is added when at least two of canvas-blocked, webgl-unavailable, audio-unavailable are present.

CaseEEmmπ\piaaConfidence
Everything present10000100high
Canvas blocked only811075high
Canvas and WebGL blocked642137low
Canvas and audio blocked682141low

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.

Both are clamped sums over flag weights:

bot=min⁡ ⁣(100, ∑f∈Fbf)spoof=min⁡ ⁣(100, ∑f∈Fsf)\text{bot} = \min\!\Big(100,\ \sum_{f \in F} b_f\Big) \qquad \text{spoof} = \min\!\Big(100,\ \sum_{f \in F} s_f\Big)
FlagBot weight bfb_fSpoof weight sfs_f
automation-webdriver550
automation-global350
native-api-patched20 (never emitted)0
canvas-blocked012
webgl-unavailable08
audio-unavailable05
storage-unavailable010
low-entropy020
privacy-mode-likely015

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.

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 2322^{32} outputs, the birthday bound puts a 50 % collision chance near 2ln⁡2⋅232≈77,000\sqrt{2 \ln 2 \cdot 2^{32}} \approx 77{,}000 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 deviceVisitorId with confidence: "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 deviceVisitorId on 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.ts prunes 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-insert wouldDowngrade log 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.

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.

AlternativeWhy it was not chosen
Commercial fingerprint SaaSAdds a third-party script and data processor to every page load for a free game with low-value targets.
Hard-block on botScore >= 70Legitimate test automation and accessibility tooling can set navigator.webdriver. Logging first gives data to tune on.
Raw signals to the serverBetter server-side models, but ships font lists and GPU strings off the device and grows the privacy surface. Hashes only.
Shannon entropy per signalNeeds population frequency tables the project does not have. Weighted coverage is cheap and explains itself.
Persist the fingerprint in localStorageWould survive reloads, but also survives the user’s attempt to reset. Memory-only cache matches the “observe, don’t track” posture.