AudioContext.sampleRate tells a page how many samples per second your audio output runs at, and for a live context that number comes from the output device: the built-in speakers, the headphones, the USB interface. Most desktops report 44100 or 48000. As an audio sample rate fingerprint it is worth about one bit, but it costs one line of script, and it has to agree with everything else a page knows about the machine.

Where the number comes from

The Web Audio specification is short on this point. When a page creates an AudioContext and does not ask for a rate, the context uses the preferred sample rate of the output device. The browser asks the operating system what that device is running at and renders at that rate, so it does not have to resample on the way out.

In practice the operating system holds the setting. On a Mac it is the format shown for the device in Audio MIDI Setup. On Windows it is the default format under the device's properties in Sound settings, such as "2 channel, 24 bit, 48000 Hz". A studio interface may be set to 96000. A Bluetooth headset that switches into its hands-free mode for a call can drop far lower, and a context created during that call reports it.

Three other cases do not read the hardware at all:

  • new AudioContext({ sampleRate: 44100 }) asks for a rate, and the browser honours it and resamples.
  • new OfflineAudioContext(1, 5000, 44100) renders into memory at whatever rate the page passed. It never touches a device, which is why the OfflineAudioContext fingerprint measures the maths of the audio stack and not the speakers.
  • A machine with no audio output gets whatever default the audio layer falls back to, which is itself a little unusual.

You can read your own in a second. Open the DevTools console on any page and type new AudioContext().sampleRate. Then change the output device and run it again.

Every profile is drawn from one real computer, not from separate listsIn Mosaic: Machines that exist. GPU, screen, CPU and fonts from one real device. Free holds 3 profiles. Pro is $49 a month for any number.

How it works: Machines that exist

What the sample rate fingerprint tells a site

On its own, very little. Two values cover nearly all desktops, so the rate splits visitors into two big groups and names nobody. Fingerprinting scripts collect it anyway, because it is free and because it sits next to things that matter more: the audio latencies, the channel count, and the audio rendering hash.

It also changes. Plug in headphones that run at 44100 on a machine whose speakers run at 48000, and the next context reports the headphones. A site that sees the rate flip between visits has seen someone change devices, which is ordinary. A site that sees a value no device in that class reports, or a rate that disagrees with the sound the page just rendered, has seen something less ordinary.

Why we stopped changing it

We changed it once, and took the change out. An early version of our JavaScript fallback, the layer that runs on stock Chromium rather than on our own engine, defined its own sampleRate on BaseAudioContext.prototype. The value came from a table. It contradicted itself three ways, and each is one line for a page to test.

The first was the offline context. sampleRate really lives on BaseAudioContext, and OfflineAudioContext inherits it rather than declaring its own. So a page that wrote new OfflineAudioContext(1, 5000, 48000) read 44100 back out of the object it had just built at 48000. No browser does that.

The second was the same mistake on a live context. A real Chrome honours new AudioContext({ sampleRate: 48000 }), and ours answered the table's number instead of the one the page had asked for.

The third could not be patched around at all. An AnalyserNode splits the signal into frequency bins whose width is the real sample rate divided by the FFT size. Feed it an oscillator at a known frequency, find the loudest bin, and the bin gives back the rate the audio stack is actually rendering at, whatever the getter says. Claiming 44100 on a 48000 machine moves that peak by nearly nine percent, because 48000 is 8.8% more than 44100. The getter can say anything; the sound cannot.

The premise was wrong as well. The table assumed macOS defaults to 44.1 kHz. A stock Chrome on the Mac we were writing on reported 48000.

So the rule we kept is that the rate belongs to the device. Tesseract Chrome, our engine, leaves the live sampleRate alone, and the machine it runs on answers. The audio values that do separate two profiles are elsewhere: each profile renders its own stable audio hash, changed in C++ inside the engine and keyed to the profile's seed, which is how our engine changes values without injected JavaScript. How that rendering hash is read, and the bug we had in it, are in an audio spoof that changed the sound every time it was read.

A value with almost no entropy can still give you away

The lesson generalises past audio. The cheapest values to read are often the cheapest to fake, and the easiest to cross-check, because the browser computes something else from them. Here the rendered sound carries the real rate. A canvas carries the real GPU's rasterisation, and a font list carries the real installed fonts. A low-entropy value left true says almost nothing. The same value faked has to stay consistent with everything computed from it, and a page can check that in one line. The full list of values a page reads, and which of them carry real weight, is in browser fingerprint.