doorman-benny
v1.25.0
Published
High-entropy device fingerprint library using browser API signals
Maintainers
Readme
Benny the Doorman
High-entropy device fingerprinting that measures what the hardware does, not just what the browser reports.
A production-grade browser fingerprinting library for fraud detection, bot detection, MFA, and cross-browser device identification. Zero runtime dependencies, ~52 KB minified, TypeScript-native.
Upgrading from 1.2.x? The
canvassignal hash changes in 1.3.0 because the collector now does a 3-run mode-merge on raw RGBA bytes (was: single-render PNG bytes). On a stable browser the new hash is deterministic and stable across calls, but it does not equal the 1.2.x value.hardwareFingerprintis unaffected. If you store fullfingerprintvalues server-side, treat this as a one-time migration.
Features
- Two-Level Fingerprinting — browser-instance fingerprint and cross-browser hardware fingerprint
- Hardware Binding — identifies the same physical device across different browsers
- Anti-Detect Detection — flags spoofed / virtualized / anti-fingerprint browsers
- Automation Detection — flags Puppeteer, Playwright, Selenium, stealth-plugin drivers
- Incognito Detection — informational private-mode signal
- Cross-Browser Stable — same hardware hash across Chrome, Safari, Firefox, Brave
- Performance Optimised — parallel collection, configurable timeouts and tiering
- Zero Runtime Dependencies — single self-contained bundle
- TypeScript Native — full type definitions included
Installation
npm install doorman-bennyQuick Start
The library has two top-level entry points. Pick based on what you need:
import { getDeviceId, getFingerprint } from 'doorman-benny';
// Cross-browser DEVICE identity (~200–300ms, stable across browsers / incognito).
// Use for: MFA, device-bound auth, cross-browser linking, license enforcement.
const { id, confidence, consistency, incognito } = await getDeviceId();
console.log(id); // "7c2e9a4b8f1d6e3a" — same on Chrome / Brave / Safari for the same device
console.log(confidence); // 0.0–1.0 — collection quality
// Full BROWSER-INSTANCE fingerprint (~1–1.5s, differentiates same-device sessions).
// Use for: session tracking, fraud detection, bot detection.
const result = await getFingerprint();
console.log(result.fingerprint); // "a3f8e2d1c9b4f6a8" — per-browser-instance
console.log(result.hardwareFingerprint); // "7c2e9a4b8f1d6e3a" — cross-browser device hash
console.log(result.consistency); // Anti-detect browser detection
console.log(result.incognito); // Private-mode detection
console.log(result.automation); // Puppeteer / Playwright / Selenium / stealth detection
console.log(result.crossBrowser); // Cross-browser confidence scoreRule of thumb:
- Identifying a device →
getDeviceId - Identifying a browser instance →
getFingerprint
Background Collection
import { createCollector } from 'doorman-benny';
const collector = createCollector();
collector.start(); // Begin early in page load
// ... user interaction, page load, etc. ...
const result = await collector.getResult();Comparing Fingerprints
import { getFingerprint, compareFingerprints } from 'doorman-benny';
const fpA = await getFingerprint();
// ... later, or on different browser ...
const fpB = await getFingerprint();
const comparison = compareFingerprints(fpA, fpB);
console.log(comparison.match); // true/false
console.log(comparison.matchScore); // 0-100 percentage
console.log(comparison.hardwareMatch); // Same device?
console.log(comparison.similarity); // 0.0-1.0 weighted scoreConfiguration
interface FingerprintOptions {
tiers?: number[]; // Which tiers to collect. Default: [1]
timeout?: number; // Global ms ceiling. Default: 5000
componentTimeout?: number; // Per-signal ms cap. Default: undefined (no cap)
exclude?: string[]; // Signal names to skip. Default: []
debug?: boolean; // Include raw values. Default: false
stabilize?: ('private' | 'iframe')[]; // Opt-in farble stabilization. Default: undefined
}Three tiers are available:
- Tier 1 — fast, high-reliability signals (default)
- Tier 2 — broader coverage, slightly slower
- Tier 3 — optional, opt-in (
tiers: [1, 2, 3])
A user-configurable global timeout caps total collection time. Set componentTimeout to also cap each individual signal independently — useful when you'd rather drop one slow signal than miss the entire collection.
stabilize: ['private'] drops signals known to be farbled or noised by the detected browser (canvas / audio / speech voices on Brave, Firefox, Safari 17+, Samsung Internet) so the resulting hash stays stable across calls on those browsers, at the cost of entropy on the affected platform. The setting is opt-in — leaving it unset preserves the default behaviour from prior versions.
Result Structure
interface FingerprintResult {
fingerprint: string; // Full hash (16-char hex)
hardwareFingerprint: string; // Hardware-only hash (16-char hex)
version: string; // Library version
collectionTimeMs: number;
signals: Record<string, SignalResult>;
consistency: ConsistencyResult; // Anti-detect browser detection
crossBrowser: CrossBrowserScore; // Cross-browser confidence
incognito: IncognitoResult; // Private/incognito mode detection
automation: AutomationResult; // Automation framework detection
errors?: FingerprintError[]; // Collector/pipeline errors (present only if non-empty)
}
interface SignalResult {
value: unknown; // Raw value (debug mode only)
hash: string; // 16-char hex hash
confidence: 'normal' | 'degraded' | 'absent' | 'stabilized';
binding: 'hardware' | 'engine';
timeMs: number;
errorMessage?: string; // Diagnostic when this signal failed (capped at ~200 chars)
sentinel?: 'unsupported' | 'threw' | 'timeout' | 'randomized'; // Diagnostic tag (1.17.0+)
}
`sentinel` is absent on healthy `normal` collections. `'unsupported'` fires when the required browser API does not exist. `'threw'` fires when the collector raised an exception (hash is `'__absent__'`). `'timeout'` fires when the collector did not finish within the global deadline (hash is `'__absent__'`). `'randomized'` fires when noise mitigation detected per-call randomisation — hash carries the real mode-merged value rather than `'__absent__'`, making it usable for best-effort identification while the sentinel flags the instability for analytics.
interface FingerprintError {
type: 'collector_threw' | 'collector_timeout' | 'fatal';
component?: string;
message: string;
}
interface ConsistencyResult {
score: number; // 0.0 – 1.0
flags: string[];
spoofLikelihood: 'low' | 'medium' | 'high';
}
interface IncognitoResult {
score: number; // 0.0 – 1.0
flags: string[];
incognitoLikelihood: 'low' | 'medium' | 'high';
}
interface AutomationResult {
score: number; // 0.0 – 1.0
flags: string[];
automationLikelihood: 'low' | 'medium' | 'high';
}
interface ComparisonResult {
matchScore: number; // 0-100 percentage
match: boolean; // true if matchScore >= threshold (default 85)
hardwareMatch: boolean;
similarity: number; // 0.0-1.0
hardwareSimilarity: number;
constraintViolations: string[];
signalComparison: Record<string, SignalComparisonDetail>;
diffVector: number[]; // ML feature vector
}spoofLikelihood, incognitoLikelihood, and automationLikelihood are derived from internal flag counts. Treat medium as suggestive, high as conclusive.
Comparison Modes
compareFingerprints supports six modes for different use cases:
const comparison = compareFingerprints(fpA, fpB, {
mode: 'cross-browser' // 'exact' | 'cross-browser' | 'hardware-only'
// 'engine-only' | 'strict' | 'lenient'
});| Mode | Use For |
|------|---------|
| cross-browser | Recommended for device matching across browsers |
| hardware-only | Strictest cross-browser device verification |
| engine-only | Detecting browser changes on the same device |
| exact | Default — all signals with fuzzy matching |
| strict | No fuzzy matching — flag any change |
| lenient | Maximum tolerance |
interface ComparisonOptions {
mode?: 'exact' | 'cross-browser' | 'hardware-only' | 'engine-only' | 'strict' | 'lenient';
includeSignals?: string[];
excludeSignals?: string[];
fuzzyMatching?: boolean; // Default: true
matchThreshold?: number; // 0-100, default: 85
checkConstraints?: boolean; // Default: true
fuzzyThresholds?: FuzzyThresholds;
}Fuzzy matching gracefully handles browser resize, font additions, timezone changes, minor signal variations, and DPR rounding.
Common Use Cases
Multi-Factor Authentication
const currentFp = await getFingerprint();
const savedFp = await loadFromServer();
const comparison = compareFingerprints(currentFp, savedFp);
if (!comparison.hardwareMatch) {
requireAdditionalVerification('Different device detected');
}Cross-Browser Device Matching
const fp = await getFingerprint();
sendToServer({ deviceId: fp.hardwareFingerprint });Fraud Detection
const fp = await getFingerprint();
if (fp.consistency.spoofLikelihood === 'high') {
flagForReview('Anti-detect browser likely');
}Bot Detection
const fp = await getFingerprint();
if (fp.automation.automationLikelihood === 'high') {
blockRequest('Automation framework detected');
} else if (fp.automation.automationLikelihood === 'medium') {
requireCaptcha();
}Submit to Benny the Doorman server
import { getFingerprint, submitToBenny, SubmitToBennyError } from 'doorman-benny';
const fingerprintResult = await getFingerprint();
try {
const response = await submitToBenny(fingerprintResult, {
endpoint: 'https://eu.api.bennythedoorman.com/v1/identify',
apiKey: 'pk_live_…',
subjectId: currentUserId, // strongly recommended for GDPR DSAR
});
console.log(response.visitorId, response.matchConfidence);
} catch (err) {
if (err instanceof SubmitToBennyError) {
console.error('identify failed', err.status, err.requestId, err.errors);
}
}Performance
| Browser | Typical Tier 1 Collection | |---------|---------------------------| | Chrome / Edge | ~1200 ms | | Firefox | ~1400 ms | | Safari | ~1600 ms |
For best UX, use the background collector (createCollector()) so collection overlaps with page load.
Browser Compatibility
| Browser | Support | |---------|---------| | Chrome 90+ | Excellent | | Edge 90+ | Excellent | | Firefox 88+ | Good | | Safari 14+ | Good | | Mobile Chrome | Good | | Mobile Safari | Good |
Signals unsupported by a given browser are marked as absent rather than failing the run.
Native SDKs (preview)
Native iOS and Android ports of the same fingerprinting pipeline live alongside the web SDK in this repo. Both produce byte-identical hashes to the web SDK for any signal that has a cross-platform equivalent — so a single hardwareFingerprint value can identify the same device across Safari, your iOS app, and your Android app.
| Platform | Location | Status |
|---|---|---|
| iOS / macCatalyst | packages/ios/ — Swift Package | Preview — core (xxHash64, fusion, hardware hash) + 2 of 26 signals implemented; rest are typed stubs |
| Android | packages/android/ — Gradle library module | Preview — same scope as iOS |
The cross-language contract (xxHash64 primes, SIGNAL_ORDER, HARDWARE_SIGNALS, __absent__ sentinel, confidence/sentinel matrix, test vectors) is documented at docs/kb/shared/contract.md. The platform-specific signal maps and the cross-surface matching guide live alongside it under docs/kb/shared/.
API Reference
getDeviceId(options?): Promise<DeviceIdResult>
Cross-browser device identifier. Skips engine-bound collectors entirely — fast and stable across browsers / incognito.
Returns: DeviceIdResult with id, confidence, stableSignals, unstableSignals, consistency, incognito, automation, collectionTimeMs, version, signals.
Use when: cross-browser device matching, MFA, account-device binding, license enforcement.
getFingerprint(options?): Promise<FingerprintResult>
Full browser-instance fingerprint (hardware + engine signals).
Use when: maximum entropy for fraud / bot detection, distinguishing two users on the same physical device.
createCollector(options?): CollectorHandle
Background collector. start() begins collection; getResult() returns the result (auto-starts if needed).
compareFingerprints(a, b, options?): ComparisonResult
Compares two fingerprints with mode-aware fuzzy matching.
submitToBenny(result, options): Promise<IdentifyResponse>
Posts a FingerprintResult to a Benny the Doorman server's POST /v1/identify endpoint and returns the parsed IdentifyResponse. Stamps issuedAt: Date.now() automatically. Throws SubmitToBennyError on any failure (network error, abort, timeout, non-2xx HTTP, JSON parse error).
Required options: endpoint: string (full URL, e.g. https://eu.api.bennythedoorman.com/v1/identify), apiKey: string (pk_live_… or pk_test_…).
Notable optional options: subjectId?: string — strongly recommended for GDPR DSAR fulfilment; without it, erasing a data subject requires a full table scan rather than an indexed lookup. linkedId?: string, tag?: Record<string, unknown>, clientVersion?: string, tiers?: readonly number[], origin?: string — required for Node.js callers (browsers stamp Origin automatically; Node's native fetch does not, so omitting this causes a 403 from the server's Origin allowlist). signal?: AbortSignal (cancellation), timeoutMs?: number (auto-aborts after N ms — implemented via setTimeout + a private AbortController, with any caller-supplied signal merged in via a one-shot listener; deliberately avoids AbortSignal.any for Node 18 compatibility), fetch?: typeof fetch (injectable for testing).
Response shape: IdentifyResponse — see v0_spec §8.3 for the full field list (requestId, visitorId, isNew, firstSeen, lastSeen, matchConfidence, linkedId, tag, errors).
SubmitToBennyError
Thrown by submitToBenny on any failure path. Extends Error.
Properties: status: number — HTTP status code on a non-2xx server response; sentinel 0 when no HTTP exchange happened (network error, abort, timeout, body-serialization failure). requestId: string | null — echoed from the server error envelope when present; use this to pivot against reqId in the server logs. errors: ReadonlyArray<IdentifyResponseError> — the server's errors[] envelope on a non-2xx, or a single synthetic entry on transport failures (NETWORK_ERROR, TIMEOUT, ABORTED, PARSE_ERROR, BODY_SERIALIZATION_ERROR, FETCH_UNAVAILABLE, INVALID_ENDPOINT, INVALID_API_KEY_INPUT, HTTP_ERROR). Always at least one entry.
try {
const response = await submitToBenny(fingerprintResult, { endpoint, apiKey });
console.log(response.visitorId);
} catch (err) {
if (err instanceof SubmitToBennyError) {
console.error(err.status, err.requestId, err.errors);
}
}VERSION: string
Library version constant.
Privacy Considerations
Benny the Doorman is a fingerprinting library intended for legitimate security and fraud-prevention use cases:
- Fraud detection
- Bot detection
- Account security (MFA)
- Analytics (device counting)
Best practices:
- Obtain user consent where required by law
- Comply with GDPR / CCPA / equivalent regulations
- Combine with other authentication methods
- Don't rely solely on fingerprints for critical decisions
- Implement server-side verification
License
Source-Available Non-Redistribution License — free to use, run, and modify for personal, internal, commercial, or non-commercial purposes, but you may not redistribute, resell, sublicense, or host the Software as a paid offering. See LICENSE for full terms.
For redistribution rights, OEM bundling, or any usage outside the granted license, contact the copyright holder for a commercial agreement.
Support & Commercial Licensing
For feature requests, integration support, or commercial licensing inquiries, contact the copyright holder.
Built for security teams, fraud-detection engineers, and platforms that need robust device identification.
