JustBrowser
Tutorials13 min read

WebUSB, WebSerial & Web Bluetooth: 3 Hardware APIs That Expose Headless Browsers

JustBrowser Platform Team·
webusb-webserial-fingerprintingheadless-detectionhardware-api-leaksantidetect-browserbrowser-fingerprintingbuildinpublicsaasstudioaiworkforcebuildwithclaude

Three weeks ago I lost a scraping fleet to a single line of JavaScript.

Not canvas fingerprinting. Not WebGL renderer strings. Not even the UA-CH headers I thought I'd covered. The detection script called navigator.usb.requestDevice({filters: []}) and waited. My headless profiles rejected instantly — TypeError, no user gesture. Real browsers would've shown a USB picker dialog and waited for user interaction. The timing difference was 4ms versus "indefinite until user acts."

Twelve profiles. Eight months of cookie warm-up. Gone because I assumed hardware-bridge APIs were too niche to matter. They weren't.

(I actually laughed when I saw the detection logs. WebUSB? Really? Of all the things to burn me.)

What We're Building

By the end of this, you'll understand:

  1. How WebUSB, WebSerial, and Web Bluetooth work as fingerprinting vectors
  2. Why headless browsers fail these checks (and why it's architecturally hard to fix)
  3. Exactly what detection scripts look for — the specific patterns
  4. How to test your antidetect profiles against these APIs
  5. What a proper mitigation looks like at the browser engine level

This post pairs with the battery and sensor API leaks tutorial — different hardware vectors, same underlying problem: APIs that assume real hardware and real user interaction.

Prerequisites

  • An antidetect browser profile to test (JustBrowser, Multilogin, GoLogin — doesn't matter for diagnostics)
  • Chrome DevTools open — we'll be running console commands
  • Basic understanding of the permission model in browsers (HTTPS, user gestures)
  • A USB device plugged in (optional, but useful for comparison against real headed Chrome)

Step 1: Understanding the Hardware-Bridge Trio

These three APIs let web pages talk to physical hardware:

WebUSB — connects to USB devices (Arduino boards, hardware wallets, 3D printers, certain authentication keys)

WebSerial — connects to serial ports (COM ports on Windows, /dev/tty* on Linux/Mac, Arduino serial interfaces)

Web Bluetooth — connects to Bluetooth LE devices (fitness trackers, smart home devices, certain peripherals)

All three share critical security constraints:

  1. Secure context required — HTTPS only (or localhost)
  2. User gesture required — must be triggered by click/keypress, not script-only
  3. Permission dialog required — browser shows device picker, user must select

And here's what matters for detection: headless browsers can't render permission dialogs. They have no UI. When these APIs try to show a picker, headless mode either crashes, hangs, throws immediately, or returns a weird error state. Each failure mode is fingerprint-able.

Detection scripts don't need you to actually have USB devices. They just need to observe how your browser handles the request. Which is annoying, frankly, because it means you can't just unplug your Arduino and call it a day.

Step 2: WebUSB Detection Mechanics

The WebUSB API lives at navigator.usb. Here's the basic enumeration:

// Check if API exists
console.log('WebUSB available:', 'usb' in navigator);

// Try to get already-paired devices (no dialog needed)
if (navigator.usb) {
  navigator.usb.getDevices().then(devices => {
    console.log('Paired USB devices:', devices.length);
  });
}

The existence check is trivial. But the interesting signal is what happens when you call requestDevice():

// This REQUIRES user gesture in real browsers
navigator.usb.requestDevice({ filters: [] })
  .then(device => console.log('Selected:', device))
  .catch(err => console.log('Error type:', err.name, '| Message:', err.message));

In headed Chrome with a real user clicking a button:

  • Browser shows USB device picker
  • User sees list of connected devices
  • If user cancels: NotFoundError: No device selected.
  • If user selects: returns USBDevice object
  • Timing: hundreds of milliseconds to seconds (waiting for human)

In headless Chromium (Puppeteer, Playwright):

  • SecurityError or NotAllowedError — no user gesture possible
  • Sometimes TypeError if the API stub is incomplete
  • Timing: instant (< 10ms)

The detection script approach:

async function checkWebUSB() {
  const start = performance.now();
  try {
    // Trigger via synthetic click to pass basic gesture checks
    await navigator.usb.requestDevice({ filters: [] });
    return { headless: false, timing: performance.now() - start };
  } catch (err) {
    const elapsed = performance.now() - start;
    // Real user cancellation takes 500ms+ (dialog render + click)
    // Headless rejection is instant
    return {
      headless: elapsed < 100,
      errorType: err.name,
      timing: elapsed
    };
  }
}

If rejection happens in under 100ms, there's no way a human interacted with a dialog. The browser is headless or the API is stubbed incorrectly.

I'll be honest — I didn't think platforms would actually deploy this. Seemed too clever. But I've now seen it in production detection scripts on three different e-commerce sites. It's out there.

Step 3: WebSerial Detection Mechanics

WebSerial is similar but with its own quirks:

console.log('WebSerial available:', 'serial' in navigator);

if (navigator.serial) {
  navigator.serial.getPorts().then(ports => {
    console.log('Previously granted ports:', ports.length);
  });
}

The requestPort() call follows the same user-gesture-and-dialog pattern:

navigator.serial.requestPort()
  .then(port => console.log('Port:', port))
  .catch(err => console.log('Error:', err.name));

Headed browser flow:

  1. User clicks button triggering requestPort()
  2. Browser shows serial port picker (lists COM ports or /dev/tty*)
  3. User selects or cancels
  4. Promise resolves or rejects with NotFoundError

Headless flow:

  • NotAllowedError — transient activation required
  • Or API missing entirely
  • Instant rejection (< 10ms)

Detection scripts can even get fancier. They check the prototype chain of navigator.serial:

// Real WebSerial has specific internal slots
console.log(Object.getPrototypeOf(navigator.serial).constructor.name);
// Should be 'Serial' in real Chrome
// Wrapped/polyfilled versions show 'Object' or custom names

CreepJS does exactly this check. JavaScript overrides that try to polyfill WebSerial break prototype integrity. The override works for casual checks but fails forensic analysis.

My take: if you're using an antidetect browser that relies on JS patching for these APIs, you're probably already flagged and don't know it yet. See our User-Agent Client Hints fingerprinting guide for another vector where JavaScript overrides fail.

Step 4: Web Bluetooth Detection Mechanics

Web Bluetooth is arguably the trickiest because Bluetooth scanning involves additional OS-level permissions:

console.log('Web Bluetooth available:', 'bluetooth' in navigator);

if (navigator.bluetooth) {
  navigator.bluetooth.getAvailability().then(available => {
    console.log('Bluetooth adapter present:', available);
  });
}

The requestDevice() pattern again:

navigator.bluetooth.requestDevice({
  acceptAllDevices: true  // Show all BLE devices
})
  .then(device => console.log('Device:', device.name))
  .catch(err => console.log('Error:', err.name, err.message));

What makes Bluetooth detection nastier: even headed browsers on desktop often lack Bluetooth adapters. So detection scripts focus on the type of error:

ScenarioError TypeTiming
Headed, user cancelsNotFoundError500ms+
Headed, no adapterNotFoundError with specific message~100ms
HeadlessNotAllowedError or TypeError< 10ms
Polyfilled APITypeError on internal slot accessinstant

The combination of error type AND timing creates a reliable signal. And since most desktop users don't have Bluetooth adapters anyway, the detection script isn't intrusive — it just looks like a failed Bluetooth feature probe.

Clever. Annoyingly clever.

Step 5: Running a Full Diagnostic

Here's a combined test you can run on any profile. I use this before deploying new antidetect configurations:

async function hardwareBridgeAudit() {
  const results = {};

  // WebUSB
  results.webusb = { available: 'usb' in navigator };
  if (navigator.usb) {
    const start = performance.now();
    try {
      await navigator.usb.getDevices();
      results.webusb.getDevices = 'success';
    } catch (e) {
      results.webusb.getDevices = e.name;
    }
    results.webusb.getDevicesTiming = performance.now() - start;
  }

  // WebSerial
  results.webserial = { available: 'serial' in navigator };
  if (navigator.serial) {
    const start = performance.now();
    try {
      await navigator.serial.getPorts();
      results.webserial.getPorts = 'success';
    } catch (e) {
      results.webserial.getPorts = e.name;
    }
    results.webserial.getPortsTiming = performance.now() - start;
    results.webserial.prototypeValid =
      Object.getPrototypeOf(navigator.serial).constructor.name === 'Serial';
  }

  // Web Bluetooth
  results.bluetooth = { available: 'bluetooth' in navigator };
  if (navigator.bluetooth) {
    try {
      results.bluetooth.adapterAvailable =
        await navigator.bluetooth.getAvailability();
    } catch (e) {
      results.bluetooth.adapterError = e.name;
    }
  }

  console.log('Hardware Bridge Audit:', results);
  return results;
}

hardwareBridgeAudit();

Compare output between:

  1. Regular Chrome (headed, on your actual machine)
  2. Your antidetect profile
  3. Raw Puppeteer/Playwright headless for reference

The deltas tell you what detection scripts see. On real headed Chrome, getDevices() and getPorts() succeed (returning empty arrays if no devices paired). On headless, they often fail or behave differently.

Step 6: Why JavaScript Polyfills Fail

Someone on a scraping Discord suggested overriding navigator.usb with a fake object that returns empty arrays. I tried it. Spent a weekend on it — a weekend I'm not getting back, by the way. Here's why it doesn't work:

Timing analysis. Native navigator.usb.getDevices() resolves in < 1ms. A Promise-wrapped JavaScript polyfill adds 3-10ms of event loop overhead. Detection scripts measure this — the same behavioral timing patterns that catch mouse movement spoofing.

Internal slot checks. Real USBDevice objects have internal slots that JavaScript can't fake. When you call methods like device.open(), they access C++ bindings. Fake objects throw TypeError on internal slot access.

Cross-context leaks. Your polyfill runs in the main thread. A Worker or ServiceWorker calls the real API. Same inconsistency pattern we see with canvas fingerprint spoofing — the Worker isolation problem applies here too.

Prototype chain forensics. CreepJS and FingerprintJS Pro check Object.getOwnPropertyDescriptor(navigator, 'usb') and verify the descriptor is non-configurable. Polyfills require making it configurable to override. That's detectable.

The only real fix is intercepting these APIs in Chromium's device service layer — C++ code that runs before JavaScript even executes. Which is what native antidetect browsers do. JustBrowser's Chromium fork intercepts WebUSB/WebSerial/Web Bluetooth at the Mojo IPC boundary. Each profile gets configured device responses. The JavaScript layer sees what looks like real API behavior with real timing characteristics.

Extension-based tools can't touch this. Not possible. The APIs live below their execution context.

Look, I know this sounds like a sales pitch. And maybe it is a little. But I wasted real money on polyfill solutions before figuring this out, so: learn from my pain.

Common Errors and Fixes

Error: TypeError when calling navigator.usb.requestDevice()

What's happening: The WebUSB API is either missing or incompletely stubbed. Some headless configurations strip device APIs entirely.

The fix: You need an antidetect browser that preserves these APIs in a detectable-consistent way. Stripping them is actually worse than having them — 'usb' in navigator returning false on what claims to be Chrome 126+ is itself suspicious.

Error: NotAllowedError with instant timing (< 10ms)

What's happening: The browser is rejecting because there's no user gesture context. Headless mode can't process permission dialogs.

The fix: Native antidetect browsers can simulate the dialog interaction at the engine level, returning rejection errors with appropriate timing delays. JustBrowser adds synthetic latency to match headed rejection patterns.

Error: API exists but prototype checks fail

What's happening: A JavaScript polyfill is overriding the native API. The object is present but doesn't have correct internal structure.

The fix: Don't use JavaScript-level patches for hardware APIs. They don't survive forensic checks. Use a native Chromium fork or accept that this vector will flag you.

Error: Profile passes individual checks but fails on timing analysis

What's happening: The API responses are correct but arriving too fast. Real user interaction takes hundreds of milliseconds minimum.

The fix: Engine-level interception must include timing simulation. This is harder than it sounds — you need to delay promise resolution by plausible human-interaction amounts without blocking other operations. JustBrowser handles this per-profile; most antidetect browsers don't. (Honestly? I'm still surprised more don't. It's 2026. This isn't new.)

Next Steps

These three APIs — WebUSB, WebSerial, Web Bluetooth — are niche. Most websites never call them. But detection scripts do, specifically because they're niche. Easy to test, hard to fake, and most automation setups ignore them completely.

  1. Run the diagnostic on every profile you operate. Document which APIs exist and how they behave.

  2. Compare against headed Chrome. On your actual machine, with real USB devices plugged in, what does the audit return? That's your target state.

  3. Test against detection services. CreepJS checks device API consistency. So does BrowserScan. Our CreepJS walkthrough covers the methodology.

  4. Re-test after browser updates. Chromium changes device API behavior between versions. A profile that passed on Chromium 140 might fail on 146. The extension vs native architecture post explains why this matters.

  5. Consider whether you need these vectors at all. If your target sites don't check hardware APIs, skip this entirely. Seriously. But if they do — and increasingly they do — you need an antidetect browser that handles them at the engine level.

The hardware-bridge APIs are obscure. Exactly why detection scripts love them. The automation tooling ecosystem treats them as afterthoughts — which makes them perfect tells. Native antidetect browsers that actually intercept them? They pass. Everything else eventually gets caught.

Twelve profiles. Eight months of warm-up. I won't make that mistake again. Hopefully you won't have to learn it the same way.

Frequently Asked Questions

Why do headless browsers fail WebUSB detection?

Headless browsers can't render the USB device picker dialog that WebUSB requires. When navigator.usb.requestDevice() is called, headed browsers show a permission dialog listing connected USB devices. Headless mode either throws immediately, hangs indefinitely, or returns an empty rejection — all detectable patterns that differ from real user interaction with the dialog.

Can WebSerial be spoofed with JavaScript?

No. WebSerial requires secure context (HTTPS) and user gesture to call requestPort(). The API returns SerialPort objects with specific internal slots and prototype chains. JavaScript wrappers have timing differences (5-10ms vs 0.1ms native calls) and break prototype integrity checks. CreepJS specifically tests for these inconsistencies.

Do all antidetect browsers handle hardware APIs correctly?

Most don't. Extension-based antidetect browsers can't intercept these low-level browser APIs — they operate at the JavaScript layer while WebUSB/WebSerial/Web Bluetooth are implemented in Chromium's C++ device layer. Native Chromium forks like JustBrowser can intercept at the engine level and return per-profile responses that match expected headed browser behavior.

What's the simplest WebBluetooth detection check?

Check whether navigator.bluetooth exists and whether requestDevice() with a valid filter returns a proper rejection type. Headed browsers reject with NotFoundError after the user cancels the dialog. Headless setups either lack the API entirely, throw TypeError, or reject with NotAllowedError before any dialog could have appeared. The error type and timing reveal the browser mode.


Try JustBrowser

Native Chromium antidetect browser — not extension-based. Real C++ engine patches at the canvas / WebGL / font / TLS layer, so 40+ identity parameters are genuine, not faked. REST API for Playwright, Puppeteer, Selenium. 7-day free trial, card required — then $9.99/month or $99.99/year, unlimited profiles, free team seats.

Get started → · How it differs from Multilogin / GoLogin / AdsPower

Ready to manage multiple accounts?

Seven days free, then $9.99/month — one plan, everything included.

We'd like to use Google Analytics, a Google service, to understand how our website is used. It sets two cookies in your browser and runs only if you click Accept. You can change your choice at any time with Cookie settings. Cookie Policy

Sign-in cookies and the cookie that remembers this choice are always on; the website needs them to work.

Google Analytics, a Google service, helps us understand how our website is used. It sets two cookies, _ga and _ga_TVZHQ99TZW. It is now onoff in this browser. If your browser sends a Global Privacy Control or Do Not Track signal, it stays off. Cookie Policy