Overview
Signals derived from web platform features, CSS capabilities, fonts, and other browser characteristics.
1. Font Fingerprinting
Mechanism
Fonts are enumerated via several techniques:
-
CSS
@font-facemeasurement: Create text, measure its width with a known font, then try loading a system font. If width changes, the font exists. -
Canvas
measureText: Similar approach using canvas. -
document.fonts.check(): Modern API for font availability checking.
Flash-based enumeration (legacy, still used): Flash can list all installed fonts.
Code Example
// Measurement-based font detection
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const text = 'mmmmmmmmmmlli'
function detectFont(font) {
ctx.font = `72px ${font}, monospace`
return ctx.measureText(text).width
}
// Compare width with monospace baseline to detect
Entropy
Font list varies widely:
| Platform | Typical Fonts | Uniqueness |
|---|---|---|
| Windows 11 stock | ~80 fonts | Medium |
| macOS Ventura+ | ~120 fonts | Medium |
| Linux (distro varies) | 10-500 fonts | Very High |
| Custom fonts installed | Any | Very High |
Mitigation at Proxy
// Override font measurement to return consistent values
const originalMeasureText = CanvasRenderingContext2D.prototype.measureText
CanvasRenderingContext2D.prototype.measureText = function(text) {
const result = originalMeasureText.call(this, text)
// Round width to reduce font detection precision
result.width = Math.round(result.width / 10) * 10
return result
}
Limitation: Font detection is only reduced, not eliminated.
2. Browser Feature Support (25+ binary vectors)
Mechanism
Each browser supports a different subset of web APIs and CSS features. Collectively, this creates a browser+version fingerprint.
High-Entropy Features
| Feature | Chrome | Firefox | Safari |
|---|---|---|---|
| WebGL | Yes | Yes | Yes |
| WebGPU | Yes (flag) | No | No |
| WebUSB | Yes | No | No |
| Web Bluetooth | Yes | No | No |
| Web NFC | Android | No | No |
| Web Authentication | Yes | Yes | Yes |
| Web Assembly | Yes | Yes | Yes |
| Service Workers | Yes | Yes | Yes |
| Battery API | No | Yes | No |
| Speech Synthesis | Yes | Yes | Yes |
| GamePad API | Yes | Yes | Yes (partial) |
| WebRTC | Yes | Yes | Yes |
| CSS Grid | Yes | Yes | Yes |
| CSS backdrop-filter | Yes | Yes | Yes |
| CSS Container Queries | Yes | Yes | Yes |
Entropy
Each feature adds ~1 bit. With ~100 features tested, theoretical max is 100 bits, but features are highly correlated (minimal entropy beyond browser family and version).
Effective entropy: ~8-12 bits (~256-4096 combinations).
Mitigation
Cannot be done at proxy level these are inherent browser capabilities. Feature detection runs in JS before any injected script can run (and even if overridden, the browser still reports actual capabilities somewhere).
3. CSS Media Query Fingerprinting
Mechanism
CSS @media queries can brute-force screen dimensions, DPI, color depth, and other properties without JS:
@media (width: 1920px) { body::after { content: '1920' } }
@media (height: 1080px) { body::after { content: '1080' } }
@media (resolution: 1.5dppx) { body::after { content: '1.5' } }
@media (prefers-color-scheme: dark) { ... }
@media (prefers-reduced-motion: no-preference) { ... }
@media (hover: hover) { ... }
@media (pointer: fine) { ... }
Challenge for Proxy
CSS executes before any JS injection. The proxy can:
- Parse CSS in the response HTML and modify media queries
- Or rely on the fact that CSS-only fingerprint probes need to exfiltrate data (which needs JS or a network request)
Real mitigation: Block the exfiltration path (JS or image loading) rather than spoofing CSS values.
Media Query List
| Query | Values | Leaked |
|---|---|---|
| width | px | Screen width |
| height | px | Screen height |
| aspect-ratio | ratio | Screen ratio |
| resolution | dppx | Pixel density |
| color | bits | Color depth |
| color-gamut | srgb/p3/rec2020 | Color space |
| prefers-color-scheme | light/dark | OS theme |
| prefers-reduced-motion | no-preference/reduce | OS setting |
| prefers-contrast | no-preference/more/less | OS setting |
| prefers-reduced-transparency | no-preference/reduce | OS setting |
| hover | none/hover | Input device |
| pointer | none/coarse/fine | Input device |
| any-hover | none/hover | Any input |
| any-pointer | none/coarse/fine | Any input |
| display-mode | fullscreen/standalone | PWA mode |
| prefers-color-gamut | srgb/p3 | Display gamut |
| dynamic-range | standard/high | HDR support |
| inverted-colors | none/inverted | OS accessibility |
| monochrome | bits | Monochrome display |
| forced-colors | none/active | Accessibility |
| prefers-reduced-data | no-preference/reduce | Data saver |
4. ClientRects Fingerprinting
Mechanism
Element.getClientRects() returns slightly different values depending on OS, browser, and font rendering:
const span = document.createElement('span')
span.textContent = 'mmmmmmmmmmlli'
document.body.appendChild(span)
const rects = span.getClientRects()
// rects[0].width varies by OS/browser/font
Mitigation
Element.prototype.getClientRects = function() {
const rects = Element.prototype.__proto__.getClientRects.call(this)
return Array.from(rects).map(r => ({
...r,
width: Math.round(r.width / 10) * 10,
height: Math.round(r.height / 10) * 10
}))
}
5. Web Audio API Fingerprint
See audio-fingerprinting.md for details.
6. WebGPU Fingerprinting
WebGPU provides structured device info not previously available:
const adapter = await navigator.gpu.requestAdapter()
const info = await adapter.requestAdapterInfo()
// { vendor: "nvidia", architecture: "ampere", device: "..." }
// Adapter limits expose specific GPU capabilities
const limits = adapter.limits
// maxTextureDimension2D, maxStorageBufferBindingSize, etc.
// Feature set
adapter.features.has('texture-compression-bc')
adapter.features.has('shader-f16')
Exposed Data
| Field | Example | Identifiability |
|---|---|---|
| vendor | "nvidia", "apple", "amd", "intel" | Low (4-5 values) |
| architecture | "ampere", "rdna2", "tigerlake" | Medium |
| device | "integrated-graphics", "Apple M3" | Medium |
| driver | "460.32.03" | High |
| backend | "Metal", "Vulkan", "D3D12" | Low |
| type | "discrete-gpu", "integrated-gpu", "cpu" | Low |
| memoryHeaps | size and count | High |
| limits | 50+ capability values | Very High |
Mitigation
// Block WebGPU adapter info
navigator.gpu.requestAdapter = async function() {
return null // or return a fake minimal adapter
}
Cost: Blocks WebGPU entirely, which breaks some apps.
7. Service Worker Persistence
Mechanism
Service Workers can be used to identify users across sessions:
// Detect if a SW is installed
navigator.serviceWorker.getRegistrations().then(regs => {
if (regs.length > 0) console.log('SW found')
})
Mitigation
navigator.serviceWorker.getRegistrations =
async () => []
8. WebGL Extensions Fingerprint
WebGL exposes a list of supported extensions that varies by GPU, driver, and browser:
| Extension Type | Count | Example |
|---|---|---|
| Standard WebGL | ~30 | OES_texture_float |
| WEBGL_* | ~15 | WEBGL_debug_renderer_info |
| EXT_* | ~15 | EXT_texture_filter_anisotropic |
| Browser-specific | ~5 | MOZ_WEBGL_* |
Entropy: The exact set and count of extensions creates a hash.
Mitigation:
const originalGetExtension = WebGLRenderingContext.prototype.getExtension
WebGLRenderingContext.prototype.getExtension = function(name) {
if (name === 'WEBGL_debug_renderer_info') return null
if (name.startsWith('MOZ_')) return null
return originalGetExtension.call(this, name)
}
Conclusions for Obscura
| Vector | Proxy Control | Mitigation Strategy |
|---|---|---|
| Font enumeration | Partial | Round measurements, block Flash |
| Feature support | None | Accept inherent to browser |
| CSS media queries | Partial | Block exfiltration (DNS/network) |
| ClientRects | Partial | Round values via JS injection |
| WebGPU | Partial | Block adapter info |
| Service Workers | Partial | Override registration APIs |
| WebGL extensions | Partial | Filter extension list |