Skip to main content
LoginForm collects two categories of data before a login request is submitted: a device fingerprint (FingerprintData) assembled from hardware and browser signals, and behavioral biometrics (BehaviorData) assembled from keystroke timing, mouse movement, and scroll patterns during the time the user spends on the form. Both are sent alongside credentials on POST /auth/login to help the backend identify automated login attempts and flag anomalous behavior. The goal is not to block users — fingerprinting and behavioral biometrics are signals. The backend decides what to do with them: challenge a suspicious session, raise the risk score, require additional verification, or log the event for review. The library’s responsibility is to collect these signals faithfully and transmit them with the login request.
Fingerprinting is non-blocking. The getFingerprint() call starts in the background when LoginForm mounts — it does not delay the form render or the submit button. If fingerprinting has not completed by the time the user clicks submit, the login proceeds with whatever signals are available.

FingerprintData — device signals

FingerprintData is populated by getFingerprint() in src/utils/fingerprint.ts. The result is cached in a module-level variable after the first call and reused for the session lifetime. Call resetFingerprintCache() to clear it between sessions. The device fingerprint is assembled from three layers of signals: basic browser/OS identity parsed from the user agent string, hardware characteristics gathered via WebGL and AudioContext APIs, and privacy-mode indicators detected via storage quota and IndexedDB probes.

Signal inventory

FieldTypeDescription
deviceFingerprintstring32-bit hash (base-36) of userAgent, language, screen dimensions+colorDepth, devicePixelRatio, timezone, hardwareConcurrency, platform
deviceBrowserstringBrowser name + major version (e.g., “Chrome 120”) — parsed from userAgent
deviceOsstringOS name + version (e.g., “macOS 14.2”, “Android 13”) — parsed from userAgent
deviceModelstringDevice model (e.g., “iPhone”, “Mac”, “PC”) — parsed from userAgent
deviceNamestringCombined OS + browser string (e.g., “macOS 14.2 — Chrome 120”) — used as human-readable device name
isVpnbooleanVPN detection via WebRTC ICE candidate IP analysis (see VPN detection section)
isIncognitobooleanIncognito/private browsing detection via storage quota probe (see incognito detection section)
gpuRendererstring | undefinedGPU renderer string from WEBGL_debug_renderer_info extension (e.g., “ANGLE (Apple M2)“)
gpuVendorstring | undefinedGPU vendor string from WEBGL_debug_renderer_info (e.g., “Google Inc.”)
canvasHashstring | undefinedHash of a 2D canvas rendering of a deterministic scene — varies across GPU pipelines and font renderers
audioHashstring | undefinedSum of first 32 frequency bins from a silent AudioContext oscillator — varies across CPU/OS audio stacks
Fields marked | undefined are omitted if the underlying browser API is unavailable (no WebGL, AudioContext blocked, etc.). The backend must treat these as optional signals — their absence does not indicate fraud. Browser extensions like Canvas Blocker can spoof or suppress these values intentionally; the backend should not hard-block on the absence of GPU or canvas signals.

How the core fingerprint hash is built

The deviceFingerprint field is a 32-bit integer (formatted as a base-36 string) computed from a |-delimited concatenation of: navigator.userAgent, navigator.language, screen dimensions with color depth (WxHxD), window.devicePixelRatio, the IANA timezone from Intl.DateTimeFormat().resolvedOptions().timeZone, navigator.hardwareConcurrency, and navigator.platform. The hash algorithm is a simple djb2-style accumulator — it is fast and deterministic but not cryptographically strong. It is a stable device identifier across page reloads for the same browser profile, not a collision-resistant token.

GPU fingerprinting

gpuRenderer and gpuVendor are extracted via the WEBGL_debug_renderer_info WebGL extension. When the extension is available, the full unmasked GPU string is captured (e.g., “ANGLE (Apple M2, APPLE M2, OpenGL 4.1)”). When unavailable, the masked values from gl.RENDERER and gl.VENDOR are used as fallbacks. canvasHash is derived from a 240×60 canvas rendering of a deterministic scene that uses two fonts (Arial and Times New Roman), two fill colors with alpha, and Unicode characters. Different GPU pipelines and font rendering engines produce subtly different pixel outputs, which produces a different hash. The hash is computed with the same djb2-style accumulator applied to the canvas’s toDataURL() output.

Audio fingerprinting

audioHash is computed by running a silent triangle-wave oscillator through an AnalyserNode for 100ms. The first 32 frequency bins from getFloatFrequencyData() are summed and formatted to 6 decimal places. The processing result varies across CPU architectures and OS audio stacks due to floating-point accumulation differences. The oscillator runs at gain=0 — no audible sound is produced.

VPN detection

VPN detection uses the WebRTC API to enumerate ICE candidates and extract IP addresses exposed by the browser’s network stack. Even when a VPN is active, WebRTC may reveal the local network IP via the ICE negotiation process because the browser’s ICE gathering happens at the operating system level, below the VPN tunnel in some configurations.

Mechanics

  1. A new RTCPeerConnection is created with Google’s STUN server (stun:stun.l.google.com:19302).
  2. An empty data channel is created to trigger ICE candidate generation.
  3. createOffer() is called and the SDP is set as the local description — this triggers ICE candidate gathering.
  4. Each ICE candidate’s candidate string is parsed with a regex to extract IPv4 and IPv6 addresses.
  5. After 2 seconds (or when ICE gathering completes), the collected IP addresses are inspected.
  6. Addresses are categorized as private (10.x, 192.168.x, 172.x, 127.0.0.1, ::) or public.
  7. Heuristic: If more than 1 public IP address is detected, isVpn is set to true. Multiple public IPs suggest split-tunneling, where the VPN routes some traffic through a separate public IP while other traffic exits the VPN’s endpoint.
The 2-second timeout is intentional. ICE gathering may take longer on slow or constrained networks, but waiting indefinitely would hold the fingerprinting result and delay the login request on form submission. The timeout resolves with whatever IPs have been gathered within that window.
This is a heuristic, not a definitive VPN detector. It generates false positives for users on networks with multiple legitimate public IPs (e.g., enterprise dual-homed networks, certain IPv6 configurations). It also generates false negatives for VPNs that fully block WebRTC or that don’t use split tunneling. The backend should treat isVpn: true as a risk signal, not a hard block.

GDPR / privacy implications

WebRTC IP probing may expose a user’s private (local network) IP address without explicit consent. Under GDPR Article 5(1)(a), processing personal data requires a lawful basis. An IP address — even a private LAN IP — may qualify as personal data in some jurisdictions. Before enabling fingerprinting in production, review whether your privacy policy discloses this collection and whether consent is required in your target jurisdictions. This area is jurisdiction-dependent — flag for legal review before publishing. (This warning reflects a known open item in the project.)

Incognito / private browsing detection

detectIncognito() uses three browser-specific heuristics to detect private browsing mode. None are 100% reliable — browser vendors actively work to reduce fingerprinting surface, and detection techniques that work today may stop working in future browser versions. The three methods run in parallel via Promise.all(). If any method returns true, the overall result is true. The result is cached alongside FingerprintData.
MethodBrowsersHow it works
Storage quota probeChrome, Edge, Firefoxnavigator.storage.estimate() — incognito mode caps the storage quota to ~120MB vs the multi-GB quota in normal mode. If quota < 200MB, incognito is inferred.
openDatabase throwSafariCalling window.openDatabase() throws in Safari’s private mode.
indexedDB.open() rejectionFirefoxFirefox disables IndexedDB in private mode. Opening a database rejects immediately.
All three methods are async and run in parallel via Promise.all(). The result is cached alongside FingerprintDatadetectIncognito() is not called on every login attempt, only on the first fingerprint collection per module lifecycle. The storage quota method is most reliable across browsers; the Safari and Firefox methods exist as complementary signals for browsers where quota-based detection is less accurate.

Invocation lifecycle

Understanding when fingerprinting runs helps diagnose timing issues in integration and testing.

In LoginForm

LoginForm mounts
  └─ useEffect fires (once, on mount)
       └─ fingerprintRef.current = getFingerprint()  ← Promise stored, not awaited
            └─ runs in background:
                 ├─ buildFingerprint()          ← synchronous, completes immediately
                 ├─ getGpuInfo()                ← synchronous, completes immediately
                 ├─ detectIncognito()           ← async, storage quota + indexedDB probe
                 ├─ detectVpn()                 ← async, RTCPeerConnection + 2s timeout
                 └─ getAudioHash()              ← async, oscillator + 100ms settle

User clicks submit
  └─ handlePasswordSubmit fires
       └─ await Promise.all([
              fingerprintRef.current ?? getFingerprint(),  ← resolves immediately if done
              getBehaviorSnapshot()
          ])
       └─ login(email, password, { fingerprint: fp, behavior })
If the fingerprint Promise has already resolved before submit, the await returns instantly. If the VPN detection is still pending (common on slow networks), the login waits for it to resolve or timeout. The 2-second detectVpn() timeout is the worst-case latency introduced by fingerprinting on the submit path.

Module-level cache

getFingerprint() stores its resolved value in a module-level _cached variable. Subsequent calls to getFingerprint() return the cached value synchronously (via the returned Promise resolving immediately). The cache persists for the lifetime of the JavaScript module — it survives page navigation within a SPA but resets on a full page reload. Call resetFingerprintCache() explicitly if you need to force a fresh fingerprint collection (e.g., after a user logs out and a new user logs in within the same SPA session).

Server-side rendering (SSR)

All fingerprinting functions guard against SSR environments with typeof navigator === 'undefined' and typeof window === 'undefined' checks. On the server, getFingerprint() returns an object with deviceFingerprint: 'ssr' and all other fields set to empty strings or false. The backend must handle the 'ssr' sentinel value as a missing signal, not a valid fingerprint.

Known limitations

Fingerprinting and bot detection are probabilistic signals with known gaps. Integrators should understand these limitations before making authorization decisions based on them.
LimitationAffected signalsNotes
Browser autofillAll KeystrokeDynamics, all BotSignalsAutofill pastes credentials without keydown events. keyCount=0, all bot flags false. Not distinguishable from a clean manual entry in the signals alone.
Canvas/audio spoofingcanvasHash, audioHash, gpuRendererBrowser extensions (Canvas Blocker, JShelter) can randomize or suppress these values. Absence or randomized values should not be treated as fraud.
VPN false positivesisVpnEnterprise networks, dual-homed interfaces, certain IPv6 configurations can produce multiple public IPs without a VPN being active.
Incognito evasionisIncognitoBrowser vendors regularly update storage APIs to reduce incognito detection accuracy. Detection rates vary by browser version.
Touch devicesAll MouseDynamicsTouch-only devices produce no mousemove events. mouse will be null. This is expected behavior, not a bot signal.
Headless browser with mouse simulationMouseDynamics, BotSignalsSophisticated automation frameworks can generate realistic mouse movement events. hasMouseEvents=true does not guarantee a human is present.
None of these limitations are defects to be fixed — they are inherent to client-side signal collection. The correct mitigation is to use these signals as one layer in a multi-factor risk model on the backend, not as a standalone gate.

BehaviorData — behavioral biometrics

BehaviorData captures user interaction patterns during the time between LoginForm mounting and the login request being submitted. These signals are used by the backend to distinguish human users from automated bots and credential stuffing tools. Unlike device fingerprinting, behavioral biometrics are session-specific — they measure how the user interacted with the login form, not which device they used. A credential stuffing bot that rotates device fingerprints will still exhibit automated keystroke and mouse patterns.

KeystrokeDynamics

Keystroke dynamics measure the timing of individual key press and release events. The two primary measurements are dwell time (how long each key is held down) and flight time (the gap between releasing one key and pressing the next). Human typists exhibit natural variance in both dimensions; automated tools produce unnaturally consistent intervals.
FieldTypeDescription
dwellMeannumberMean time a key is held down, in milliseconds
dwellStdnumberStandard deviation of dwell time
flightMeannumberMean time between consecutive keydown events, in milliseconds
flightStdnumberStandard deviation of flight time
wpmnumberEstimated words per minute
keyCountnumberTotal keystrokes observed
errorRatenumberRatio of backspace events to total keystrokes
keystroke in BehaviorData is null if fewer than 3 keystrokes were captured — for example, if the user autofills credentials and submits immediately. The backend must handle null gracefully.

MouseDynamics

Mouse dynamics capture movement patterns during the form session. Bot-operated sessions typically exhibit either no mouse movement at all or perfectly linear movements; human users exhibit curved, variable-speed paths with idle pauses and micro-corrections.
FieldTypeDescription
speedMeannumberMean cursor speed in px/ms
speedStdnumberStandard deviation of cursor speed
directionChangesnumberDirection change rate 0–1 (low = straight-line / bot; high = human)
totalDistanceNormnumberTotal cursor distance traveled divided by the screen diagonal
idleRationumberFraction of samples where the cursor was idle (not moving)
sampleCountnumberTotal movement samples captured
mouse in BehaviorData is null if fewer than 5 movement samples were captured — for example, on touch-only devices or if the user navigates entirely via keyboard.

ScrollDynamics

Scroll events on the login form are relatively rare — the form is typically short enough to fit in the viewport without scrolling. When scroll events do occur, their velocity and direction patterns can distinguish human scrolling (variable speed, occasional direction reversals) from scripted scrolling (constant-rate, single-direction).
FieldTypeDescription
eventCountnumberTotal scroll events captured
totalDeltaNormnumberTotal scroll distance divided by viewport height
velocityMeannumberMean scroll delta per event, in pixels
directionFlipsnumberNumber of up→down or down→up direction changes
scroll is null if no scroll events occurred before submission. On short login forms that don’t require scrolling, this is the expected state and should not be treated as a bot indicator.

InteractionTiming

Interaction timing captures the macro-level temporal structure of the login session — how quickly the user started typing after the form appeared, how long they spent typing, and how many significant pauses occurred.
FieldTypeDescription
timeToFirstKeynumberMilliseconds from component mount to first keydown event
totalTypingDurationnumberMilliseconds from first to last keydown before submit
pauseCountnumberCount of typing gaps greater than 500ms (hesitation events)
sessionDurationnumberMilliseconds from component mount to behavioral data snapshot

BotSignals

BotSignals provides pre-computed boolean flags derived from the raw behavioral data. These are designed to give the backend a fast path to high-confidence signals without requiring the backend to implement its own behavioral analysis.
FieldTypeDescription
hasMouseEventsbooleanAny mouse movement was detected during the session
hasScrollEventsbooleanAny scroll events were detected during the session
anomalousTypingSpeedbooleantrue if wpm > 300 — physically impossible for humans; indicates automated input
uniformIntervalsbooleantrue if all flight intervals are within 5ms of each other — characteristic of synthetic keyboard input
anomalousTypingSpeed and uniformIntervals are high-confidence bot indicators. If either is true, the backend should treat the login attempt as suspicious. However, they do not fire for autofill — browser autofill pastes credentials without generating keystroke events, so keyCount will be 0 and all bot signal fields will be false. Autofill is not detectable via keystroke dynamics. Do not treat keyCount=0 combined with anomalousTypingSpeed=false as a clean signal — it may simply mean the user autofilled their credentials.

Privacy obligations

The fingerprinting and behavioral biometric collection described on this page may constitute personal data processing under GDPR (EU), CCPA (California), and similar frameworks in other jurisdictions. The following areas require review before deploying to production.
Data collectedPotential classificationAction required
WebRTC IP addresses (local and public)Personal data (IP address) in most jurisdictionsDisclose in privacy policy; assess consent requirement
Canvas fingerprintUnique device identifier in EU ePrivacy Directive scopeDisclose in privacy policy; may require consent under PECR (UK)
Audio fingerprintUnique device identifierDisclose in privacy policy; assess consent requirement
Keystroke timing dynamicsBiometric behavioral data (may qualify under GDPR Art. 9)Flag for legal review — biometric data has heightened protection in GDPR
Mouse/scroll dynamicsBehavioral dataDisclose in privacy policy
The keystroke timing row deserves particular attention. GDPR Article 9 defines biometric data used for unique identification as a special category requiring explicit consent or another heightened lawful basis. Whether keystroke dynamics qualify depends on how the backend uses the data and whether it is stored in a way that can uniquely re-identify individuals. This determination is jurisdiction-dependent.
This page describes the technical implementation of data collection. It does not constitute legal advice. Whether and how you must disclose this collection, obtain consent, or implement data retention limits depends on your jurisdiction, your users’ jurisdictions, and the nature of your app. Flag all items in the table above for legal review before publishing to production users.

Next steps

Token Security Model

Login Spoofing Prevention