Tindalabs

Open source · Self-hostable · Composable

Browser security & identity
for hostile environments

Three composable SDKs that handle what your analytics platform ignores: what your users are running, whether they've been there before, and whether you can trust the session.

Get started View on GitHub
Blindspot

@tindalabs/blindspot

OTel browser observability - spans for every navigation, interaction, and fetch without PII.

Shield

@tindalabs/shield

Tamper detection - DevTools, automation drivers, extension injection, headless environments.

Scent

@tindalabs/scent-sdk

Probabilistic identity continuity - confident even after cookie deletion, VPNs, and browser updates.

Blindspot

Privacy-first OTel browser observability

Every navigation, click, form submission, and fetch call becomes an OpenTelemetry span - without capturing IP addresses, user agents, or any other PII. Drop it into your existing OTel pipeline; it speaks OTLP natively.

  • Automatic route instrumentation for React Router, Vue Router, and Next.js App Router
  • Web vitals (LCP, CLS, FID) as first-class span attributes
  • Behavioral signals: time-to-first-interaction, paste ratio, mouse entropy, interaction rate - bot detection without a separate SDK
  • useSpan / useBlindspot hooks for manual instrumentation
  • Consent-gated - pauses collection until grantConsent() is called
  • Composes with Scent: identity context attaches to every span automatically
npm install @tindalabs/blindspot-react
import { BlindspotProvider } from '@tindalabs/blindspot-react';
import { useSpan } from '@tindalabs/blindspot-react';
import { recordEvent } from '@tindalabs/blindspot';

// Wrap your app once
<BlindspotProvider config={{ serviceName: 'my-app', endpoint: '/v1/traces' }}>
  <App />
</BlindspotProvider>

// Annotate any component
function CheckoutButton() {
  const { setAttribute } = useSpan();

  function handleClick() {
    setAttribute('checkout.cart_value', cartTotal);
    recordEvent('checkout.initiated');
  }

  return <button onClick={handleClick}>Checkout</button>;
}
import { assess, ContentProtector } from '@tindalabs/shield';

// One-shot environment assessment
const result = await assess();

console.log(result.signals);
// {
//   'shield.devtools.open':        false,
//   'shield.automation.webdriver': false,
//   'shield.automation.headless':  false,
//   'shield.frame.embedded':       false,
//   'shield.extension.detected':   false,
// }

console.log(result.risk);
// { score: 0.4, flags: ['devtools_open'] }

// Attach directly to an OTel span
span.setAttributes(result.spanAttributes);

// Or merge into a Scent observation
const obs = await scent.observe({ extraSignals: result.signals });

// ── Active protection ──────────────────────────────
// Block selection, copy, print, and screenshots; watermark.
const protector = new ContentProtector({
  targetElement: document.querySelector('#invoice'),
  preventSelection: true,
  preventClipboard: true,
  preventScreenshots: true,
  enableWatermark: true,
});
protector.protect();
Shield

Tamper detection for hostile browsers

A single assess() call returns structured risk signals and a calibrated 0-1 risk score. Signals are namespaced as shield.* OTel attributes - ready to attach to Blindspot spans or pass into Scent's risk engine.

  • DevTools detection via debugger-timing Web Worker (Chrome, Firefox, Safari)
  • WebDriver / Selenium detection from CDP artifacts and navigator.webdriver
  • Headless browser heuristics: UA string, zero plugins, missing Permissions API
  • Extension detection via DOM selectors and JS global signatures
  • Active protection: prevent selection, printing, screenshots, and watermarking via ContentProtector
npm install @tindalabs/shield
Scent

Probabilistic identity continuity

Tracks whether a returning visitor is likely the same entity - even after cookie deletion, VPN changes, browser updates, or anti-fingerprinting tools. Returns a calibrated confidence score with a signal-level explainability breakdown.

  • SimHash + weighted Jaccard matching - no deterministic hashes, no brittle equality checks
  • Drift-tolerant: a browser update or new IP doesn't break identity continuity
  • scent.identify(userId) links anonymous device identity to authenticated accounts - enabling "N accounts, same device" fraud queries
  • Private browsing and storage restriction detection built-in (storage.restricted)
  • Persistence policies (conservative | balanced | aggressive | forensic) as a first-class compliance lever
  • Risk engine: coordinated behavior, storage amnesia, impossible transitions, automation scoring
  • Self-hostable: PostgreSQL + Redis, single docker compose up
npm install @tindalabs/scent-sdk
import { init } from '@tindalabs/scent-sdk';

const scent = init({
  apiKey: 'proj_...',
  endpoint: 'https://your-scent-server/v1',
  persistence: 'balanced',
});

const obs = await scent.observe();
// obs.identity.confidence  → 0.91
// obs.identity.continuity  → 'confirmed'
// obs.identity.isNew       → false

// After the user logs in, link their account ID.
// Enables: "how many accounts share this device?"
await scent.identify(currentUser.id);
await scent.flush();

// Query the reverse: all identities ever seen for this account
// GET /v1/account/:userId/identities → fraud cluster detection

scent.on('risk_elevated', ({ score, flags }) => {
  // Block signup, require CAPTCHA, trigger step-up auth
});

Why not just use what you have?

Each package replaces a tool you might reach for first - and is built differently on purpose.

Blindspot

Why not session replay?

Hotjar, FullStory and RUM tools record the DOM - snapshots that carry PII and CIPA / wiretapping exposure. Blindspot emits structured OTel spans instead: what happened, not a recording. No DOM capture, no PII, and it drops straight into the OTel pipeline you already run.

Shield

Why not disable-devtool?

disable-devtool gives you a boolean and only watches DevTools. Shield returns structured risk signals and a calibrated 0-1 score across DevTools, automation drivers, headless and extensions - as shield.* OTel attributes you compose into a decision, not a blunt block.

Scent

Why not FingerprintJS?

A deterministic hash mints a brand-new visitor the moment one signal changes. In a reproducible benchmark under realistic drift (browser updates, VPNs, anti-fingerprinting), deterministic fingerprints re-identify ~45-55% of returning visitors - Scent, 100%, with an explainable confidence score. Self-hostable, MIT.

Composed, they cover the full session layer

Each package works independently. Together they give you a complete picture of every session: what happened, who did it, and whether to trust them.

import { BlindspotProvider, useSpan } from '@tindalabs/blindspot-react';
import { assess } from '@tindalabs/shield';
import { init } from '@tindalabs/scent-sdk';

const scent = init({ apiKey: 'proj_...', endpoint: '/v1' });

async function onPageLoad() {
  // 1. Detect environment threats
  const shield = await assess();

  // 2. Resolve identity - shield signals merge into the snapshot
  const obs = await scent.observe({ extraSignals: shield.signals });
  await scent.flush();

  // obs.identity.confidence → how sure we are this is a returning user
  // obs.risk.score          → composite threat score (device + behavior + shield signals)
}

// 3. After login: link the authenticated user to the Scent identity.
//    Enables "how many accounts share this device?" fraud queries.
async function onLogin(userId: string) {
  await scent.identify(userId);
  await scent.flush();
}

// 4. Every OTel span gets identity + risk context automatically
//    scent.identity.id, scent.identity.confidence, scent.risk.score
//    shield.devtools.open, shield.automation.webdriver, ...
//    ux.session.time_to_first_interaction_ms, ux.input.paste_ratio, ...

This page runs the full stack

Blindspot is instrumenting every click and navigation you make right now. The panel below shows live Shield and Scent output from your current session.

Shield · assess()

Click Run to assess this session.

Scent · observe()

Runs after Shield assessment.

Scent · identify()
await scentClient.identify('demo-user@tindalabs.io');
// Links this fingerprint → account ID in one call

Runs after identity is resolved above.

Shield · ContentProtector
Confidential

Q4 Financial Summary

ClientAcme Corporation
Total Revenue$4.2M
Growth (YoY)+23%
Risk RatingAA
Prepared byFinance Dept.

Try selecting or copying this text. Enable protection to block it.

Enable protection, then toggle strategies individually.

Text selection
Context menu
Keyboard shortcuts
Print / Save as PDF
Screenshot capture
Clipboard copy/cut
Watermark overlay
DevTools detection
Extension access
Shield · assessAndProtect()
riskScore ≥ 0.2
enableWatermark
riskScore ≥ 0.5
preventSelectionpreventClipboard
headless = true
preventContextMenupreventKeyboardShortcuts

Click Run to assess and apply policies.

Blindspot · trace

Blindspot emits an OTel span for every navigation, click and fetch - correlated by W3C traceparent, no PII. They render in-page here; in production they ship to your collector (Tempo / Grafana). recordEvent() attaches a custom event to the active route span.

recordEvent('checkout.started', {
  'cart.item_count': 3,
  'cart.total_usd': 142.50,
  'user.plan': 'pro',
});
// → event added to the active route span

Click Generate trace to emit a span tree and see it rendered here.