Probabilistic identity continuity for hostile browser environments.
Scent tracks whether a returning visitor is "likely the same entity" even after cookie deletion, VPN changes, browser updates, or anti-fingerprinting tools — using a drift-tolerant confidence scoring engine, not deterministic hashes.
Live demo — run Scent, Blindspot and Shield together in your browser. (Reload and re-run to watch Scent recognise you across visits.)
import { init } from '@tindalabs/scent-sdk';
const sdk = init({ apiKey: 'your-api-key', persistence: 'balanced' });
const obs = await sdk.observe();
await sdk.flush();
console.log(obs.identity.confidence); // 0.91
console.log(obs.identity.continuity); // "confirmed"
console.log(obs.risk.score); // 0.07FingerprintJS computes a hash. One signal changes → different hash → different visitor. In the real world users update browsers, switch VPNs, and clear cookies constantly. Scent uses probabilistic similarity scoring: 18 of 20 signals match → confidence 0.93, continuity confirmed.
| FingerprintJS | Scent | |
|---|---|---|
| Approach | Deterministic hash | Probabilistic similarity |
| Browser update | New visitor | 0.91 confidence |
| Cookie deletion | New visitor | Server-side resurrection |
| VPN change | New visitor | Stable signals still match |
| Explainability | Black box | Per-signal breakdown |
| Self-hostable | No | Yes |
| Open source | No | Yes |
Don't take our word for it. A reproducible benchmark (bench/, pnpm bench) holds signal collection constant and varies only the matching algorithm: under realistic drift, deterministic re-identification recall is FingerprintJS 45% / ThumbmarkJS 55% / Scent 100% — and it reports Scent's confidence gradient and false-merge rate too, not just the headline. See bench/RESULTS.md.
sdk.observe()— collects ~50 browser signals (canvas, audio, fonts, hardware, screen, locale, anti-tamper heuristics)sdk.flush()— sends the snapshot to your Scent server- Identity engine — runs SimHash candidate lookup + weighted Jaccard similarity against your identity store
- Confidence score —
0–1float with a human-readablecontinuityband and per-signal explanation
Confidence bands:
confirmed(≥ 0.85) — same stable signals, minor driftprobable(≥ 0.60) — some signals changed, very likely same entityuncertain(≥ 0.35) — significant drift, worth re-authenticatingunknown(< 0.35) — treat as new
git clone https://github.com/tindalabs/scent
cd scent
docker compose upThis starts:
- scent-server on
localhost:3000(identity API) - scent-observatory on
localhost:4000(identity UI) - PostgreSQL, Redis, OTel Collector, Grafana Tempo
npm install @tindalabs/scent-sdkimport { init } from '@tindalabs/scent-sdk';
const sdk = init({
apiKey: 'your-api-key', // from Observatory → Project Settings
endpoint: 'https://your-scent-server/v1',
persistence: 'balanced', // conservative | balanced | aggressive | forensic
});
// On each significant interaction (login, signup, checkout):
const obs = await sdk.observe();
await sdk.flush();
// Use the result
if (obs.identity.continuity === 'unknown' || obs.risk.score > 0.6) {
// Challenge this user — step-up auth, CAPTCHA, manual review
}http://localhost:4000 — browse identities, inspect signal profiles, see drift timelines, review risk flags.
| Option | Type | Default | Description |
|---|---|---|---|
apiKey |
string |
required | Project API key |
endpoint |
string |
https://api.tindalabs.dev/v1 |
Scent server URL |
persistence |
PersistencePolicy |
'balanced' |
Storage and collection scope |
traceparentProvider |
() => string | null |
— | OTel traceparent hook (see OTel bridge) |
Collects signals, attempts to recover a prior identity from storage, and returns a ScentObservation. Does not make a network request.
const obs = await sdk.observe();
obs.identity.id // string — the persistent scent ID
obs.identity.confidence // 0–1 float
obs.identity.isNew // boolean — first-ever observation
obs.identity.continuity // "confirmed" | "probable" | "uncertain" | "unknown"
obs.drift.detected // boolean
obs.drift.delta // string[] — signal names that changed
obs.drift.entropy // float — magnitude of change
obs.risk.score // 0–1 float (Phase 3, server-resolved)
obs.risk.flags // string[] — e.g. ["automation_suspected", "vpn_detected"]Sends buffered snapshots to the server. Resolves when the server has ingested and scored them. Safe to call multiple times; no-ops when buffer is empty.
Returns the current signal state without resolving or persisting identity. Useful for debugging signal collection.
Returns which storage layers are available in the current browser session.
If your app uses OpenTelemetry, the @tindalabs/scent-otel bridge attaches identity and risk context to your existing spans:
import { init } from '@tindalabs/scent-sdk';
import { ScentOtelBridge, readTraceparent } from '@tindalabs/scent-otel';
const sdk = init({ apiKey: '...', traceparentProvider: readTraceparent });
const bridge = new ScentOtelBridge(sdk);
const obs = await bridge.observe(); // span annotated with scent.identity.*
await bridge.flush();Span attributes set: scent.identity.id, scent.identity.confidence, scent.identity.continuity, scent.risk.score, scent.risk.flags.
See OTel bridge guide for full setup including @tindalabs/blindspot integration.
Copy .env.example and configure:
DATABASE_URL=postgresql://scent:password@localhost:5432/scent
REDIS_URL=redis://localhost:6379
PORT=3000
# OTel (optional)
OTEL_SERVICE_NAME=scent-server
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318Migrations run automatically on server start. To run them standalone:
pnpm --filter @tindalabs/scent-server migrateThe scent-server image is published on every release to both GitHub Container Registry and Docker Hub, tagged latest and with the commit SHA:
docker pull ghcr.io/tindalabs/scent-server:latest
# or, from Docker Hub
docker pull tindalabs/scent-server:latestPull this instead of building from source. The server is licensed under BSL-1.1 (see License); the SDK, engine, and OTel bridge remain MIT.
For a single-VPS production stack — server + async-ingest worker + Postgres + Redis + automatic HTTPS (Caddy), using the prebuilt image — see deploy/:
cd deploy
cp .env.example .env # set SCENT_DOMAIN, ACME_EMAIL, POSTGRES_PASSWORD
docker compose pull && docker compose up -dFull runbook (DNS, minting an API key, updates, backups) in deploy/README.md. Sized for a ~€4/mo VPS such as a Hetzner CX22.
The repo-root docker-compose.yml is the local dev stack instead — it builds from source and bundles the Observatory UI plus a Grafana Tempo observability stack and a demo API key.
- Concepts — probabilistic identity, drift, confidence, risk
- Signal Reference — every collected signal, stability class, GDPR notes
- Persistence Policies — storage scopes, compliance guide
- REST API — OpenAPI 3 spec (the authoritative contract; lint with
pnpm openapi:lint) - OTel Bridge — tracing integration guide
- Migrating from FingerprintJS
| Package | Description |
|---|---|
@tindalabs/scent-sdk |
Browser SDK — signal collection, multi-layer persistence, OTel bridge |
@tindalabs/scent-engine |
Probabilistic matching — SimHash, weighted Jaccard, drift, risk scoring |
@tindalabs/scent-otel |
OTel bridge — attaches scent.* identity attributes to existing spans |
@tindalabs/scent-server |
Node.js API server — identity resolution, drift history, risk assessment |
Apps (not published to npm):
| App | Description |
|---|---|
Observatory |
React UI — identity list, drift timelines, risk dashboard (port 4000) |
demo |
Standalone demo app wiring all packages together (port 5174) |
@tindalabs/scent-sdk (browser)
├── ~50 signal collectors
├── Multi-layer persistence (localStorage, IndexedDB, cookies, ETag)
└── OTel traceparent bridge
@tindalabs/scent-server (Node.js)
├── POST /v1/events — snapshot ingestion
├── SimHash + Jaccard identity engine
├── Drift detection + history
├── Risk scoring (6 anomaly detectors)
└── REST query API
@tindalabs/scent-observatory (React, port 4000)
├── Identity list + detail pages
├── Drift timeline visualization
└── Risk dashboard
Scent is one of three composable browser-layer packages:
| Package | What it does |
|---|---|
| @tindalabs/blindspot | Privacy-first OTel frontend observability |
| @tindalabs/shield | Tamper detection & active content protection |
| @tindalabs/scent | Probabilistic identity continuity |
Pass @tindalabs/shield assessment results directly into observe() so the server's risk engine sees tamper signals alongside the browser fingerprint:
import { init } from '@tindalabs/scent-sdk';
import { assess } from '@tindalabs/shield';
const scent = init({ apiKey: '...', endpoint: '...' });
const shield = await assess();
const obs = await scent.observe({
extraSignals: shield.signals,
});
await scent.flush();The shield.* signals become first-class fields in the stored snapshot and are visible in drift timelines and risk assessments in the Observatory.
Open-core licensing:
- SDK, Engine, OTel bridge (
@tindalabs/scent-sdk,@tindalabs/scent-engine,@tindalabs/scent-otel) — MIT. Free to use, embed in apps, modify, and redistribute. - Server (
@tindalabs/scent-server) — Business Source License 1.1 (BSL-1.1). Free to self-host for non-commercial use; converts to MIT on June 12, 2031. Commercial hosting (SaaS) is restricted until the conversion date.
The licensing split reflects our open-core model: the SDK and matching engine are freely embeddable in your app; self-hosters can run the server on their own infrastructure; the revenue model is the hosted SaaS tier (Phase 7), where we handle Postgres, Redis, worker scaling, Observatory UI, and enterprise features like SSO and audit logs.
Built by tindalabs.dev.