Simplescraper
Skip to content

How to spoof a realistic browser fingerprint in Playwright

How to spoof a realistic browser fingerprint in Playwright

Updated 2026-06-25 · 6 min read

If you're running authorized scrapes against a site that fingerprints the browser, you have probably already changed the User-Agent and still seen the session flagged. The reason is usually that the rest of the fingerprint did not move with it: the navigator platform still reads Linux, the WebGL renderer still names a headless GPU, the viewport is the default 1280x720, and the timezone is UTC while the User-Agent claims a Windows machine in New York. An anti-bot script that cross-checks those values sees a contradiction no stock desktop browser would produce.

The fix is to generate one fingerprint where the User-Agent, navigator platform (the navigator.platform string a site reads to learn the operating system), WebGL strings, viewport, locale, and timezone all describe the same machine, then inject that whole set into the Playwright context before the first page loads. We'll build a small script that generates a fingerprint pinned to desktop Chrome on Windows so every spoofed signal agrees with the Chromium engine we launch, creates the Playwright context from that one fingerprint so the User-Agent, viewport, navigator, and WebGL values move together, pins the locale and timezone so the language signals and the clock describe the same region, and reads the values back from a test page to confirm the set holds together. It uses the fingerprint-injector library, and comes out to about 30 lines of Node.js and one package.

The complete script

js
// spoof-fingerprint.mjs
import { chromium } from 'playwright'
import { newInjectedContext } from 'fingerprint-injector'

const browser = await chromium.launch({ headless: true })

// constrain the generator to the same engine you launch (Chromium),
// the same OS the User-Agent will claim (Windows), and one locale.
// this keeps the navigator platform and WebGL strings consistent with Chrome.
const context = await newInjectedContext(browser, {
  fingerprintOptions: {
    browsers: ['chrome'],
    operatingSystems: ['windows'],
    devices: ['desktop'],
    locales: ['en-US']
  },
  // newInjectedContext applies userAgent, viewport, and accept-language
  // from the generated fingerprint. it does NOT set locale or timezoneId,
  // so pin them here to match the locale above.
  newContextOptions: {
    locale: 'en-US',
    timezoneId: 'America/New_York'
  }
})

const page = await context.newPage()

// load any page, then read the values the browser reports to client JS.
await page.goto('https://httpbin.org/headers', { waitUntil: 'domcontentloaded' })

const report = await page.evaluate(() => ({
  userAgent: navigator.userAgent,
  platform: navigator.platform,
  languages: navigator.languages,
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
  viewport: { width: window.innerWidth, height: window.innerHeight }
}))

console.log(report)

await browser.close()
bash
npm install playwright fingerprint-injector
node spoof-fingerprint.mjs

How it works

Constrain the generator to one engine and OS. fingerprintOptions filters the Bayesian network down to desktop Chrome on Windows. You launch chromium, so a Chrome-on-Windows fingerprint keeps the User-Agent, navigator.platform (Win32), and the WebGL vendor and renderer in the family a real Chromium reports. Leaving the generator unconstrained can hand you a Safari or Firefox fingerprint that contradicts the engine you are actually running.

Create the context with newInjectedContext. This helper generates one fingerprint and applies it in two places at once. It sets the Playwright context userAgent and viewport from the fingerprint's navigator.userAgent and screen dimensions, sets the accept-language header from the generated headers, and adds an init script that patches navigator, screen, and the WebGL getters before any document loads. Playwright cannot change the User-Agent or viewport after a context exists, which is why this runs at context creation. It already sets the viewport from the fingerprint, so do not pass a fixed viewport in newContextOptions, which would reintroduce a size that contradicts the claimed device. The fingerprint is fixed per context, so every page you open in this context shares it; for a different identity, call newInjectedContext again for a fresh context and close the old one.

Pin locale and timezoneId yourself. newInjectedContext sets the accept-language header and navigator.languages, but it does not set the Chromium-level locale or timezoneId. Without those, Intl.DateTimeFormat().resolvedOptions().timeZone returns the host machine's timezone, often UTC on a server, while your User-Agent and headers claim a US English browser. Passing locale: 'en-US' and an America/New_York timezone closes that gap so the language signals and the clock agree.

Read the values back. The page.evaluate block reports the User-Agent, platform, languages, timezone, and viewport the page actually sees. Run it once and confirm the platform reads Win32, the timezone reads America/New_York, and the languages lead with en-US. If any value still describes the host machine, the spoof is not complete for that signal. A matching fingerprint is only the passive half: it does not change request timing, mouse paths, or the absence of human interaction, which behavioral detectors weigh separately, so pair it with human-like input and pacing (see How to add human-like delays and mouse movement in Puppeteer).

Use this when

You have authorization to scrape a site that reads the browser fingerprint, and a plain User-Agent change is not enough because the site cross-checks platform, WebGL, viewport, and timezone for consistency.

Skip this when

The site only checks the IP reputation (rotate through a proxy pool instead); the block is a Cloudflare or Turnstile interactive challenge rather than a passive fingerprint read (see How to bypass Cloudflare in Puppeteer); the target returns full HTML to a bare request (use fetch and skip the browser); or you only need to vary the User-Agent header on HTTP requests with no browser (see How to rotate user agents per request in Node.js).

Skip the code, just get the data

Simplescraper turns any website into structured data in seconds.