Signal

Canvas fingerprint

Captures pixel-level differences in how a browser engine rasterises gradients, Unicode emoji text, arcs, bezier curves, and alpha-blended rectangles on a 256x256 canvas.

Tier 1 engine src/signals/canvas.ts

What it measures

The canvas signal draws a fixed scene onto a 256x256 surface and hashes the entire resulting PNG byte stream. Engine differences in anti-aliasing, sub-pixel rendering, font hinting, emoji rasterisation, gradient interpolation, and color-space math cause each browser engine to produce a distinct byte sequence even on the same GPU with the same fonts installed.

The collector prefers OffscreenCanvas (off-main-thread, no DOM required) and falls back to a hidden DOM canvas element when OffscreenCanvas is unavailable.

How it's collected

The collector first attempts OffscreenCanvas: it creates a 256x256 offscreen surface, calls drawScene on the 2D context, and awaits convertToBlob with a 2000 ms timeout to get the PNG. The raw PNG bytes are converted to a string via String.fromCharCode and hashed with xxHash64. If OffscreenCanvas is unavailable, the collector creates a hidden DOM canvas (positioned at -9999px with visibility hidden), draws the same scene, and uses toDataURL('image/png') to obtain a base64 data URL string, which is then hashed directly.

The value field exposed to callers contains only the first 100 characters of the image data for debug display; the full byte string is what is actually hashed. If both paths fail, the result is absent.

typescript
// Scene drawn by drawScene(ctx) — all steps in order
// 1. Linear gradient background
const grad = ctx.createLinearGradient(0, 0, 256, 256);
grad.addColorStop(0, '#ff6633');
grad.addColorStop(0.5, '#3366ff');
grad.addColorStop(1, '#33ff66');
ctx.fillRect(0, 0, 256, 256);

// 2. Text with emoji
ctx.fillStyle = '#ffffff';
ctx.font = '18px Arial';
ctx.textBaseline = 'top';
ctx.fillText('DeviceSignal,❤️🔍', 10, 10);

// 3. Circle arc
ctx.arc(128, 128, 50, 0, Math.PI * 2);
ctx.strokeStyle = '#000000';
ctx.lineWidth = 2;
ctx.stroke();

// 4. Bezier curve
ctx.moveTo(20, 200);
ctx.bezierCurveTo(60, 50, 180, 250, 236, 100);
ctx.strokeStyle = '#ff0000';
ctx.lineWidth = 3;
ctx.stroke();

// 5. Alpha rectangle
ctx.fillStyle = 'rgba(0, 0, 128, 0.5)';
ctx.fillRect(50, 150, 80, 40);

The deterministic drawScene steps. Drawing errors are non-fatal — partial output is still hashed.

Confidence rules

ConfidenceTrigger
normalEither OffscreenCanvas or DOM canvas produced image data
absentBoth paths returned null, or top-level catch fired

Why engine-bound

Canvas 2D rendering is implemented in each browser engine's graphics backend: Skia in Chrome/Edge, Cairo/Skia in Firefox, CoreGraphics in Safari. Differences in sub-pixel anti-aliasing, font rasterisation (especially emoji), gradient interpolation, and color-space handling produce pixel-distinct PNGs even on the same GPU with the same font files installed.

Because the variation is driven by the rendering engine rather than the underlying hardware, the hash is stable within a browser but differs across engines on the same machine. The signal contributes to fingerprint but not to hardwareFingerprint.

Things worth knowing

  • The OffscreenCanvas path has a single async await (convertToBlob with a 2000 ms timeout). The DOM canvas path is fully synchronous.
  • Typical collection time is 50-100 ms.
  • There is no degraded state; the signal is either normal or absent.
  • All drawScene drawing is wrapped in a try/catch so partial drawing is still hashed if one step fails.