npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@onedevkh/device-fingerprint

v0.2.0

Published

Privacy-conscious, SSR-safe device fingerprinting and device ID for Angular 20 standalone applications.

Readme

Device Fingerprinting

A production, privacy-conscious device fingerprinting library for Angular 20 standalone applications. It provides two independent identifiers — a persistent, random application device ID, and a SHA-256 device fingerprint hash derived from low-risk browser signals — for use as supporting signals in device recognition and risk-based authentication. Neither is a secret, a credential, or a replacement for authentication.

This file is the practical usage guide. Deeper material lives in the source repository rather than in the published package: docs/architecture/device-fingerprinting.md for architectural background and rationale, and docs/plans/device-fingerprinting-todo.md for the incremental build history and the design decisions behind everything below. References to those two files throughout this document mean paths in that repository.

Upgrading to 0.2.0

One breaking change, and it only affects applications that call DeviceApiService.registerDevice():

| | 0.1.0 | 0.2.0 | | --- | --- | --- | | Endpoint | POST ${apiUrl}/api/devices/register | POST ${apiUrl}/api/devices | | Return type | Observable<void> | Observable<unknown> |

The old path was a placeholder chosen before any backend existed. If your backend serves the old one, either move it or add a rewrite — see §14.

Everything else is additive, and your existing fingerprints do not change. The eight opt-in high-entropy signal groups (§8) all default to off, and with none enabled the canonical string fed to SHA-256 is byte-identical to 0.1.0's. Enabling any group is what changes the hash, and that is under your control. Also new: collection confidence (§9) and JSON export (§11).

1. Overview

  • Device ID — a random UUID, persisted locally, stable across sessions when storage works. Independent of the fingerprint.
  • Device fingerprint — a SHA-256 hash of normalized, canonically-serialized browser signals (user agent, screen, timezone, language, hardware hints, etc.). Deterministic for the same signals, but expected to change when the browser/device genuinely changes.
  • Both are computed and exposed through DeviceFingerprintService, the module's primary public API.
  • The backend remains the final authority for authentication, authorization, device trust, and risk decisions — this module never makes those decisions itself. See docs/architecture/backend-security-requirements.md for what a backend integrating with this module is responsible for.

2. Architecture

src/
├── lib/
│   ├── models/         Type definitions (DeviceFingerprintComponents, DeviceIdentity, DeviceInfo, DeviceFingerprintConfig, ...)
│   ├── collectors/     BrowserSignalCollector (always on) + eight opt-in high-entropy collectors, all SSR-safe
│   ├── services/       DeviceFingerprintService, DeviceStorageService, DeviceApiService
│   ├── utils/          BrowserEnvironment, normalizers, hasher, device-detector, confidence estimator
│   ├── interceptors/   deviceIdInterceptor
│   └── tokens/         DEVICE_FINGERPRINT_CONFIG, provideDeviceFingerprint()
└── public-api.ts       Public API surface

The fingerprint pipeline: collect → normalize → canonically serialize → SHA-256 hash → cache. Every stage is independently unit-tested; see docs/plans/device-fingerprinting-todo.md Phases 7–14 for the pipeline's design and Phase 21 for the test coverage audit.

3. Installation

npm install @onedevkh/device-fingerprint

Requires Angular 20 (@angular/common and @angular/core ^20.3.0 are peer dependencies). Then wire it into app.config.ts (this is exactly what this repository's own demo app in src/app/app.config.ts does):

import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { deviceIdInterceptor, provideDeviceFingerprint } from '@onedevkh/device-fingerprint';

export const appConfig: ApplicationConfig = {
  providers: [
    // ...your other providers,
    provideHttpClient(withFetch(), withInterceptors([deviceIdInterceptor])),
    provideDeviceFingerprint({
      storageKey: 'app_device_identity',
      attachDeviceIdHeader: true
    })
  ]
};

provideHttpClient()/withInterceptors() are only required if you want the HTTP interceptor; provideDeviceFingerprint() is only required if you want to override the defaults (see Configuration) — every service works with zero configuration.

4. Configuration

provideDeviceFingerprint(config?: DeviceFingerprintConfig) accepts:

| Option | Default | Effect | | --- | --- | --- | | storageKey | 'app_device_identity' | The localStorage/sessionStorage key used to persist the device identity. | | enablePersistence | true | Set false to force memory-only storage even when localStorage/sessionStorage are available. | | enableFingerprintCache | true | Set false to recompute the fingerprint on every getFingerprint() call instead of caching it. Concurrent-call deduplication still applies regardless of this setting. | | apiUrl | '' (same-origin) | Base URL prefix for DeviceApiService's requests, e.g. 'https://api.example.com'. A trailing slash is fine — it is stripped. Setting this also makes that origin eligible for the X-Device-ID header (see HTTP interceptor). | | attachDeviceIdHeader | true | Set false to make the HTTP interceptor a no-op without removing it from the app's interceptor chain. | | signals | {} (none) | Per-group switches for the opt-in high-entropy signals. Every group defaults to off. | | signalTimeoutMs | 1000 | Per-group time budget for the opt-in collectors. A group that overruns is reported as unavailable instead of delaying the fingerprint. |

All fields are optional; omitting provideDeviceFingerprint() entirely is equivalent to calling it with {}.

5. Basic usage

Inject DeviceFingerprintService and call its methods:

const deviceId = service.getDeviceId();

const fingerprint =
  await service.getFingerprint();

const deviceInfo =
  await service.getDeviceInfo();

6. Getting the device ID

const deviceId: string = service.getDeviceId();

Synchronous — reads the persisted identity if valid, otherwise generates and persists a new one. Stable across sessions when storage works; independent of the fingerprint; never regenerated on repeated calls within the same session. See DeviceStorageService.getDeviceId() for the underlying implementation.

7. Getting the fingerprint

const fingerprint: string = await service.getFingerprint();       // cached after the first call
const fresh: string = await service.refreshFingerprint();         // bypasses the cache, recomputes
const raw = await service.getFingerprintComponents();             // the un-hashed signals themselves
  • getFingerprint() caches its result in memory (unless enableFingerprintCache: false) and deduplicates concurrent calls — three simultaneous calls trigger exactly one computation. getDeviceInfo() participates in the same cache and deduplication.
  • Resolves to '' (never throws or rejects) if hashing is unavailable — during SSR, or if Web Crypto is missing — and is never cached in that case, so a later call will retry.
  • refreshFingerprint() always clears the cache and recomputes, without regenerating the device ID.

Not every collected signal feeds the hash. online and timezoneOffset are collected and exposed on getFingerprintComponents(), but deliberately excluded from the hash input, because they change without the device changing: online flips with network connectivity, and timezoneOffset flips at every DST transition — which would hand the backend a false "new device" signal twice a year for every user in a DST-observing region. The IANA timezone name is hashed instead; it identifies the same zone and is stable across DST. See lib/utils/fingerprint-normalizer.ts.

The remaining signals are stable for a given browser profile but not immutable — notably pixelRatio changes with browser zoom, and screenWidth/screenHeight change when a window moves between displays. Treat a changed fingerprint as a signal to evaluate, never as proof of a different device.

8. Opt-in high-entropy signals

The signals in section 7 are the always-on baseline: low risk, but shared by large populations, so they distinguish browser/OS/hardware classes far better than individual devices. Eight additional signal groups are available for applications that need more distinguishing power. Every one of them is off by default and is only collected if you name it, because several are invasive enough that turning them on is a decision an application must make deliberately — and disclose.

provideDeviceFingerprint({
  signals: {
    clientHints: true,   // recovers what Chromium's frozen User-Agent hides
    webgl: true,         // GPU vendor/renderer strings and capability limits
    display: true,       // colour gamut, HDR, available screen area
    platform: true,      // navigator build traits and the platform's ICU data
    math: true,          // transcendental function results (engine build)
    canvas: true,        // INVASIVE — rasterizes and hashes pixels
    audio: true,         // INVASIVE — renders an offline audio graph
    fonts: true          // INVASIVE — enumerates installed fonts
  }
});

const extended = await service.getExtendedSignals();

| Group | Risk | Entropy | Cost | What it reads | | --- | --- | --- | --- | --- | | canvas | Invasive | Very high | ~2–5 ms, one offscreen rasterization | Digests of a rendered geometry scene and a mixed-script text sample. Varies with GPU, driver, font rasterizer and anti-aliasing. | | webgl | Low | Very high | ~1–3 ms, one 1×1 context | Vendor/renderer strings (including the unmasked GPU name where exposed), version strings, capability limits, a digest of the extension list. Reads parameters only — it never renders or reads back pixels. | | fonts | Invasive | High | ~5–15 ms, two forced reflows | Which of 29 common fonts are installed, by measuring rendered text width. Reveals installed software, and sometimes locale/accessibility choices. | | audio | Invasive | High | ~10–30 ms, off the main thread | The summed output of an offline oscillator/compressor render. Nothing is played and no microphone is touched — the entropy is in each platform's audio DSP floating-point behaviour. | | clientHints | Low | Moderate | One async call | navigator.userAgentData, including high-entropy values (platform version, architecture, bitness, model, full browser version). Recovers accuracy Chromium removed from the User-Agent string. GREASE brands are filtered out. | | platform | Low | Moderate | <1 ms | navigator.vendor/product/webdriver/pdfViewerEnabled, plugin and MIME-type counts (never names), storage availability, and samples of the platform's ICU date/number/collation output. | | display | Low | Low | <1 ms, CSS media queries | Available screen area, colour gamut, monochrome bit depth, HDR, forced-colors, inverted-colors. | | math | Low | Low | <1 ms | Results of 19 transcendental functions. No DOM, no permission, nothing user-specific — only JS-engine-build-specific. |

Behaviour guarantees, all covered by tests:

  • Nothing runs unless enabled. A collector for a disabled group is never invoked, so an unused group costs nothing.
  • A disabled group is null; an enabled-but-unavailable group is present with supported: false. Keeping those distinguishable is what makes the confidence breakdown meaningful.
  • Enabling nothing produces the same hash as before these collectors existed — the canonical input is byte-identical, so an application that does not opt in keeps every fingerprint it has already issued. Enabling a group does change the hash for everyone, so plan for a one-time re-baseline.
  • No group can hang or break collection. Each runs under signalTimeoutMs and its own error isolation; a throwing, rejecting or stalled collector degrades to supported: false while its siblings complete normally.
  • Nothing here requires a permission prompt, and none of it is collected during SSR.

Deliberately still not collected at any setting: media device enumeration, camera/microphone access, geolocation, battery status, WebRTC/IP probing, plugin or font names beyond the fixed probe list, and any form of cross-site storage or tracking.

9. Collection confidence

const confidence = await service.getConfidence();
// { score: 0.83, level: 'high', contributions: [{ name, weight, quality, state }, ...] }

score (0–1) estimates collection completeness — how much of the entropy the library is capable of collecting this browser actually provided — measured against everything the library could collect, not against what you enabled. So leaving a group disabled genuinely lowers the score; a default-configured application scores 0.3. DeviceInfo.confidence carries the same number to the backend, so a risk rule can weight a fingerprint match by how much went into it.

Each contribution reports a state:

| State | Meaning | | --- | --- | | collected | Enabled, available, yielding its full expected entropy. | | degraded | Enabled and available, but yielding less than it should — a masked WebGL renderer, client hints without high-entropy values, a nearly empty font list. Usually means a privacy protection is active. | | unavailable | Enabled, but this browser could not provide it at all. | | disabled | Not enabled by the application, so never attempted. |

The weights are ordinal judgements from the published fingerprinting literature, not measured bits of entropy — real entropy depends on the population being fingerprinted, which a client-side library cannot observe. A high score does not mean the fingerprint is trustworthy. It measures how much data was collected, not whether that data is genuine; a spoofed browser can score 1.0. See Security limitations.

10. Getting device information

const info: DeviceInfo = await service.getDeviceInfo();
// { deviceId, fingerprint, browser, operatingSystem, deviceType, screenResolution, timezone, language, confidence }

confidence is the collection-confidence score, carried here so it reaches the backend alongside the fingerprint it describes.

browser/operatingSystem/deviceType come from lightweight, regex-based User-Agent detection (lib/utils/device-detector.ts) — enough for presentation and recognition purposes, but not authoritative for security decisions (it's trivially spoofable, same as the fingerprint itself). deviceType falls back to 'unknown' rather than guessing when there's no signal to examine (e.g. SSR).

11. Exporting collected data

const data: DeviceFingerprintExport = await service.getExport();
const json: string = await service.exportAsJson();                  // pretty-printed
const compact: string = await service.exportAsJson({ pretty: false });

Returns everything the library collected, in one JSON-safe object:

{
  "formatVersion": 1,
  "exportedAt": "2026-07-30T02:14:09.412Z",
  "deviceId": "e7e0212d-cb49-4abb-a1cf-db5147d5968a",
  "fingerprint": "519dab261c787ddee5eb89b98b81553bf78f4384579f8f0de...",
  "confidence": { "score": 1, "level": "high", "contributions": [/* per signal group */] },
  "deviceInfo": { "browser": "Chrome", "operatingSystem": "macOS", /* ... */ },
  "components": { "userAgent": "...", "timezone": "Asia/Phnom_Penh", /* every baseline signal */ },
  "extendedSignals": { "canvas": { /* ... */ }, "audio": null, /* every opt-in group */ }
}

Two things it is good for:

  • Transparency — showing a user exactly what was collected about their device, and letting them take a copy. Since this library collects data a person may reasonably want to inspect, being able to hand them the whole record is part of collecting it responsibly.
  • Diagnostics — attaching a real device's signals to a bug report. The hash alone cannot tell you which signal moved when a fingerprint changes unexpectedly; two exports diff field by field.

Notes:

  • It reads the same cached snapshot as every other getter, so exporting costs no extra collection and always describes the fingerprint it ships with — never a fresher, subtly different one.
  • A signal group that was never enabled is exported as an explicit null, not omitted, so a reader can tell "not collected" from "not in this format version".
  • formatVersion is bumped only when a field is removed or changes meaning, never for an additive one. Compare against the exported EXPORT_FORMAT_VERSION constant when parsing a stored export.
  • Safe under SSR like everything else — you get the same empty-fallback values getFingerprint() would give you, not an exception.
  • The library stops at producing the data. Turning it into a file download is a UI concern, so it is left to the application — a DOM-touching helper would be dead weight for non-browser consumers. The demo app's exportJson() shows the whole pattern:
const blob = new Blob([await service.exportAsJson()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = 'device-fingerprint.json';
anchor.click();
URL.revokeObjectURL(url);

An export contains the full fingerprinting record, so treat a file produced this way with the same care as the data itself — it is not a secret, but it is not something to attach to a public issue tracker without a look either.

12. Resetting identity

service.resetDeviceIdentity();

Clears the persisted device identity and the in-memory fingerprint cache; the next getDeviceId()/getFingerprint() call generates fresh values. Typically wired to a user-facing "forget this device" or "sign out everywhere" action — building that UI is outside this module's scope.

13. Login integration

const device = await service.getLoginDeviceContext(); // { deviceId, fingerprint, deviceInfo }
// A real login flow composes this into its own request:
// http.post('/api/login', { username, password, device });

This project has no authentication feature to integrate into (a deliberate scope decision — see Phase 18 in the implementation plan), so getLoginDeviceContext() is the integration point a real login flow is expected to call. It never rejects — if fingerprint collection fails, the returned fingerprint is just '', so it never blocks a login attempt.

14. Backend integration

apiService.registerDevice({ deviceId, fingerprint, deviceInfo }).subscribe();

DeviceApiService.registerDevice() posts to ${apiUrl}/api/devices. Fingerprint data is only ever sent when this method (or getLoginDeviceContext(), above) is explicitly called — never automatically with every request.

Changed in 0.2.0. The path was previously /api/devices/register, a placeholder chosen before any backend existed. It is now POST /api/devices, matching the API contract in the device-security plan and the reference backend in backend/ — registration is a POST to the collection, not an RPC-style verb. registerDevice() also now returns the created device rather than void, typed as unknown so the library takes no compile-time dependency on one backend's response shape. If you have a backend on the old path, either move it or set a rewrite.

The endpoint requires an authenticated caller: the reference backend takes the user from the session and never from the request body, so a device cannot be registered onto someone else's account. See docs/architecture/backend-security-requirements.md for the full set of backend responsibilities, and backend/README.md for the reference implementation.

15. HTTP interceptor

deviceIdInterceptor attaches an X-Device-ID header (the device ID only, never the fingerprint) to application API requests — meaning same-origin requests, plus the origin of a configured apiUrl if you set one. It is automatically skipped for:

  • Third-party / cross-origin domains (every origin other than the page's own and the configured apiUrl)
  • File upload (FormData) requests
  • Requests made during SSR
  • Every request, if attachDeviceIdHeader: false is configured

It also degrades gracefully (forwards the request unheadered) if the device ID is unavailable for any reason, rather than breaking the request — see Phase 21 in the implementation plan.

16. SSR support

Every public method is safe to call during server-side rendering — none of them throw. Under SSR: getFingerprint()/refreshFingerprint() resolve to ''; getDeviceInfo() resolves with safe fallback values ('Unknown' browser/OS, 'unknown' device type, '0x0' resolution); getDeviceId() still returns a usable id (generated via a non-crypto fallback if Web Crypto is unavailable server-side, since a memory-only id is expected to change on reload anyway); resetDeviceIdentity() is a safe no-op-equivalent. This is achieved via BrowserEnvironment (lib/utils/browser-environment.ts), which centralizes window/document/navigator/screen/crypto/localStorage/sessionStorage access behind isPlatformBrowser() checks. Every file in the module goes through it with one deliberate exception: lib/utils/fingerprint-hasher.ts reads the global crypto directly, because it is a plain framework-agnostic function with no Angular DI. It is safe regardless — it does its own typeof crypto/typeof crypto.subtle guard, it is async (so an unavailable API surfaces as a rejected promise, never a synchronous throw), and DeviceFingerprintService refuses to call it at all outside a real browser.

17. Browser compatibility

  • Required for a real fingerprint hash: the Web Crypto API (crypto.subtle.digest). Without it, getFingerprint() resolves to '' gracefully rather than throwing.
  • Required for a cryptographically strong device ID: crypto.randomUUID(), falling back to crypto.getRandomValues(), falling back to a Math.random()-based generator as a last resort if neither is available.
  • Every individual signal in BrowserSignalCollector (deviceMemory, hardwareConcurrency, etc.) has an independent try/catch and a safe fallback — one missing/throwing browser API never breaks collection of the rest.

18. Storage behavior

Device identity persistence tries, in order: localStoragesessionStorage → in-memory only. The tier is chosen by an actual write-probe (not just a presence check), so storage that exists but rejects writes (private browsing, quota exceeded, disabled storage) correctly falls through to the next tier. A memory-only identity is expected to change on page reload — this is documented, expected behavior, not a bug. See DeviceStorageService and Phase 5 in the implementation plan.

19. Security limitations

This module's frontend code:

  • Never treats the device ID or fingerprint as an authentication credential.
  • Never makes a trust, risk, or authorization decision itself — see the absence of any such logic verified in Phase 22 (Security Review).
  • Cannot prevent a determined attacker from spoofing browser properties — the fingerprint is not attested data.
  • Cannot guarantee fingerprint stability — browser/OS updates, hardware changes, and private browsing all legitimately change it.
  • Stores nothing sensitive locally — only { deviceId, fingerprint (always empty in practice), createdAt, lastSeenAt }, never tokens, passwords, or secrets.

Full findings: Phase 22 in the implementation plan. Backend-side responsibilities that these limitations imply (never trust the fingerprint alone, risk scoring, revocation, etc.): docs/architecture/backend-security-requirements.md.

20. Privacy considerations

Signals collected, and why

| Signal | Why it's collected | | --- | --- | | userAgent | Browser/version identification — the primary device-recognition signal. | | platform | Operating system identification, supplements userAgent. | | language / languages | Locale-related recognition signal; also drives the presentation-facing DeviceInfo.language field. | | timezone | Device recognition signal; a change is treated as a risk signal for backend evaluation, not proof of a new device. | | timezoneOffset | Diagnostic only — excluded from the fingerprint hash because it changes at every DST transition (see Getting the fingerprint). | | screenWidth / screenHeight / colorDepth / pixelRatio | Display-characteristic recognition signals; also drive DeviceInfo.screenResolution. | | hardwareConcurrency | Approximate CPU core count — a stable recognition signal. | | deviceMemory | Approximate device RAM — a stable recognition signal (not available in every browser; falls back to null). | | maxTouchPoints | Recognition signal; also used to help distinguish tablets from desktops in device-type detection. | | cookieEnabled | Minor recognition/diagnostic signal. | | doNotTrack | Recorded as a signal, not currently acted on by this module — the module does not alter its own behavior based on it. | | online | Diagnostic only — excluded from the fingerprint hash because it flips with network connectivity (see Getting the fingerprint). | | Application device ID | A random UUID (crypto.randomUUID() where available), independent of every browser signal above — identifies an application installation/browser profile, not a physical device. |

Everything above is collected unconditionally, in every configuration.

Nothing else is, unless the application asks for it. Eight further signal groups exist — described in full, with their privacy impact, in Opt-in high-entropy signals — and every one of them is off by default. Three of them (canvas, audio, fonts) are the classic invasive fingerprinting techniques and should not be enabled without a specific, documented need; if you do enable them, disclose it in your own privacy notice, since this library cannot do that for you. The remaining five (clientHints, webgl, display, platform, math) read build and hardware traits of the same category the User-Agent string already carries.

Deliberately not collected at any setting: media device enumeration, camera/microphone access, precise geolocation, battery status, WebRTC/IP probing, plugin or font names beyond a fixed probe list, and any form of cross-site storage or tracking. No collected signal requires a permission prompt.

Where data is stored

  • The application device ID (and a DeviceIdentity record wrapping it — see below) is persisted client-side only, via localStorage, falling back to sessionStorage, falling back to an in-memory-only value if neither is available (e.g. private browsing with storage disabled). Default storage key: app_device_identity (configurable via provideDeviceFingerprint({ storageKey })).
  • The persisted DeviceIdentity record is { deviceId, fingerprint, createdAt, lastSeenAt } — in practice fingerprint is always an empty placeholder here and lastSeenAt is never updated after creation (confirmed in the Phase 22 security review); the live fingerprint hash exists only in an in-memory cache inside DeviceFingerprintService, for the current page/app instance's lifetime, and is never written to localStorage/sessionStorage.
  • Nothing collected by this module is sent anywhere outside the browser unless a caller explicitly transmits it (see "When the fingerprint is transmitted," below) — there is no background sync, beacon, or automatic upload of any kind.
  • Backend-side storage and retention, if a consuming application wires this up to a real backend, are outside this module's control — see docs/architecture/backend-security-requirements.md for what a backend integrating with this module is expected to do.

How long data is retained

  • Client-side: indefinitely, until whichever happens first — the user/browser clears site storage, the application calls resetDeviceIdentity(), or (only for the memory-only fallback tier) the page is reloaded or closed.
  • This module has no client-side expiry/TTL logic of its own; if an application needs one, it belongs in the backend record, not here.

When the fingerprint is generated

  • Lazily, on first demand — the first call to getFingerprint() (or getDeviceInfo(), getExtendedSignals(), getConfidence(), which need it too) triggers collection and hashing. It is never computed eagerly at application startup, and never during server-side rendering (collection returns a deterministic empty-signal fallback under SSR instead of touching any browser API).
  • All of those getters share a single cached snapshot, so one page load performs exactly one collection pass no matter how many of them are called — which matters once the opt-in collectors are enabled, since those rasterize, render audio and force reflows.

When the fingerprint is transmitted

Never automatically. It is only included in an outgoing request when application code explicitly does one of:

  • Calls DeviceApiService.registerDevice(request) — an explicit device-registration API call.
  • Calls DeviceFingerprintService.getLoginDeviceContext() and includes its result in a login request — the integration point a real login flow is expected to use.

The optional X-Device-ID HTTP interceptor attaches only the device ID — never the fingerprint — and only to same-origin application API requests. It is skipped for third-party/cross-origin domains, file upload (FormData) requests, and SSR requests, and can be disabled entirely via provideDeviceFingerprint({ attachDeviceIdHeader: false }).

How users see what was collected

Call DeviceFingerprintService.getExport() (or exportAsJson()) and show or download the result — it contains every signal collected, the confidence breakdown, and both identifiers. See Exporting collected data. This module provides the capability; surfacing it in a "your data" screen is the consuming application's decision, as is deciding whether it needs to.

How users reset their device identity

Call DeviceFingerprintService.resetDeviceIdentity(). This clears the persisted identity, clears the in-memory fingerprint cache, and causes a new device ID to be generated the next time one is requested. A consuming application would typically expose this behind a user-facing action such as "forget this device" or "sign out everywhere" — building that UI is outside this module's scope, which only provides the underlying capability.

Limitations of fingerprinting

  • Not a guaranteed unique or permanent hardware identifier.
  • Can be altered by browser updates, OS updates, display/hardware changes, or private browsing.
  • Can be spoofed by a sufficiently motivated attacker (browser properties are attacker-controlled data, not attested).
  • Cleared automatically whenever the user clears browser storage.
  • A VPN does not affect this module's assumptions, because it collects no IP address or geolocation data at all.
  • Must always be treated as a supporting risk signal, never as a credential, and never trusted in isolation — see the Phase 22 security review for the full list of security properties this module does and does not provide.

No hidden cross-site tracking

  • Every signal collected is used only within the application that includes this module. The device ID and fingerprint are never sent to third-party domains — the X-Device-ID interceptor explicitly excludes cross-origin requests, and nothing else in this module makes outbound requests of any kind on its own. There is no shared/global identifier design here; a deviceId generated for one application has no relationship to any other application or origin.

No unnecessary permissions

  • Every signal is read from browser APIs that require no permission prompt: navigator, screen, Intl, Date, and — for the opt-in groups only — canvas, WebGL, OfflineAudioContext, matchMedia and text measurement. This module never requests geolocation, camera, microphone, or any other permission-gated capability. The opt-in audio collector renders into a buffer via OfflineAudioContext; nothing is played and no microphone is ever touched.

21. Testing

Every source file has a co-located *.spec.ts file (Jasmine/Karma, ng test). Notable techniques used throughout the suite:

  • Real-browser assertions where possible — tests compare results against the actual navigator/screen/Intl values in the Karma-launched Chrome instance, rather than only against mocks.
  • Real dependency-injection SSR tests{ provide: PLATFORM_ID, useValue: 'server' } through the real BrowserEnvironment, not a fake, so SSR safety is proven end-to-end rather than assumed.
  • A fake BrowserEnvironment/Storage (in-memory, fully controllable) for deterministic fallback and error-path testing (corrupted JSON, quota-exceeded, private browsing) without depending on real browser storage quirks.
  • HttpTestingController for DeviceApiService and deviceIdInterceptor.
  • Manually-controlled deferred promises to prove true concurrent request deduplication (Phase 14), not just sequential-await timing.
  • NIST SHA-256 test vectors to verify hashFingerprint() against known-correct output, not just "looks like 64 hex characters."

Run the whole suite with:

npm test