device-signal-kit
v0.1.1
Published
Lightweight, dependency-free TypeScript library for browser device fingerprinting, composite risk scoring, and bot/automation detection.
Maintainers
Readme
device-signal-kit
A lightweight, zero-runtime-dependency TypeScript library for browser-side device fingerprinting, composite risk scoring, and bot/automation detection — the kind of signal collection done by tools like FingerprintJS, rebuilt from first principles with a strict, explicitly-SOLID architecture.
What this is: a portfolio / demonstration project showing how a real client-side signal pipeline is structured. What this is not: a production-grade anti-fraud system. The heuristics here are deliberately simple, well-documented, and evadable — see Limitations.
What it does
- Collects a device fingerprint from native browser APIs — canvas rendering, WebGL renderer/vendor, an audio-context signal, and navigator/screen/timezone properties.
- Combines signals into a 0–100 risk score based on their consistency and completeness.
- Flags likely bot/automation sessions with small, explainable heuristics (every verdict comes with reasons, never a black-box boolean).
- Ships as ESM + UMD bundles with
.d.tstypes and no third-party runtime dependencies. - Is architected to scale — new signals and heuristics are added without touching existing code.
Install & usage
npm install device-signal-kitimport { collect } from 'device-signal-kit';
const result = await collect();
console.log(result.fingerprint); // e.g. "42910ff0"
console.log(result.riskScore); // 0–100
console.log(result.bot); // { isLikelyBot, reasons[], checks[] }
console.log(result.signals); // per-signal breakdowncollect() returns:
interface ScanResult {
fingerprint: string; // stable device ID (FNV-1a hash of signals)
riskScore: number; // 0–100, higher = more suspicious
signals: SignalResult[]; // one entry per collector
bot: {
isLikelyBot: boolean;
reasons: string[]; // human-readable, only the triggered ones
checks: BotCheckResult[]; // every heuristic's full result
};
}📘 Full SDK usage guide →
docs/USAGE.md— install, module formats (ESM/UMD/CDN), the complete API & result reference, enabling the opt-in interaction heuristic, extending with custom collectors/heuristics/scorers, framework examples, and the full TypeScript export list.
Live dashboard
A full demo dashboard lives in ui/. Run it with:
npm install
npm run dev # opens http://localhost:5174It shows a radial risk gauge (green < 30, amber 30–70, red > 70), the
copy-to-clipboard fingerprint, a per-signal breakdown table with
collected / inconsistent / blocked status, and a "Likely human / Likely
automated" badge with the list of triggered reasons. It supports light/dark via
prefers-color-scheme and is responsive to mobile width.
The dashboard (ui/app.ts) contains no fingerprinting/scoring/bot logic — it
only calls the library's public collect() and registry, keeping the UI layer
honestly separated from the core.
Risk-scoring approach
EntropyRiskScorer (src/scoring/entropy-risk-scorer.ts)
is a heuristic, not a trained model, and is documented as such. The
intuition:
- A genuine browser exposes a full set of high-entropy signals that are mutually consistent.
- Privacy tools and automation frameworks tend to block APIs (canvas refuses
to render, no audio context) or spoof them (software WebGL renderer,
contradictory
navigator.languages).
So the score is built up like this:
| Condition | Effect |
| --- | --- |
| Baseline | start at 5 |
| Each blocked signal | +2 (less identifiable, mildly suspicious) |
| Each inconsistent signal | +3 (active spoofing — a stronger tell) |
The total penalty is normalised against the worst case (every signal inconsistent) so the output stays a stable 0–100 regardless of how many collectors are registered. A clean, fully-consistent browser scores ~5; a browser with every signal spoofed scores 100.
Bot heuristics
Each heuristic checks exactly one condition and returns
{ name, triggered, reason } so results are explainable.
| Heuristic | File | Checks |
| --- | --- | --- |
| webdriver | webdriver-heuristic.ts | navigator.webdriver === true |
| headless-plugins | headless-plugin-heuristic.ts | Chrome UA but empty navigator.plugins/mimeTypes, or missing window.chrome |
| headless-ua | headless-ua-heuristic.ts | UA reveals an automation/embedded-runtime marker (HeadlessChrome, Electron, Puppeteer, …) |
| permissions-mismatch | permissions-heuristic.ts | Notification.permission === 'denied' while permissions.query returns 'prompt' (classic headless tell) |
| devtools-cdp (experimental) | devtools-cdp-heuristic.ts | Probes for an attached inspector/CDP session by logging an object with a getter and seeing if it gets read |
| interaction (opt-in) | interaction-heuristic.ts | Click with no preceding mousemove; perfectly linear cursor paths |
The dashboard also renders a "Heuristic checks" panel (every check, pass or fail) and a raw "Environment" panel exposing the exact values the heuristics read (
navigator.webdriver, plugin count,window.chrome, UA, …), so any verdict is fully auditable rather than a black box.
Note on
headless-ua: theElectrontoken it matches also appears in legitimate desktop apps (Slack, VS Code, agent/desktop shells), so it will false-positive on those — it's a deliberately weak, illustrative signal, not a reliable one.
Why a sophisticated agent can still read as "human"
A capable agent driving a real, non-headless browser through an extension or profile (rather than WebDriver/headless mode) typically:
- has
navigator.webdriver === false, - exposes real
navigator.pluginsand a normalwindow.chrome, - carries a stock Chrome user-agent, and
- produces mouse input indistinguishable from a person's.
In that case most client-side heuristics here correctly report "human" — and that's the right answer for what client-side detection can actually observe. Catching that class of automation requires server-side signals (request timing/rate, TLS/network fingerprinting, IP reputation, behavioural analysis over a session, challenge–response), which are out of scope for a browser-only library. This boundary is the whole point of the demo.
The one client-side angle that sometimes reaches it is the experimental
devtools-cdp heuristic: tooling that drives a real browser often attaches an
inspector/CDP session, which the probe can occasionally detect. It is
deliberately weak — it depends on Chrome version and CDP preview behaviour,
it can be evaded by avoiding console previews, and it false-positives whenever
a human has DevTools open. So a devtools-cdp trigger means "an inspector
appears attached", which is weaker than "this is a bot" — read it alongside the
other checks, never alone.
Why interaction is opt-in
Unlike the others, the interaction heuristic must attach mousemove/click
listeners and observe the user interacting with the page. That carries UX
and privacy implications the passive checks don't, so it is never enabled by
default. A caller turns it on explicitly:
import { collect, heuristicRegistry, InteractionHeuristic } from 'device-signal-kit';
heuristicRegistry.register(new InteractionHeuristic({ enabled: true }));
// ... later, after the user has had a chance to interact ...
const result = await collect();Limitations & honesty
These heuristics are demonstrations. Every one can be evaded — webdriver
can be patched out, plugins can be faked, and human-like mouse movement can be
synthesised. A real anti-bot system layers server-side signals, behavioural
models, TLS/network fingerprinting, and continuous evaluation. Do not rely
on this library as your only line of defence.
Architecture (SOLID, mapped to the code)
This project implements SOLID literally, not "in spirit". Where each principle shows up:
- S — Single Responsibility. Every collector
(
src/signals/) collects exactly one signal; every heuristic (src/bot/) checks exactly one condition. Scoring, data collection, fingerprint hashing, and UI rendering each live in their own module and never mix. - O — Open/Closed.
SignalOrchestratoris never edited to add a feature. A new signal/heuristic is a new file plus oneregister()call. The orchestrator contains zero references to canvas/WebGL/audio/navigator. - L — Liskov Substitution. Any
ISignalCollectoris swappable for any other; same forIBotHeuristicandIRiskScorer. This is enforced by shared contract tests (test/contracts/) that run the same assertions against every implementation. - I — Interface Segregation. The contracts in
src/core/interfaces.tsare tiny, single-method interfaces (collect(),check(),score()) — no bloated god-interface. - D — Dependency Inversion. The orchestrator depends only on those
interfaces, injected via its constructor.
src/index.tsis the only place concrete classes are instantiated and wired (the composition root). Plain constructor injection — no DI framework, consistent with the zero-dependency goal.
Concurrency & scalability
Signal collection runs with Promise.allSettled, so adding more signals does
not increase scan time linearly and one failing collector can never break
the others. The Registry allows third parties to
register new collectors/heuristics at runtime, so the library is extensible
without forking it.
Adding a new signal
Create one file implementing ISignalCollector, then register it. No existing
file is modified (Open/Closed):
// my-fonts-signal.ts
import type { ISignalCollector, SignalResult } from 'device-signal-kit';
export class FontsSignalCollector implements ISignalCollector {
readonly name = 'fonts';
async collect(): Promise<SignalResult> {
try {
const available = ['Arial', 'Courier', 'Times'].filter((f) =>
document.fonts.check(`12px "${f}"`),
);
return {
name: this.name,
value: available.join(','),
collected: true,
status: 'collected',
};
} catch {
return { name: this.name, value: '', collected: false, status: 'blocked' };
}
}
}import { collectorRegistry } from 'device-signal-kit';
import { FontsSignalCollector } from './my-fonts-signal';
collectorRegistry.register(new FontsSignalCollector()); // ← the one line
// collect() now includes the new signal automatically.Adding a new bot heuristic
Identical pattern with IBotHeuristic:
// timezone-mismatch-heuristic.ts
import type { BotCheckResult, IBotHeuristic } from 'device-signal-kit';
export class TimezoneMismatchHeuristic implements IBotHeuristic {
readonly name = 'timezone-mismatch';
async check(): Promise<BotCheckResult> {
const offsetTz = new Date().getTimezoneOffset();
const intlTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const suspicious = offsetTz === 0 && intlTz === 'UTC'; // common headless default
return {
name: this.name,
triggered: suspicious,
reason: suspicious
? 'UTC timezone with zero offset (common headless default)'
: 'timezone consistent',
};
}
}import { heuristicRegistry } from 'device-signal-kit';
heuristicRegistry.register(new TimezoneMismatchHeuristic()); // ← the one lineEthics & responsible use
This library collects device/browser characteristics only — canvas rendering, the WebGL renderer string, an audio-context fingerprint, and navigator/screen properties. It collects no personally identifiable information and is not tied to any real person's identity.
Even so, device fingerprinting can be used to track users across sites without their knowledge. If you use this:
- Get consent and disclose what you collect and why.
- Don't use it for covert cross-site tracking.
- Respect privacy regulations (GDPR, ePrivacy, CCPA, etc.) in your jurisdiction.
- Treat the risk score and bot verdict as signals to a human or a larger system, not as automated grounds to deny someone access.
Project layout
src/
core/ interfaces, orchestrator, registry (depend only on contracts)
signals/ canvas, webgl, audio, navigator collectors
scoring/ entropy risk scorer
bot/ webdriver, headless-plugin, interaction heuristics
fingerprint.ts pure FNV-1a hash of signals
index.ts composition root — the only place concretes are wired
test/
contracts/ shared LSP contract suites (run against every impl)
signals/ bot/ scoring/ + fingerprint, orchestrator, registry tests
ui/ vanilla-TS dashboard (no framework, dev-only)Scripts
| Command | Description |
| --- | --- |
| npm run build | Type-check, then build ESM + UMD bundles and .d.ts |
| npm test | Run the Vitest suite (jsdom) |
| npm run dev | Start the UI dashboard dev server |
| npm run typecheck | tsc --noEmit |
