Why comparison is built in
FingerprintJS and Thumbmark both leave result comparison to the consumer. In practice that means consumers write ad-hoc hash equality checks that miss legitimate cross-browser scenarios, produce false positives on benign screen resolution changes, and give the caller no structured signal-level diff to feed into a fraud model.
Benny ships compareFingerprints as a first-class API. It handles five things that naive string equality misses: a mode system that selects the right signal set for the comparison intent, per-signal fuzzy matching for signals with known browser-to-browser variance, hardware-weighted similarity scoring, OS-change constraint enforcement, and a fixed-length diff vector for ML downstream.
The function never throws. On any uncaught error it returns a safe zero-score default with an empty signalComparison map, a 26-zero diffVector, and constraintViolations: ['comparison_error'] so callers can detect the catch path.
Comparison modes
| Mode | Signal set | Fuzzy matching |
|---|---|---|
| 'exact' | All signals present in either fingerprint | Enabled (default) |
| 'cross-browser' | the hardware signal set only (6 signals) | Enabled. Designed for Chrome vs Safari on the same device |
| 'hardware-only' | the hardware signal set only (6 signals) | Enabled. Explicit alias for the same hardware signal set |
| 'engine-only' | Engine-bound signals only | Enabled |
| 'strict' | All signals | Disabled. Only exact hash equality scores 1.0 |
| 'lenient' | All signals | Enabled |
Similarity weighting
The similarity calculation iterates over the per-signal comparisons, skips any signal that was absent on exactly one side, and applies a per-binding weight multiplier so hardware-bound signals contribute more to similarity than engine-bound signals. The weighted sum divided by the weighted total produces similarity (0.0 to 1.0). The exact weight ratio is part of the internal scoring policy and not part of the public contract.
hardwareSimilarity is computed differently: it is the unweighted average of hardware-bound signal matchScores only, ignoring engine-bound signals entirely.
matchScore is a 0–100 integer derived from similarity. The match boolean is true when matchScore meets the matchThreshold option (the default is tuned for typical re-identification workloads; consumers can override). hardwareMatch is always string equality of the two hardwareFingerprint fields, computed even when the function returns early due to a constraint violation.
diffVector
generateDiffVector produces a fixed-length array of 26 float elements, one per signal in the fusion order. Each element is detail.matchScore if the signal was included in the comparison, or 0.0 if it was not. The vector is suitable for direct consumption by downstream ML classifiers, because the slot count and order are stable so model features do not shift between library versions unless the fusion order changes.
As of 1.23.0, the fusion order has 26 slots. 1.24.0 did not add signals; it added consistency-layer flags only, so the vector length is unchanged at 26.
Adding a new signal appends a slot to the fusion order. This is a forced hash migration for fingerprint but is backwards-compatible for diffVector consumers that treat it as a variable-length prefix: the new slot appears at the end with value 0.0 on any fingerprint collected before the new signal was available.
import { compareFingerprints } from 'doorman-benny';
import type { ComparisonOptions } from 'doorman-benny'; // Cross-browser device match: same physical device, different browsers
const crossBrowserResult = compareFingerprints(chromeResult, safariResult, { mode: 'cross-browser',
});
console.log(crossBrowserResult.hardwareMatch); // true/false (string equality)
console.log(crossBrowserResult.similarity); // 0.0 to 1.0 weighted
console.log(crossBrowserResult.matchScore); // 0 to 100 integer // Strict fraud check: two fingerprints that must match exactly
const strictResult = compareFingerprints(resultA, resultB, { mode: 'strict', matchThreshold: 100,
}); // ML-ready diff vector (26 floats, fixed order)
const vector = compareFingerprints(resultA, resultB).diffVector;
// vector[0] = audio matchScore, vector[1] = canvas matchScore,... // Constraint violations short-circuit before scoring
if (strictResult.constraintViolations.includes('os_changed')) { // OS changed between sessions; hardware mismatch is expected
}The mode determines the signal set. fuzzyMatching controls whether signals with dedicated fuzzy functions receive them; mismatched signals fall back to exact-hash comparison.
Per-signal fuzzy matching
| Signal | Fuzzy function | Key tolerances |
|---|---|---|
| screen | fuzzyMatchScreen | Width and height percent diff tiers (0% to 1.0, up to 2.5% to 0.95, up to 5% to 0.85, up to 10% to 0.6); colorDepth tolerance 6 bits; DPR tolerance 0.1, zoom tolerance 0.5 |
| platform | fuzzyMatchPlatform | Platform string must match exactly; hwConcurrency, deviceMemory, maxTouchPoints each add 0.25 to the score (max 1.0) |
| timezone | fuzzyMatchTimezone | Both IANA name and UTC offset must match exactly; returns 1.0 or 0.0 |
| media_devices | fuzzyMatchMediaDevices | audioOutputCount intentionally ignored (Safari privacy); audioInputCount and videoInputCount both match scores 1.0; partial match scores 0.5 |
| audio | fuzzyMatchAudio | Per-sample percent-diff is bucketed into tiers and averaged across samples; the exact tier cut-offs are part of the internal scoring policy and not part of the public contract |
| all others | hash equality only | Mismatch = 0.0 with no fuzzy path defined |
Signal change classification
| Condition | changeType | Interpretation |
|---|---|---|
| Both signals absent and hashes agree (typical __absent__/__absent__) | 'identical' | Both browsers consistently lack the API. Counted as match (score 1.0) and contributes to weighted similarity |
| Exactly one signal absent (or both absent with different hashes) | 'one_absent' | Signal excluded from similarity calculation; matchScore = 0.0 |
| a.hash === b.hash (both present) | 'identical' | No change |
| Hardware signal, hashes differ | 'implausible' | Hardware characteristics should not change; any difference is suspicious |
| Engine signal, hashes differ | 'plausible' | Engine-bound signals can legitimately differ across browser updates or settings changes |
Things worth knowing
- Appending a new signal is a forced fingerprint-hash migration but does not break existing diffVector consumers.
- Some signals classified as hardware-bound for cross-browser identity are classified engine-bound for comparison purposes because browser-imposed capability caps and per-origin farbling make them browser-variant on the same physical device.
- matchThreshold defaults to a value tuned for typical re-identification workloads. Raise it for identity proofing and lower it for fuzzy device grouping.
- fuzzyThresholds allows overriding per-signal tolerances without changing mode.
- includeSignals and excludeSignals operate after mode filtering. They narrow the active set; they do not expand it beyond what the mode permits.
- Hardware-bound signal mismatches dominate similarity compared with engine-bound mismatches; a small number of hardware mismatches can outweigh many engine matches.
Last reviewed 2026-06-04

