How Do You Collect a Device Fingerprint With JavaScript?
You cannot read a true device fingerprint with JavaScript. What you can build is a browser-device identifier: collect a handful of signals, normalise them, hash them with SHA-256, and let your server add network context. This tutorial gives you seven working steps for device fingerprinting javascript, the signals still worth reading in 2026, and the normalisation rules that keep the hash stable across browser updates. Every snippet uses documented browser APIs and runs as written.

📌 TL;DR Executive Summary
- Core Takeaway: A JavaScript collector returns a browser-device hash, not a hardware serial. Collect stable signals, normalise them, hash with SHA-256, and version the output.
- Key Risk/Challenge: Canvas and audio output can be randomised, Client Hints are Chromium-only and permission-gated, and version strings drift after browser updates.
- Recommended Solution: Score similarity on the server instead of demanding an exact match, treat raw signals as personal data, and keep the collector small enough to audit.
What JavaScript Can and Cannot Fingerprint
JavaScript cannot read a serial number, a MAC address, or a permanent device ID. It can read what the browser exposes: screen geometry, locale, timezone, GPU strings, canvas pixels, audio processing and, on Chromium, Client Hints. Those values identify a browser-device pair, not the physical device. The same laptop in Chrome and Firefox returns two different hashes, which is the distinction covered in device fingerprint vs device ID.
Every device fingerprinting javascript collector follows three stages: collect signals from browser APIs, normalise the values, then hash them into a compact one-way identifier. Hardware-bound signals such as GPU family and screen geometry survive browser changes better than engine-bound signals such as canvas pixels and audio output. Weight them accordingly instead of treating every component as equally trustworthy.
A fingerprint is not stored on the device, so clearing cookies or opening a private window does not reset it the way a cookie-based ID resets. That durability is also why the output is evidence, not proof. EFF’s Cover Your Tracks tool reports a browser’s visibility as bits of identifying information rather than a pass or fail, and it warns that it does not measure every form of tracking or protection. Use that mental model in your own logs.
| Signal | Browser API | Stability | Use in the hash |
|---|---|---|---|
| Screen, locale, CPU | screen, navigator, Intl | High | Yes, normalised |
| Canvas | CanvasRenderingContext2D.toDataURL | Medium | Yes, weighted component |
| WebGL | WebGLRenderingContext.getParameter | Medium-high | Yes, vendor and limits |
| Audio | OfflineAudioContext | Medium | Yes, quantised slice |
| Client Hints | navigator.userAgentData | High on Chromium | Yes, selected fields only |
| User-Agent and plugins | navigator.userAgent, navigator.plugins | Very low | No |
Prerequisites Before You Write a Collector
- A page served over HTTPS or on localhost, because
crypto.subtleonly works in a secure context. - A server endpoint if you want to store, compare or expire hashes.
- A lawful basis and a short privacy notice. A hash is pseudonymous, not automatically anonymous.
- Chromium, Firefox and Safari for testing. Client Hints exist only in Chromium.
- A decision on retention, deletion, and whether you need raw signals or only the final hash.
- No third-party script is required. Everything below uses documented browser APIs.
How Send.win Helps With Device Fingerprinting Javascript
Send.win is a browser profile manager for professionals and teams: every account gets its own isolated browser profile.
- Isolated profiles – separate cookies, storage and sessions per profile
- Fingerprint protection – each profile keeps its own consistent browser fingerprint, so profiles never share one browser identity
- Desktop app + cloud browser – native app for Windows, macOS and Linux, or run profiles in the cloud with no install
- Residential proxies included – on Pro and Team, with timezone and locale matched to the proxy
- Team sharing – share a signed-in profile with a teammate without sharing the password
Try the free cloud demo — no install — or download the desktop app. Paid plans start at $19/month, and your first card purchase is covered by a 7-day money-back guarantee (see pricing).
Good device fingerprinting javascript starts with restraint: if you cannot explain why a signal is needed, leave it out. Data you never collect cannot leak, and a smaller collector is easier to audit and to defend in a privacy review.
Step 1: Build a Stable Hash Helper
Start with SHA-256 through the Web Crypto API, plus deterministic serialisation. Plain JSON.stringify preserves insertion order, so two objects holding the same values can serialise to different strings. Sorting keys removes that source of drift.
function stableStringify(value) {
if (value === undefined) return 'null';
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
return '{' + Object.keys(value).sort().map(function (key) {
return JSON.stringify(key) + ':' + stableStringify(value[key]);
}).join(',') + '}';
}
async function sha256(text) {
const bytes = new TextEncoder().encode(text);
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest)).map(function (byte) {
return byte.toString(16).padStart(2, '0');
}).join('');
}
The hash is one-way, but it is not anonymisation. If someone can guess the input space, they can test candidates against the digest. Keep the collector version beside the hash, and never treat the digest as a password or a legal identity document.
Step 2: Collect Low-Entropy Browser and Hardware Signals
Begin with values that are cheap, stable and easy to explain. Return zeros or empty strings for missing APIs instead of letting undefined into the hash.
function getBasics() {
const n = navigator;
return {
language: n.language || '',
languages: (n.languages || []).join(','),
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
timeZoneOffset: new Date().getTimezoneOffset(),
cores: n.hardwareConcurrency || 0,
memory: n.deviceMemory || 0,
touchPoints: n.maxTouchPoints || 0,
screen: screen.width + 'x' + screen.height + 'x' + screen.colorDepth,
pixelRatio: window.devicePixelRatio || 1
};
}
Do not hash the full User-Agent string or the plugin list. Chrome froze the User-Agent string in version 107, and navigator.plugins now returns a fixed generic array, so both carry almost no entropy and plenty of version drift. deviceMemory is not exposed in every browser; a zero is a valid normalised value.
Step 3: Add a Canvas Signal
A canvas probe draws text and shapes onto a hidden canvas, then hashes the pixel output. Font rasterisation, anti-aliasing and colour handling vary by GPU, driver and browser engine.
async function getCanvasSignal() {
const canvas = document.createElement('canvas');
canvas.width = 240;
canvas.height = 60;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.textBaseline = 'top';
ctx.font = '16px Arial';
ctx.fillStyle = '#f60';
ctx.fillRect(0, 0, 120, 20);
ctx.fillStyle = '#069';
ctx.fillText('Fingerprint 2026', 2, 24);
ctx.fillStyle = 'rgba(102,204,0,0.7)';
ctx.fillText('canvas', 4, 40);
return sha256(canvas.toDataURL());
}
Canvas is a medium-entropy, medium-durability signal: one 2026 technique audit rates it as still working on roughly 80% of browsers while noting continued degradation. In practice it stays stable in Chrome and Safari, gets noised per page load in Firefox with resistFingerprinting, and is randomised by default in Brave. Draw only local text and shapes, because a cross-origin image taints the canvas and makes toDataURL() throw. For the full signal-by-signal treatment, see the canvas fingerprinting guide.
Step 4: Add WebGL Parameters
WebGL exposes GPU family strings and hardware limits. These are hardware-bound signals, so they survive engine changes better than canvas pixels, though driver updates can change the renderer string.
function getWebGLSignal() {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl');
if (!gl) return null;
const ext = gl.getExtension('WEBGL_debug_renderer_info');
return {
vendor: ext ? String(gl.getParameter(ext.UNMASKED_VENDOR_WEBGL)) : String(gl.getParameter(gl.VENDOR)),
renderer: ext ? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)) : String(gl.getParameter(gl.RENDERER)),
maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE),
maxViewport: Array.from(gl.getParameter(gl.MAX_VIEWPORT_DIMS) || []).join('x'),
glsl: String(gl.getParameter(gl.SHADING_LANGUAGE_VERSION))
};
}
Keep the parameter set small. Requesting dozens of limits adds noise and makes browser upgrades break your hash. If WEBGL_debug_renderer_info is unavailable, fall back to VENDOR and RENDERER rather than failing the whole collection.
Step 5: Add an Audio Signal
Web Audio processing differs across audio stacks, sample-rate handling and browser engines. A one-second offline render is enough to produce a useful component without blocking the page.
async function getAudioSignal() {
if (!window.OfflineAudioContext) return null;
const ctx = new OfflineAudioContext(1, 44100, 44100);
const osc = ctx.createOscillator();
osc.type = 'triangle';
osc.frequency.value = 10000;
const comp = ctx.createDynamicsCompressor();
comp.threshold.value = -50;
comp.knee.value = 40;
comp.ratio.value = 12;
comp.attack.value = 0;
comp.release.value = 0.25;
osc.connect(comp);
comp.connect(ctx.destination);
osc.start(0);
const buffer = await ctx.startRendering();
const data = buffer.getChannelData(0);
return sha256(Array.from(data.slice(4500, 5000)).map(function (value) {
return value.toFixed(6);
}).join(','));
}
Audio is engine-bound, so the same laptop can produce different output in different browsers. Hash a quantised slice, not the entire buffer. If the API is missing or startRendering() rejects, return null and continue with the other components.
Step 6: Read Client Hints Correctly
Chromium exposes navigator.userAgentData as the modern replacement for parsing the User-Agent string. The low-entropy fields are available directly; high-entropy fields need a Promise and may be limited by the ch-ua-high-entropy-values Permissions-Policy.
async function getClientHints() {
const ua = navigator.userAgentData;
if (!ua) return null;
const low = {
brands: Array.from(ua.brands || []).map(function (brand) {
return brand.brand + ':' + brand.version;
}).sort().join('|'),
platform: ua.platform || '',
mobile: Boolean(ua.mobile)
};
if (typeof ua.getHighEntropyValues !== 'function') return low;
try {
const high = await ua.getHighEntropyValues([
'architecture', 'bitness', 'model',
'platformVersion', 'fullVersionList', 'wow64'
]);
return Object.assign(low, {
architecture: high.architecture || '',
bitness: high.bitness || '',
platformMajor: String(high.platformVersion || '').split('.')[0],
model: high.model || ''
});
} catch (error) {
return low;
}
}
The accepted hints are architecture, bitness, formFactors, fullVersionList, model, platformVersion and wow64; uaFullVersion is deprecated in favour of fullVersionList. Requesting a hint the browser refuses can reject with a NotAllowedError DOMException, so catch it and continue with brands, mobile and platform. The interface is still experimental, so read MDN’s getHighEntropyValues reference before you rely on a field.
Do not hash the whole fullVersionList array. It can contain a deliberately invalid brand and version entry, so the array can change without a real browser change. Safari and Firefox do not implement userAgentData at all; return null there and use the legacy User-Agent string only as a coarse fallback, never as an identity signal.
Step 7: Wire the Collector Together
Combine the components into one object, sort the serialisation, then hash. Send the hash and the collector version; keep the raw signal object on the client unless your server genuinely needs it.
async function collectFingerprint() {
const parts = await Promise.all([
getCanvasSignal(),
getAudioSignal(),
getClientHints()
]);
const signals = {
version: 1,
basics: getBasics(),
webgl: getWebGLSignal(),
canvas: parts[0],
audio: parts[1],
hints: parts[2]
};
const hash = await sha256(stableStringify(normalise(signals)));
await fetch('/api/fingerprint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash: hash, version: 1 })
});
return hash;
}
Normalise before you hash
The normaliser is what keeps a hash stable between releases, so treat it as part of the algorithm rather than a formatting detail.
- Drop a component entirely when its API is missing, instead of writing
nullinto the payload. - Lowercase locale and platform strings, but leave WebGL renderer strings untouched, because their casing is part of the value.
- Cut version strings to the major number:
platformVersion“15.0.0” becomes “15”. - Sort every array before joining, so brand order cannot create a new hash.
- Bump the schema version whenever you add, rename or drop a field, and store that version beside every digest.
What the server must add
The server sees request headers, IP address and network context that JavaScript cannot read. That includes the Sec-CH-UA headers, which appear only when your response asks for them with an Accept-CH header, and the TLS ClientHello itself, which is hashed by schemes such as JA4 (version, cipher list, extensions, ALPN order). A JS platform value that disagrees with the header or the TLS stack is one of the more useful mismatch signals you can log.
Use that context as additional evidence, not as a replacement for consent. Store component hashes separately, then score similarity instead of demanding an exact match: two visits from the same browser after a Chrome update should look close, not identical. Exact matching is brittle on homogeneous hardware such as stock phones, where a model, carrier and OS version can be shared by thousands of people. Weight the components, set thresholds, and keep a human-review path for account recovery instead of locking someone out because a canvas hash changed.
Test the Collector Across Browsers
Run the same device fingerprinting javascript in Chromium, Firefox, Brave and Safari before you ship it. Each engine returns a different component set, and two of them change values between page loads by design. Log the component hashes, not only the final digest, so you can see which one moved.
| Environment | Canvas behaviour | navigator.userAgentData | Effect on the hash |
|---|---|---|---|
| Chromium desktop | Stable per version | Available | Full component set; drift follows browser and driver updates |
| Firefox | Stable | Not implemented | No hints component; canvas and WebGL carry the weight |
| Firefox with resistFingerprinting | Noised per page load | Not implemented | Canvas and audio change every load; score, do not block |
| Brave | Randomised by default | Chromium engine | Treat the canvas component as low weight |
| Safari | Stable | Not implemented | Canvas and WebGL dominate; no high-entropy hints |
Write one unit test before you touch the browser: build two objects with the same keys in different insertion order, run stableStringify on both, and assert the strings match. That single test catches the most common cause of a “new device” appearing out of nowhere. Then snapshot each component hash for one browser and re-run it after a release; anything that moves without a real change is a normalisation bug, not a new user.
For an outside view of how visible a browser is, EFF’s Cover Your Tracks summarises a browser’s characteristics in bits of identifying information across many metrics and warns that it does not cover every tracking method or protection. Treat it as context for a conversation, not as a compliance certificate.
Common Errors and How to Fix Them
- The hash changes on every page load. Canvas or audio randomisation is the usual cause. Keep the components, but treat a change as a confidence penalty rather than a new identity.
crypto.subtleis undefined. The page is not in a secure context. Serve it over HTTPS or test on localhost.- Client Hints reject with
NotAllowedError. The high-entropy policy blocked a hint. Catch it and fall back to brands, mobile and platform. - The hash drifts after a browser update. You are hashing full version strings or the decoy
fullVersionList. Use major versions, drop the full User-Agent, and version the schema. toDataURL()throws aSecurityError. The canvas is tainted by a cross-origin image. Draw only local text and shapes.- Audio returns null.
OfflineAudioContextis unavailable or rendering failed. Continue with the remaining signals instead of aborting collection.
If you would rather not maintain this collector, FingerprintJS is MIT-licensed and returns a client-side visitorId; ThumbmarkJS is MIT-licensed and returns result.thumbmark without an API key, while its visitor ID and extra fields require the API. Compare the trade-offs in fingerprinting libraries compared, and apply the same consent and retention rules either way.
The Complete Collector File
The file below assembles the seven steps. Replace the /api/fingerprint endpoint with your own, keep the collector version in the stored record, and add the normaliser before you hash.
function stableStringify(value) {
if (value === undefined) return 'null';
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
return '{' + Object.keys(value).sort().map(function (key) {
return JSON.stringify(key) + ':' + stableStringify(value[key]);
}).join(',') + '}';
}
async function sha256(text) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return Array.from(new Uint8Array(digest)).map(function (byte) {
return byte.toString(16).padStart(2, '0');
}).join('');
}
function normalise(signals) {
const out = {};
Object.keys(signals).forEach(function (key) {
const value = signals[key];
if (value === null || value === undefined) return;
if (typeof value === 'string') { out[key] = value.trim().toLowerCase(); return; }
if (typeof value === 'number') { out[key] = Number.isFinite(value) ? value : 0; return; }
if (Array.isArray(value)) { out[key] = value.map(String).sort().join('|'); return; }
out[key] = normalise(value);
});
return out;
}
function getBasics() {
const n = navigator;
return {
language: n.language || '',
languages: (n.languages || []).join(','),
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
timeZoneOffset: new Date().getTimezoneOffset(),
cores: n.hardwareConcurrency || 0,
memory: n.deviceMemory || 0,
touchPoints: n.maxTouchPoints || 0,
screen: screen.width + 'x' + screen.height + 'x' + screen.colorDepth,
pixelRatio: window.devicePixelRatio || 1
};
}
async function getCanvasSignal() {
const canvas = document.createElement('canvas');
canvas.width = 240;
canvas.height = 60;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.textBaseline = 'top';
ctx.font = '16px Arial';
ctx.fillStyle = '#f60';
ctx.fillRect(0, 0, 120, 20);
ctx.fillStyle = '#069';
ctx.fillText('Fingerprint 2026', 2, 24);
ctx.fillStyle = 'rgba(102,204,0,0.7)';
ctx.fillText('canvas', 4, 40);
return sha256(canvas.toDataURL());
}
function getWebGLSignal() {
const gl = document.createElement('canvas').getContext('webgl');
if (!gl) return null;
const ext = gl.getExtension('WEBGL_debug_renderer_info');
return {
vendor: ext ? String(gl.getParameter(ext.UNMASKED_VENDOR_WEBGL)) : String(gl.getParameter(gl.VENDOR)),
renderer: ext ? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)) : String(gl.getParameter(gl.RENDERER)),
maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE),
maxViewport: Array.from(gl.getParameter(gl.MAX_VIEWPORT_DIMS) || []).join('x'),
glsl: String(gl.getParameter(gl.SHADING_LANGUAGE_VERSION))
};
}
async function getAudioSignal() {
if (!window.OfflineAudioContext) return null;
const ctx = new OfflineAudioContext(1, 44100, 44100);
const osc = ctx.createOscillator();
osc.type = 'triangle';
osc.frequency.value = 10000;
const comp = ctx.createDynamicsCompressor();
comp.threshold.value = -50;
comp.knee.value = 40;
comp.ratio.value = 12;
comp.attack.value = 0;
comp.release.value = 0.25;
osc.connect(comp);
comp.connect(ctx.destination);
osc.start(0);
const data = (await ctx.startRendering()).getChannelData(0);
return sha256(Array.from(data.slice(4500, 5000)).map(function (value) {
return value.toFixed(6);
}).join(','));
}
async function getClientHints() {
const ua = navigator.userAgentData;
if (!ua) return null;
const low = {
brands: Array.from(ua.brands || []).map(function (brand) {
return brand.brand + ':' + brand.version;
}).sort().join('|'),
platform: ua.platform || '',
mobile: Boolean(ua.mobile)
};
if (typeof ua.getHighEntropyValues !== 'function') return low;
try {
const high = await ua.getHighEntropyValues([
'architecture', 'bitness', 'model',
'platformVersion', 'fullVersionList', 'wow64'
]);
return Object.assign(low, {
architecture: high.architecture || '',
bitness: high.bitness || '',
platformMajor: String(high.platformVersion || '').split('.')[0],
model: high.model || ''
});
} catch (error) {
return low;
}
}
async function collectFingerprint() {
const parts = await Promise.all([
getCanvasSignal(),
getAudioSignal(),
getClientHints()
]);
const signals = {
version: 1,
basics: getBasics(),
webgl: getWebGLSignal(),
canvas: parts[0],
audio: parts[1],
hints: parts[2]
};
const hash = await sha256(stableStringify(normalise(signals)));
await fetch('/api/fingerprint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash: hash, version: 1 })
});
return hash;
}
collectFingerprint().then(function (hash) {
console.log(hash);
}).catch(function (error) {
console.error(error);
});
Consent, Retention, and the Limits You Should Document
A browser-device hash is pseudonymous data, not anonymous data. If you operate in the EU or UK, you need a lawful basis for collecting it, and consent may be the practical route for analytics or risk scoring. Read device fingerprinting legality before you ship the collector, and write the notice in plain language that names the signals you read.
Collect only the components you actually use. Set a retention period, avoid exporting raw canvas or audio values to third parties, and provide a deletion route where the law requires one. Log the collector version beside each hash so you can explain why an older digest differs from a newer one without guessing.
Where Browser Profile Isolation Fits
If your team manages client accounts you are authorised to use, the operational problem is often session separation rather than one more hash. Cookies, local storage, logged-in sessions and proxy settings have to stay inside the right account boundary, or your test data describes the wrong environment.
Send.win is a browser profile manager built for that work. Each profile keeps its own cookies, storage, sessions, fingerprint settings and proxy, in the Windows, macOS and Linux desktop app or in the cloud browser that runs on Send.win’s EU server. For manual QA you can open the same test page in two isolated profiles and compare what your device fingerprinting javascript returns, without one profile inheriting the other’s session. Timezone and locale follow the proxy exit IP, which matters when you verify region-dependent output. Paid teammates can share a profile without exchanging passwords. Every step here is hands-on: you open a profile, load the test page yourself and read the result in the browser window — there is nothing to wire up or script on Send.win’s side. It is a tool for testing, support and account separation. Follow each platform’s rules, and where a platform limits multiple accounts, use its official approval route.
🏆 Send.win Verdict
For this topic, Send.win matters at the isolation layer, not inside the collector. When you need to confirm how a page behaves across separate, consistent browser environments for client accounts you manage, Send.win keeps cookies, storage and fingerprint settings per profile, with timezone and locale following the proxy exit IP. It is a manual tool, and it does not replace consent, server-side scoring or platform rules.
See Send.win plans — paid plans start at $19/mo with a 7-day money-back guarantee on the first card purchase, and the free cloud demo needs no install.
Frequently Asked Questions
How do I get a device fingerprint in JavaScript?
Collect browser signals, normalise them, hash the result with SHA-256, and send the hash to your server. You cannot get a true device fingerprint from JavaScript; you get a browser-device identifier. Add server-side context only with a lawful basis and a clear privacy notice.
What signals should a JavaScript collector read?
Start with screen geometry, locale, timezone, hardwareConcurrency and deviceMemory. Add canvas, WebGL and audio as weighted components, and use Client Hints on Chromium when policy allows. Skip the User-Agent string and plugins; both carry almost no entropy now.
Can JavaScript fingerprint the device rather than the browser?
No. JavaScript sees the browser’s exposed surface, so the same hardware in two browsers produces two hashes. That is why identity decisions should score similarity rather than demand an exact match. A server can add network context, but it still cannot read a hardware serial.
How do I hash fingerprint signals into one identifier?
Sort object keys, serialise to a deterministic string, encode it with TextEncoder, and digest it with crypto.subtle.digest('SHA-256', bytes). Return hex and store a collector version beside the hash. Do not use the hash as proof of identity.
Why does my canvas fingerprint change between visits?
Privacy protections can noise or randomise canvas output. Firefox with resistFingerprinting changes it per page load, and Brave randomises it by default. Treat a changed canvas hash as lower confidence, not as a new user.
Does incognito mode change a browser fingerprint?
Not fundamentally. Incognito clears cookies and storage, but the fingerprint is computed from browser and hardware signals, so it can stay similar. Canvas randomisation and other privacy settings can change individual components.
Which getHighEntropyValues hints can I request?
Request architecture, bitness, model, platformVersion, fullVersionList and wow64, plus formFactors where you need it. uaFullVersion is deprecated in favour of fullVersionList. If the ch-ua-high-entropy-values policy blocks the request, expect only brands, mobile and platform.
Which fingerprinting library should I use in 2026?
FingerprintJS is MIT-licensed and returns a client-side visitorId; ThumbmarkJS is MIT-licensed and returns result.thumbmark without a key, with its visitor ID and extra fields behind the API. Use a library if you need broad signal coverage, and keep the same consent and retention rules.