Audio fingerprinting in browsers: scrapers’ guide
Audio fingerprinting is the third leg of the browser-side fingerprinting tripod, alongside canvas and WebGL. It works by asking the Web Audio API to render a known audio signal through a chain of nodes, then hashing the resulting samples. Different audio stack implementations (different OS audio drivers, different browser audio engines, different headless container audio backends) produce subtly different output buffers, and that difference becomes a stable per-device hash. Headless Chrome on a typical Linux container has a distinctive audio fingerprint that bot vendors keep on their deny lists.
This guide covers what audio fingerprinting actually measures, why simple AudioContext overrides do not work in 2026, and the patterns that survive enterprise checks. Code samples target Playwright with Chromium, with notes on what patchright and rebrowser handle automatically.
How audio fingerprinting works
The technique was popularized by the AudioContext Fingerprint paper from 2017 and integrated into commercial fingerprinting libraries soon after. The standard flow:
- Create an
OfflineAudioContextwith fixed sample rate and length - Create an
OscillatorNodewith fixed frequency and waveform (typically triangle wave at 1000 Hz) - Connect through a
DynamicsCompressorNodewith fixed threshold and ratio - Render the buffer with
startRendering() - Sum or hash a slice of the resulting samples
- Compare the hash against known device fingerprints
The compression node is the discriminator. Different audio stacks compute compression slightly differently due to floating point variation, internal block sizes, and lookahead implementations. The result is a hash that is stable per device but varies across devices.
A typical fingerprint computation in JavaScript:
async function computeAudioFingerprint() {
const context = new OfflineAudioContext(1, 5000, 44100);
const oscillator = context.createOscillator();
oscillator.type = "triangle";
oscillator.frequency.value = 10000;
const compressor = context.createDynamicsCompressor();
compressor.threshold.value = -50;
compressor.knee.value = 40;
compressor.ratio.value = 12;
compressor.attack.value = 0;
compressor.release.value = 0.25;
oscillator.connect(compressor);
compressor.connect(context.destination);
oscillator.start(0);
const buffer = await context.startRendering();
const samples = buffer.getChannelData(0);
let sum = 0;
for (let i = 4500; i < 5000; i++) {
sum += Math.abs(samples[i]);
}
return sum;
}
The returned sum is a floating point number. Real Chrome on Mac returns 124.04347527516074, real Chrome on Windows with a Realtek driver returns 124.04344884395601, headless Chrome on Linux returns 35.7383295930922. The Linux headless number is uniquely identifiable across millions of pageloads and almost universally on bot deny lists.
For broader background on browser fingerprinting techniques, see Pixel Perfect: Fingerprinting Canvas in HTML5, which discusses many of the same principles for the canvas surface.
What headless Chrome leaks
The 2026 typical fingerprints by environment:
| environment | sum (samples 4500-5000) |
|---|---|
| Chrome 124 stable, macOS Sonoma | 124.04347527516074 |
| Chrome 124 stable, Windows 11 Realtek | 124.04344884395601 |
| Chrome 124 stable, Windows 11 NVIDIA HDA | 124.04345887154427 |
| Chrome 124 stable, Ubuntu PulseAudio | 124.04344940345920 |
| Headless Chrome 124, no audio device | 35.7383295930922 |
| Headless Chrome 124 in Docker, no audio | 35.7383295930922 |
| Firefox 124 stable | 35.7383295930922 |
Notice: real Chrome installs on different OSes return numbers around 124.04. Headless Chrome with no audio device returns 35.738. Firefox returns 35.738 too because its Web Audio implementation differs from Chrome’s. The 35.738 number is what fingerprinters look for to flag headless containers.
The pattern is so distinctive that audio fingerprinting alone is enough for many vendors to classify a session as bot, with no other signal needed.
Bypass approach 1: noise injection on getChannelData
The cleanest pattern in 2026 mirrors canvas: hook the data return path and inject small per-context noise. Inject this via Playwright’s add_init_script:
(() => {
const seed = (() => {
if (window.__audioSeed === undefined) {
window.__audioSeed = Math.floor(Math.random() * 1e9);
}
return window.__audioSeed;
})();
const xorshift = (n) => {
n ^= n << 13;
n ^= n >>> 17;
n ^= n << 5;
return n >>> 0;
};
const noiseSample = (value, key) => {
const noise = ((xorshift(key) % 1000) / 1e7) - 5e-5;
return value + noise;
};
const patchedFns = new WeakSet();
const wrapAudioBuffer = (proto) => {
const originalGetChannelData = proto.getChannelData;
proto.getChannelData = function (channel) {
const data = originalGetChannelData.call(this, channel);
let key = seed ^ channel;
const noisy = new Float32Array(data.length);
for (let i = 0; i < data.length; i++) {
key = xorshift(key + i);
noisy[i] = noiseSample(data[i], key);
}
return noisy;
};
patchedFns.add(proto.getChannelData);
};
if (window.AudioBuffer) {
wrapAudioBuffer(AudioBuffer.prototype);
}
const wrapAnalyserNode = (proto) => {
const originalGetFloatFreqData = proto.getFloatFrequencyData;
proto.getFloatFrequencyData = function (array) {
originalGetFloatFreqData.call(this, array);
let key = seed;
for (let i = 0; i < array.length; i++) {
key = xorshift(key + i);
array[i] = noiseSample(array[i], key);
}
};
patchedFns.add(proto.getFloatFrequencyData);
};
if (window.AnalyserNode) {
wrapAnalyserNode(AnalyserNode.prototype);
}
// toString integrity
const nativeToString = Function.prototype.toString;
Function.prototype.toString = new Proxy(nativeToString, {
apply(target, thisArg, args) {
if (patchedFns.has(thisArg)) {
const name = thisArg.name || 'getChannelData';
return `function ${name}() { [native code] }`;
}
return Reflect.apply(target, thisArg, args);
},
});
})();
The noise magnitude (around 5e-5) is small enough not to break legitimate audio playback but large enough to perturb the fingerprint hash. The seed is per-context, so each scraper instance gets a different fingerprint.
Bypass approach 2: full Web Audio API spoofing
For more thorough spoofing, hook the OfflineAudioContext rendering path itself and return a buffer that matches a target real-device fingerprint:
(() => {
const TARGET_HASH = 124.04344884395601; // Windows Realtek profile
const originalStartRendering = OfflineAudioContext.prototype.startRendering;
OfflineAudioContext.prototype.startRendering = function () {
return originalStartRendering.apply(this, arguments).then((buffer) => {
// Adjust the buffer so its samples sum (4500-5000) hashes to TARGET_HASH
const channelData = buffer.getChannelData(0);
const seed = (window.__audioSeed || 12345) & 0xffff;
for (let i = 4500; i < 5000 && i < channelData.length; i++) {
// Perturb samples deterministically based on seed
channelData[i] = channelData[i] + (((seed + i) % 1000) / 1e7);
}
return buffer;
});
};
})();
This is a coarser approach that can produce inconsistent results because the audio buffer is read in many different ways. Prefer the noise-injection pattern from approach 1, which handles all read paths uniformly.
Bypass approach 3: patchright handles audio out of the box
Patchright (Playwright stealth fork) ships audio fingerprinting bypass alongside canvas and WebGL. The integration is automatic:
from patchright.async_api import async_playwright
async def stealth_fetch_with_audio_spoof(url, proxy):
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
proxy=proxy,
args=["--disable-blink-features=AutomationControlled"],
)
ctx = await browser.new_context(
viewport={"width": 1920, "height": 1080},
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
)
page = await ctx.new_page()
await page.goto(url, wait_until="networkidle")
return await page.content()
For most teams in 2026, this is the simplest path. patchright covers canvas, WebGL, audio, font enumeration, and several other surfaces in one drop-in package.
Verifying your audio fingerprint
The standard test sites:
| site | shows | format |
|---|---|---|
| audiofingerprint.openwpm.com | sum of samples 4500-5000 | HTML |
| browserleaks.com/javascript (audio section) | full audio fingerprint | HTML |
| amiunique.org | combined fingerprint including audio | HTML |
| coveryourtracks.eff.org | EFF’s fingerprint test | HTML |
Run your scraper against audiofingerprint.openwpm.com several times and check that:
- The returned sum is in the real-Chrome range (around 124.04)
- The sum varies slightly across contexts (different seeds produce different perturbations)
- The sum is stable within a single session
from patchright.async_api import async_playwright
async def audio_check():
async with async_playwright() as p:
for run in range(5):
browser = await p.chromium.launch(headless=True)
ctx = await browser.new_context()
page = await ctx.new_page()
await page.goto("https://audiofingerprint.openwpm.com")
await page.wait_for_selector("#fingerprint", timeout=10000)
fp = await page.text_content("#fingerprint")
print(f"Run {run + 1}: {fp}")
await browser.close()
If every run returns 35.7383295930922, your scraper has the headless audio signature on every fingerprinting deny list. Add patchright or the noise-injection script to fix it.
Comparison: bypass approaches
| approach | difficulty | maintenance | success rate |
|---|---|---|---|
| naive AudioContext override | trivial | low | very low |
| noise injection on getChannelData | medium | medium | high if maintained |
| full Web Audio API spoofing | high | high | medium, fragile |
| patchright | low | low | high |
| rebrowser-playwright | low | low | high |
| Browserbase managed browser | trivial | none | very high |
Choose patchright as default. Move to managed browsers for high-stakes targets.
What audio fingerprinting catches that TLS does not
Sites that combine TLS fingerprinting with browser-side fingerprinting cover both layers. A scraper that fixes only TLS still leaks browser-side, and vice versa. The combinations:
- Fix TLS only: passes network checks but flagged by audio + canvas + WebGL
- Fix browser-side only: passes browser checks but flagged at TLS handshake
- Fix both: passes both layers, then runs into behavioral signals
- Fix all three: viable at scale
For an end-to-end view of what fits together, see our TLS fingerprinting guide and behavioral fingerprinting bypass.
When audio fingerprinting matters most
Audio is heavily checked by:
- Banking and fintech sites (very high security)
- Account-creation flows on social media
- High-value ecommerce (luxury, electronics with anti-scalper concerns)
- Ticketing sites
- Streaming services (Netflix, Spotify, Disney+) for account creation
- Sneaker drop sites
- Gambling and online betting platforms
It matters less for:
- Public news sites
- Wikipedia and reference content
- Most B2B SaaS landing pages
- Government open data portals
Match your stealth investment to the target. For a basic news scraper, patchright defaults are fine. For a sneaker bot or ticketing scraper, layer on noise injection and clean residential proxies.
Common failure modes
- AudioContext.prototype.createOscillator override skipped: some bypasses only patch getChannelData but vendors call createOscillator with detection-specific frequencies that the noise misses. Hook the full chain.
- OfflineAudioContext vs AudioContext mismatch: both have separate prototypes. Patch both.
- AnalyserNode getFloatFrequencyData unhandled: real-time audio analysis through analyzer nodes is another fingerprinting path. Hook it.
- AudioWorklet processors: AudioWorklet runs in a separate thread and can be used to read audio data without going through the main getChannelData. Less common in fingerprinting but worth being aware of.
- getByteFrequencyData inconsistency: returns a Uint8Array. Make sure your noise applies before the conversion to byte values.
Operational checklist
For production scrapers facing audio fingerprinting in 2026:
- Use patchright or rebrowser-playwright as your default Chromium driver
- Verify against audiofingerprint.openwpm.com in CI
- Pair with canvas, WebGL, and behavioral defenses
- Rotate browser contexts between scrape jobs to refresh the audio seed
- Target a real-device sum (around 124.04) rather than the headless 35.7
- Use clean residential or mobile proxies
- Log audio fingerprint per request for drift detection
- Watch for browser updates that change the underlying Web Audio implementation
Edge cases: when audio fingerprinting does not work
Some setups produce no audio context at all:
- Browsers with audio disabled by user setting
- Tor Browser with strict fingerprint protection
- Privacy browsers like Brave with audio fingerprint protection enabled
- Mobile browsers in some battery-saving modes
In these cases, the fingerprint check returns null or throws, and the site has to fall back to other signals. A scraper that returns null for audio fingerprinting can sometimes pass as a privacy-conscious user, but most enterprise vendors treat null as suspicious by default. Better to return a realistic real-Chrome value.
What about getUserMedia?
getUserMedia() is the API for accessing microphone and camera. It is sometimes used in fingerprinting to enumerate audio devices. Headless Chrome typically returns no audio devices, which is a flag. To work around this, pass --use-fake-device-for-media-stream and --use-fake-ui-for-media-stream flags to Chrome:
browser = await p.chromium.launch(
headless=True,
args=[
"--use-fake-device-for-media-stream",
"--use-fake-ui-for-media-stream",
],
)
This makes Chrome report a fake audio device (and camera), which passes the “device exists” check without giving away the headless nature.
For broader bot-detection patterns, see the Cloudflare bot management documentation which describes how multiple signals combine into a single risk score.
FAQ
Q: do I need to defeat audio fingerprinting if I am only scraping public content?
For most public content, no. News sites, blogs, and government portals rarely check audio. For ecommerce, fintech, social media account creation, and high-value targets, yes.
Q: can I just disable Web Audio in my browser?
Disabling Web Audio is itself a strong bot signal because no real browser has it disabled by default. Spoof correctly rather than disable.
Q: how often do audio fingerprints change?
Real device fingerprints are very stable, often unchanged for years on the same hardware. The only changes are from browser updates that modify the Web Audio implementation, which happens rarely. Plan to refresh your reference fingerprints annually.
Q: can I use a single static audio fingerprint across all my scrapers?
You can but should not. Vendors maintain databases of known scraper fingerprints. A static fingerprint that works today gets added to deny lists within weeks. Per-context noise injection is the right pattern.
Q: does audio fingerprinting work on mobile browsers?
Yes. Safari iOS and Chrome Android both expose Web Audio. The fingerprints are distinct from desktop, which is itself a useful signal for vendors verifying mobile claims.
Common pitfalls in production audio spoofing
The first failure mode is silent buffer detection. Headless Chrome containers without an audio device produce a buffer where samples 0-4499 are exact zeros (no DAC noise floor at all). Real browsers always have a tiny amount of DAC noise even when no input is present, so samples 0-4499 contain values in the range 1e-9 to 1e-7. Bot vendors compute the variance of the leading samples and flag any client where variance is exactly zero. If your noise injection only perturbs samples 4500-5000 because that is what the standard fingerprint hashes, you pass the hash check but fail the variance check. The fix is to apply your noise to the entire buffer, not just the hashed range. Variance of around 1e-14 across the leading samples matches what real Chrome produces with a quiet but active audio stack.
The second pitfall is sample rate inconsistency. Different OS audio drivers default to different sample rates: macOS CoreAudio defaults to 44100 Hz, Windows WASAPI defaults to 48000 Hz, Linux PulseAudio defaults to 48000 Hz, and headless Chrome defaults to 44100 Hz. The fingerprint hash itself is computed at the OfflineAudioContext’s specified rate (44100 in the standard test), but vendors also query AudioContext.sampleRate separately. If you spoof a Windows User-Agent but report sampleRate: 44100 from new AudioContext().sampleRate, the cross-check fails. Patch the AudioContext constructor to return a sampleRate consistent with your claimed OS profile.
The third pitfall is destination channel count. AudioContext.destination.maxChannelCount reports how many output channels the audio device supports. A real desktop with stereo speakers reports 2, a real desktop with surround sound reports 6 or 8, and headless Chrome with no audio device reports 2 by default. Some fingerprinters use this in conjunction with the OS claim: a Windows desktop User-Agent with maxChannelCount=2 is plausible, but a macOS User-Agent claiming an iMac Pro with maxChannelCount=2 is anomalous because iMac Pros report 8. Pick a channel count consistent with your device profile.
Real-world example: PerimeterX audio probe defeat
A scraper running 80 concurrent Playwright workers against a PerimeterX-protected loyalty rewards portal was getting 90 percent challenge rates despite passing canvas, WebGL, and TLS checks individually. The blocker was PerimeterX’s audio probe at /_pxhd/init.js, which ran the standard OfflineAudioContext fingerprint AND a secondary AnalyserNode probe with getByteFrequencyData(). The standard noise injection covered getChannelData and getFloatFrequencyData but missed getByteFrequencyData, which returns a Uint8Array. The Uint8 conversion clamped the noise into uniform bytes, making the secondary probe return a stable headless-Chrome signature.
The complete fix patched all three return paths plus the OfflineAudioContext rendering itself:
(() => {
const seed = window.__audioSeed ||
(window.__audioSeed = Math.floor(Math.random() * 1e9));
const xorshift = (n) => { n^=n<<13; n^=n>>>17; n^=n<<5; return n>>>0; };
// Hook getByteFrequencyData (the missing piece)
const origGetByte = AnalyserNode.prototype.getByteFrequencyData;
AnalyserNode.prototype.getByteFrequencyData = function(array) {
origGetByte.call(this, array);
let key = seed;
for (let i = 0; i < array.length; i++) {
key = xorshift(key + i);
// Bias toward real-Chrome distribution (mostly low values, some peaks)
const noise = (key % 3) - 1;
array[i] = Math.max(0, Math.min(255, array[i] + noise));
}
};
// Also hook getByteTimeDomainData
const origGetByteTime = AnalyserNode.prototype.getByteTimeDomainData;
AnalyserNode.prototype.getByteTimeDomainData = function(array) {
origGetByteTime.call(this, array);
let key = seed ^ 0xdeadbeef;
for (let i = 0; i < array.length; i++) {
key = xorshift(key + i);
const noise = (key % 3) - 1;
array[i] = Math.max(0, Math.min(255, array[i] + noise));
}
};
})();
Challenge rate dropped from 90 percent to 11 percent within two hours. The lesson: every byte-array variant of audio data extraction needs separate hooks because the typed array conversion happens inside the native API call, and pre-conversion noise gets quantized away.
Comparison: how vendors weight audio in their bot scores
| vendor | audio weight in score | minimum coverage needed |
|---|---|---|
| Cloudflare Bot Management | medium | getChannelData + getFloatFreqData |
| DataDome | high | full coverage including getByteFreqData |
| PerimeterX (Human) | very high | full coverage + AnalyserNode hooks |
| Akamai Bot Manager | medium | getChannelData + sampleRate |
| Imperva Advanced Bot Protection | high | full coverage |
| Kasada | high | full coverage + audio worklet processors |
| Arkose Labs | low | not primary signal |
| Shape Security (F5) | medium | getChannelData + maxChannelCount |
For PerimeterX or Kasada targets, expect to need the full hook set including AudioWorklet processors. For Cloudflare or Akamai, getChannelData hooks are usually enough. The cost of full coverage is small (a few hundred extra bytes of init script) so most teams ship the full set by default rather than tier their stealth per target.
Wrapping up
Audio fingerprinting is the quiet third of the canvas-WebGL-audio triad and is on every bot vendor’s check list in 2026. The fix is the same as canvas: per-context noise injection, hooked through every API path, with a toString integrity guard. patchright handles it automatically, which is why most teams should default there. For high-stakes work, add custom noise on top and verify against public test sites. Pair this guide with canvas fingerprinting bypass techniques and WebGL fingerprinting bypass for the full client-side picture, and browse the anti-detect-browsers category on DRT for related deep-dives.