Audio fingerprinting in browsers: scrapers’ guide

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:

  1. Create an OfflineAudioContext with fixed sample rate and length
  2. Create an OscillatorNode with fixed frequency and waveform (typically triangle wave at 1000 Hz)
  3. Connect through a DynamicsCompressorNode with fixed threshold and ratio
  4. Render the buffer with startRendering()
  5. Sum or hash a slice of the resulting samples
  6. 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:

environmentsum (samples 4500-5000)
Chrome 124 stable, macOS Sonoma124.04347527516074
Chrome 124 stable, Windows 11 Realtek124.04344884395601
Chrome 124 stable, Windows 11 NVIDIA HDA124.04345887154427
Chrome 124 stable, Ubuntu PulseAudio124.04344940345920
Headless Chrome 124, no audio device35.7383295930922
Headless Chrome 124 in Docker, no audio35.7383295930922
Firefox 124 stable35.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:

siteshowsformat
audiofingerprint.openwpm.comsum of samples 4500-5000HTML
browserleaks.com/javascript (audio section)full audio fingerprintHTML
amiunique.orgcombined fingerprint including audioHTML
coveryourtracks.eff.orgEFF’s fingerprint testHTML

Run your scraper against audiofingerprint.openwpm.com several times and check that:

  1. The returned sum is in the real-Chrome range (around 124.04)
  2. The sum varies slightly across contexts (different seeds produce different perturbations)
  3. 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

approachdifficultymaintenancesuccess rate
naive AudioContext overridetriviallowvery low
noise injection on getChannelDatamediummediumhigh if maintained
full Web Audio API spoofinghighhighmedium, fragile
patchrightlowlowhigh
rebrowser-playwrightlowlowhigh
Browserbase managed browsertrivialnonevery 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

vendoraudio weight in scoreminimum coverage needed
Cloudflare Bot ManagementmediumgetChannelData + getFloatFreqData
DataDomehighfull coverage including getByteFreqData
PerimeterX (Human)very highfull coverage + AnalyserNode hooks
Akamai Bot ManagermediumgetChannelData + sampleRate
Imperva Advanced Bot Protectionhighfull coverage
Kasadahighfull coverage + audio worklet processors
Arkose Labslownot primary signal
Shape Security (F5)mediumgetChannelData + 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.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top
message me on telegram

Resources

Proxy Signals Podcast
Operator-level insights on mobile proxies and access infrastructure.

Multi-Account Proxies: Setup, Types, Tools & Mistakes (2026)