What Your Machine Can Decode Is Part of Your Identity

Beyond canvas pixels and GPU strings, a browser answers a much cheaper question: what can it actually play? Codec support, camera counts, core counts and installed voices are all readable in under a millisecond, none of them require permission, and all of them are stable for the lifetime of the machine. A WAF can run the whole battery on a single page load and come away with a coherent hardware description.

The trap is that these probes look optional. Nothing on a normal page needs getVoices() or a WebRTC codec table, so it is tempting to hide them. Hiding them is worse than answering them badly, because a browser with no capabilities at all is a build nobody ships.

canPlayType Returns Three Words, and Every One of Them Means Something

HTMLMediaElement.canPlayType(type) takes a MIME type with an optional codec parameter and returns exactly one of three strings. The spec is precise about the vocabulary and vague about the semantics, which is why the answer is still useful.

Return value Meaning Collector reads it as
"probably" the browser believes it can play this without failure full support
"maybe" it can decode, but the container or profile is unverified partial support
"" (empty string) the MIME type or codec is not recognised no support at all

The container and the codec are separate axes. video/mp4 with no codec parameter tests only the demuxer; video/mp4; codecs="avc1.42E01E" names H.264 Baseline Level 3.1. A build that ships an MP4 demuxer without a proprietary decoder answers "" to the second and "maybe" to the first, and asking both has separated "no MP4" from "MP4 but no H.264", which is a different machine entirely.

The Set Names a Build, Not a Device

A single answer separates perhaps 4 percent of the population. The set of answers, together with the support level of each, narrows to a build. Chromium and Chromium-based browsers ship genuinely different codec matrices depending on whether the build was compiled with proprietary codecs, and that is a licensing decision made by whoever packaged the browser, not by the user.

Environment H.264 HEVC AV1 MP3 FLAC Opus
Chrome on Windows, all channels probably probably probably probably probably probably
Chrome on Linux, Google's own builds probably probably probably probably probably probably
Chromium on Linux, proprietary_codecs = false "" "" probably probably probably probably
Debian chromium package, 2023 and later "" "" probably probably probably probably
Firefox on Windows probably maybe probably probably probably probably
Safari 18 on macOS and on iOS probably probably probably probably probably probably
A distro chromium-headless-shell, or a bare Playwright download "" "" probably probably probably probably

Read down the first column and you have a fingerprint needing no permissions, no canvas and no interaction. A profile that claims Chrome/140 on Windows while answering "" for avc1.42E01E is describing a Chromium build wearing a Chrome user agent, and that pairing is rare enough to score highly on its own.

MediaSource, MediaRecorder and the WebRTC Codec Tables

The same question is asked through three other doors, and each fails differently on a stripped build. MediaSource.isTypeSupported(mimeType) and MediaRecorder.isTypeSupported(mimeType) are static, permission-free, and cover the streaming pipeline rather than the element pipeline; a headless build with no proprietary codecs typically answers false for video/mp4; codecs="avc1.42E01E" and true for video/webm; codecs="vp8,opus", and MediaRecorder.isTypeSupported('video/x-matroska;codecs=avc1') is a separate axis again because the container list differs from the demuxer list.

WebRTC publishes its own tables. RTCRtpSender.getCapabilities('video') returns every codec the build can send, each with mimeType, clockRate, channels, sdpFmtpLine and, in Chromium, a numeric payloadType; RTCRtpReceiver.getCapabilities(kind) does the same for decoding. On a live sender, getParameters().headerExtensions is where the SDP extension list shows up: abs-send-time, transport-wide-cc, sdes:mid, sdes:rtp-stream-id, sdes:repaired-rtp-stream-id, video-orientation, toffset, playout-delay. An empty headerExtensions array means there is no real WebRTC stack behind it.

const v = document.createElement('video');
for (const t of ['video/mp4; codecs="avc1.42E01E"', 'video/webm; codecs="vp9"', 'audio/mpeg'])
  console.log(t, '->', JSON.stringify(v.canPlayType(t)));
console.log('send codecs:', RTCRtpSender.getCapabilities('video').codecs.map(c => c.mimeType).join(' '));
const pc = new RTCPeerConnection();
pc.addTransceiver('video', {direction: 'sendonly'});
console.log('headerExtensions:', pc.getParameters().headerExtensions.map(h => h.uri).join(' '));
pc.close();

The extension list is not uniform across builds or platforms. A Linux Chromium build and a Windows Chrome build of the same version do not advertise identical sets, so this leaks platform as well as automation.

Camera and Microphone Counts Are Not Permission-Gated

navigator.mediaDevices.enumerateDevices() is the awkward one, because privacy rules have progressively reduced what it returns. Since roughly Chrome 68, and by the equivalent changes in Safari and Firefox, the label field of every device is an empty string until the page is granted camera or microphone permission. A page with no permission still receives the full list of devices with blank labels, plus one synthetic default entry per kind.

What is not suppressed is the shape of the list. Entries still carry deviceId, groupId and kind, and a groupId is normally shared between a camera and its paired microphone, so even ungranted a collector learns how many audioinput and videoinput entries exist and how they group. Chrome also injects a synthetic audiooutput and one default per kind regardless of hardware, and those must be discounted before counting.

Claimed device Realistic audioinput Realistic videoinput
A desktop laptop with a webcam 1 to 2 (built-in mic, headset) 1
A desktop tower, no peripherals 0 to 1 0
A headless Linux container 0 0
An iPhone over a remote debug session 0 (permission never granted) 0

A profile claiming a consumer laptop while reporting zero audio inputs and zero cameras is describing a machine nobody types on. The remedy is not a bigger list but a plausible one: either fake a headset and a built-in camera with a stable deviceId and a shared groupId, or pick a class where zero is correct, such as a tower or an iPhone, and let the rest of the profile agree with that choice.

The Cheap Scalar Probes

A dozen numbers and booleans, none of them permission-gated, describe a machine better than most hardware strings.

  • navigator.hardwareConcurrency is the logical core count the browser can see. On a VM it is the vCPU allocation, which is why a 64-core cloud host reporting 64 is a datacenter tell and not a power user.
  • navigator.deviceMemory is a Chromium-only approximation in GiB, rounded down to exactly six buckets: 0.25, 0.5, 1, 2, 4, 8. The coarseness is the point: a real value is always one of the six, and it correlates with the screen size the owner chose.
  • navigator.maxTouchPoints is 0 on any mouse-driven desktop and 5 on every current iPhone, a one-bit phone/desktop split that generic spoofs get wrong constantly.
  • navigator.getBattery() returns a promise, and the presence of the function is the signal. It shipped in Chrome 53, was removed from Firefox, has never existed in Safari, and was dropped from the main thread in Chrome workers. A claimed iPhone exposing it is describing an Android or a spoofed global.
  • window.matchMedia answers per class: (hover: hover) and (pointer: fine) are true on a desktop and false on a touch phone, (prefers-reduced-motion: reduce) and (prefers-color-scheme: dark) follow OS settings, and (display-mode: browser) separates a tab from an installed PWA.
  • SpeechSynthesis.getVoices() returns the OS voice list: [] on a bare container, a multi-entry SAPI or OneCore list on Windows, a language-specific list on macOS. It is empty until voiceschanged fires, often 100 to 500 ms after load, so a synchronous read returning nothing is a timing artefact while a post-event read returning nothing means a build with no speech engine.
  • navigator.gpu.requestAdapter() returns an adapter whose requestAdapterInfo() exposes vendor, architecture, device, description and a subgroupMinSize. A null adapter on a claimed GPU machine is a strong tell, and the strings must agree with the WebGL UNMASKED_RENDERER_WEBGL value covered in Spoofing Canvas, WebGL and Audio.
  • navigator.connection exposes effectiveType, rtt, downlink and saveData where it exists, tying the profile to a network tier. It is Chromium-only; the local-IP side of the same API family is in WebRTC and Network Information Leaks.

Suppressing Capability Is Its Own Tell

Here is the design trap. If the hardening layer makes canPlayType return "" for everything, enumerateDevices return an empty list, getVoices return [], matchMedia('(hover: hover)') return false and requestAdapter return null, you have not built a discreet profile. You have built a machine no consumer ships, and the collector's job is now trivial: every capability is zero.

The real reference points are mid-market and unglamorous. A 2023-era Windows 11 laptop with an Intel Iris Xe, Chrome 140, a 1920x1080 panel, one built-in camera, one audio input, 16 GiB of RAM reported as 8, 16 hardware threads, no getBattery, four to fifteen speech voices, acceleration on, and a 16384-pixel WebGL texture limit. Every one of those numbers is boring, which is exactly why it passes. The iPhone 15 reference is equally ordinary: six cores, no getBattery, deviceMemory of 8, five touch points, a 393x852 screen at a device pixel ratio of 3, a single Apple GPU renderer, no camera or microphone entries until permission is granted, and one or two system voices. Reporting real container values honestly gets you a datacenter profile; faking a minimal set gets you a stripped build; copying one real profile end to end is the only one of the three that works.

What You Can Override and What You Cannot

The distinction that matters is between values the browser looks up in a table you can reach and values it reads from a native stack you cannot. hardwareConcurrency, deviceMemory and maxTouchPoints are plain attributes on a plain object, so any of them can be replaced from a Page.addScriptToEvaluateOnNewDocument hook. Codec support is a lookup into a compiled-in table: JavaScript can shadow the answer but cannot make the demuxer exist, so a video element claiming H.264 still cannot seek in an H.264 file. enumerateDevices enumerates real hardware through the OS subsystems, so a fake list yields devices that never appear in a permission prompt. SpeechSynthesis voices come from the OS, so a fake list gives voices that speak nothing. requestAdapter talks to the GPU process, so a fake adapter returns a null requestAdapterInfo() promise the moment anything calls it.

Probe Override point Survives a native call?
navigator.hardwareConcurrency, deviceMemory, maxTouchPoints attribute or getter override yes, nothing native reads them
navigator.getBattery presence add or delete the function no, the real battery path is absent anyway
canPlayType prototype method override no, the element still cannot decode
MediaSource.isTypeSupported static method override no, MSE still refuses the stream
enumerateDevices promise-returning override no, the permission prompt stays empty
SpeechSynthesis.getVoices prototype method override no, speechSynthesis.speak is a no-op
navigator.gpu.requestAdapter object override no, GPU queries return null
matchMedia answers override matchMedia no, CSS media queries still evaluate truthfully

The last row is the general case: overriding matchMedia to lie about (hover: hover) does not change which CSS rules the engine applies, so a stylesheet with @media (hover: hover) renders one way while your probe says another. Durable fixes for the right-hand column are real media devices, a real codec-enabled binary, a real GPU and a patched build, which is the subject of Patching and Building Chromium for Stealth.

Auditing a Claimed Device Class Against Real Answers

The audit is mechanical: state which device class the profile claims, collect the eight probe answers the real browser produced, and check each against the set of values a real machine in that class could return.

DEVICE_RULES = {
    "windows-11-laptop": {"cores": {4, 6, 8, 12, 16, 20, 24}, "ram": {4, 8, 16},
                          "touch": {0}, "audio_in": {1, 2}, "cams": {1, 2},
                          "h264": True, "battery": False, "voices": 3},
    "iphone-15-safari": {"cores": {6}, "ram": {8}, "touch": set(range(1, 11)),
                         "audio_in": {0}, "cams": {0}, "h264": True,
                         "battery": False, "voices": 1},
    "linux-ci-container": {"cores": {1, 2, 4, 8, 16}, "ram": {0.25, 0.5, 1, 2, 4, 8},
                           "touch": {0}, "audio_in": {0}, "cams": {0},
                           "h264": False, "battery": False, "voices": 0},
}

WHY = {
    "cores": "%s cores is not a shipping count for this class",
    "ram": "navigator.deviceMemory %s is outside the buckets for this class",
    "touch": "maxTouchPoints %s never occurs on this class",
    "audio_in": "%s audio inputs: no real user of this class has none",
    "cams": "%s cameras: no real user of this class has none",
    "h264": "canPlayType returned %s: no H.264 decoder in this build",
    "battery": "navigator.getBattery is %s, absent on this class",
    "voices": "%s voices after voiceschanged: a real OS always has some",
}


def show(label, device_class, probes):
    rules = DEVICE_RULES[device_class]
    print("=" * 74)
    print(label)
    print("=" * 74)
    print("%-9s %-10s %-34s %s" % ("probe", "observed", "expected", "verdict"))
    bad = 0
    for key, expected in rules.items():
        seen = probes.get(key, "?")
        if key == "h264":
            ok, shown = (seen != "") == expected, "yes" if expected else "no"
        elif key == "battery":
            ok, shown = seen is expected, expected
        elif key == "voices":
            ok, shown = isinstance(seen, int) and seen >= expected, ">= %d" % expected
        else:
            ok = seen in expected
            shown = "in {%s}" % ", ".join(str(v) for v in sorted(expected))
        print("%-9s %-10s %-34s %s" % (key, repr(seen), shown, "ok" if ok else "IMPOSSIBLE"))
        if not ok:
            bad += 1
            print("  ! " + WHY[key] % repr(seen))
    print("--> %d of %d probes contradict %r\n" % (bad, len(rules), device_class))


show("Claimed iPhone 15, answered by a 64-core datacenter host", "iphone-15-safari", {
    "cores": 64, "ram": 8, "touch": 0, "audio_in": 0, "cams": 0,
    "h264": "probably", "battery": True, "voices": 0})

show("Claimed Windows 11 laptop, answered by a bare CI container", "windows-11-laptop", {
    "cores": 4, "ram": 8, "touch": 0, "audio_in": 0, "cams": 0,
    "h264": "", "battery": False, "voices": 0})

show("Claimed Linux CI container, answered honestly", "linux-ci-container", {
    "cores": 8, "ram": 4, "touch": 0, "audio_in": 0, "cams": 0,
    "h264": "", "battery": False, "voices": 0})
==========================================================================
Claimed iPhone 15, answered by a 64-core datacenter host
==========================================================================
probe     observed   expected                           verdict
cores     64         in {6}                             IMPOSSIBLE
  ! 64 cores is not a shipping count for this class
ram       8          in {8}                             ok
touch     0          in {1, 2, 3, 4, 5, 6, 7, 8, 9, 10} IMPOSSIBLE
  ! maxTouchPoints 0 never occurs on this class
audio_in  0          in {0}                             ok
cams      0          in {0}                             ok
h264      'probably' yes                                ok
battery   True       False                              IMPOSSIBLE
  ! navigator.getBattery is True, absent on this class
voices    0          >= 1                               IMPOSSIBLE
  ! 0 voices after voiceschanged: a real OS always has some
--> 4 of 8 probes contradict 'iphone-15-safari'

==========================================================================
Claimed Windows 11 laptop, answered by a bare CI container
==========================================================================
probe     observed   expected                           verdict
cores     4          in {4, 6, 8, 12, 16, 20, 24}       ok
ram       8          in {4, 8, 16}                      ok
touch     0          in {0}                             ok
audio_in  0          in {1, 2}                          IMPOSSIBLE
  ! 0 audio inputs: no real user of this class has none
cams      0          in {1, 2}                          IMPOSSIBLE
  ! 0 cameras: no real user of this class has none
h264      ''         yes                                IMPOSSIBLE
  ! canPlayType returned '': no H.264 decoder in this build
battery   False      False                              ok
voices    0          >= 3                               IMPOSSIBLE
  ! 0 voices after voiceschanged: a real OS always has some
--> 4 of 8 probes contradict 'windows-11-laptop'

==========================================================================
Claimed Linux CI container, answered honestly
==========================================================================
probe     observed   expected                           verdict
cores     8          in {1, 2, 4, 8, 16}                ok
ram       4          in {0.25, 0.5, 1, 2, 4, 8}         ok
touch     0          in {0}                             ok
audio_in  0          in {0}                             ok
cams      0          in {0}                             ok
h264      ''         no                                 ok
battery   False      False                              ok
voices    0          >= 0                               ok
--> 0 of 8 probes contradict 'linux-ci-container'

The third case is the lesson. A container that admits it is a container scores zero, because nothing about it is contradictory. The problem is never that the answers are wrong, it is that they are wrong for the claim, which is the same reasoning as Cross-Validating Fingerprint Signals applied to hardware instead of headers.

Failure Modes

  • Reading the voice list too early. getVoices() returns [] synchronously on a healthy Windows machine. Patching it to a fixed list breaks voiceschanged, so a page that waits for the event never gets it.
  • A fresh deviceId per enumerateDevices call. An unstable deviceId makes one machine look like an unlimited fleet and contradicts the groupId a real camera and microphone share.
  • Overriding matchMedia without touching CSS. The stylesheet and the probe now disagree, visibly, in computed styles.
  • hardwareConcurrency spoofed to a prime. Thread counts are the base count times two with SMT, or the vCPU allocation. 7 and 13 exist on almost nobody's machine.
  • deviceMemory set to 16. The buckets stop at 8, and 16 is the commonest error in spoofed profiles.
  • Claiming a GPU you did not build. An RTX 4070 renderer string next to a null requestAdapter(), or a 16384 texture limit next to hardwareConcurrency: 2, is the magnitude mismatch covered next.

Capability Checklist

  • Every probe agrees with the claimed class, and the class is a shipping configuration rather than a minimum.
  • canPlayType is called for the bare container and for the container with a codec parameter, separating demuxer from decoder.
  • MediaSource.isTypeSupported and MediaRecorder.isTypeSupported match the element probe for the same MIME types.
  • RTCRtpSender.getCapabilities('video') is non-empty and the live headerExtensions array is non-empty.
  • enumerateDevices returns a count the class would really have, with a stable deviceId and a groupId shared by the camera and its paired microphone.
  • navigator.getBattery exists only on the platforms that ship it, and deviceMemory is one of the six Chromium buckets.
  • The matchMedia answers match the class, and the rendered CSS agrees with them.
  • The voice list is non-empty after voiceschanged, and the first lang matches navigator.language.
  • requestAdapterInfo().vendor and .architecture agree with the WebGL UNMASKED_RENDERER_WEBGL string.
  • Nothing is zero where a real consumer device would report a non-zero value.

The Legitimate Route

Everything above is about making an environment you are authorised to drive look internally consistent: your own test account, your own staging deployment, or a site whose terms permit automated collection. The legitimate routes for data are the vendor's official API and partner feed, a documented rate limit in the response headers, or a written agreement; the same probes aimed at a third-party user who never consented are surveillance, and a browser reporting capabilities you could only have obtained without consent has not become a real browser. On the defensive side the answer is cheap: probe the capabilities you need, compare the vector against your own traffic, and alert on joint rarity rather than on any single value, which is Evading ML Anomaly Detection read backwards.