Not Every Fingerprint Is a Chrome Fingerprint

Most of this course is about Chromium, because Chromium is what anti-bot vendors fingerprint hardest. But an entire population of real traffic is Firefox and Safari, and a coherent non-Chromium profile is not a weaker version of a Chrome profile: it is a different, and often less crowded, place to stand.

That opportunity has a price. The API surface you have to shape is smaller, but the fingerprint sources are unfamiliar, and the most popular hardening knob in Firefox -- privacy.resistFingerprinting -- actively makes you more distinctive if you turn it on from a datacenter.

Why a Non-Chromium Profile Is Worth the Work

Every layer of the transport stack is engine-specific. Firefox's TLS ClientHello has a different cipher list, a different extension order and a different GREASE pattern from BoringSSL's, so its JA3 and JA4 land in a different bucket. Its HTTP/2 frames order pseudo-headers differently. Its request header sequence is its own, and it does not send Chromium's Sec-CH-UA trio at all. Its canvas hash comes from a different Skia-free rasteriser, its audio numbers from a different mixing path, and its font metrics from a different shaping stack.

The point is not that a Firefox profile is invisible. It is that a coherent Firefox profile does not compete for uniqueness inside the Chrome crowd. Cross-validating fingerprint signals is the exercise that makes this concrete: you are not trying to be unique among all browsers, only not anomalous among the browsers you claim to be. And a second Firefox on the same residential exit IP is a population with far more members than a second headless Chrome.

Firefox: The Pref Surface

Firefox is configurable in a way Chromium is not, which is the whole opportunity. Preferences live in about:config, ship in a user.js, and can be locked per-profile with prefs.js in a distribution directory. The fields you care about:

Pref or property What it controls
general.useragent.override replaces the whole UA string (and is almost always the wrong tool)
general.buildID.20181001000000 the build id reported to pages; the UA's Gecko token is the frozen date 20100101
privacy.resistFingerprinting blocks canvas readback, unifies navigator.userAgent and userAgentData, letterboxes the window
privacy.reduceTimerPrecision coarsens performance.now() and Date.now()
dom.webaudio.enabled the whole Web Audio surface, which feeds the audio fingerprint
font.name.* / font.name-list.* the standard font set, which drives every text-metric measurement
webgl.enable-debug-renderer-info exposes WEBGL_debug_renderer_info, and with it the vendor and renderer strings
network.dns.disablePrefetch changes how many DNS lookups a navigation generates
marionette.port starting the remote agent sets navigator.webdriver

navigator.platform is not a pref you set; it is derived from the build, and a Windows Firefox reports Win64, a macOS one MacIntel, a Linux one Linux x86_64. window.oscpu is Firefox-only and carries an OS and CPU token. Neither is a header, so no amount of extra_http_headers will make them agree with a UA you rewrote -- which is the central trap below.

resistFingerprinting Adds Signal, It Does Not Remove It

privacy.resistFingerprinting is Firefox's answer to the fingerprinting problem, and it works: it blocks canvas readback, strips high-resolution timers, letterboxes the content area to a fixed set of sizes, standardises navigator.userAgent and userAgentData, and forces every window to report one of a handful of dimensions. Firefox also ships letterboxing and RFP as separate stages because the two change different signals.

The problem is the population. A real user on a laptop almost never has RFP on; a browser extension that enables it is the main source. So a datacenter IP presenting an RFP profile is a rare joint value, and rare joint values are exactly what an anomaly model keys on -- the same trap as an unusual font combination or an untested canvas variant. privacy.reduceTimerPrecision compounds it: your inter-event timings are quantised in a way almost no user traffic is.

The defensible position is the other one. Leave RFP off, and get your uniqueness reduction from ordinary means: a real profile directory, a real font set, real window history. Turn RFP on only when you are deliberately modelling a privacy-hardened user, and then model it all the way -- letterbox size, the standardised UA, the coarsened timers -- because a half-applied RFP is worse than none.

The Wrong Way to Use general.useragent.override

This is the Firefox equivalent of a UA spoof in Python, and it is the single most common mistake. Set the pref to a Chrome string, and you have changed exactly one field: navigator.userAgent. Everything the server can cross-check still says Firefox -- no Sec-CH-UA brand list, Gecko-style header order, Firefox TLS, window.oscpu naming a real OS, Firefox canvas, Firefox plugin surface. The result is a Firefox that claims to be Chrome in one field and is measurably not Chrome in fifteen.

Either commit to the whole bundle -- Firefox UA, Firefox headers, Firefox TLS stack, Firefox fonts, no client hints -- or change nothing. The validator further down is written to make that decision mechanical: it takes a bundle and prints every field that disagrees with every other field, so a half-applied override is a table row rather than a production incident.

Marionette, webdriver, and Playwright's Firefox

navigator.webdriver in Firefox is set by Marionette, the remote automation agent. Playwright drives Firefox over Juggler, a different protocol, and its Firefox build is patched specifically so that the WebDriver marker does not appear and the protocol is not Marionette. That is genuinely harder to detect than the Chromium case, and it is also the reason Playwright's Firefox does not behave quite like a stock Firefox: it is a vendor build with its own Juggler-specific surfaces.

If you drive Firefox with Selenium instead, you are starting Marionette, and navigator.webdriver is true. You can avoid the visible flag by never enabling the pref, by patching the preference read in a build, or by hiding the property in an init script -- but each of those leaves something else: the marionette.enabled pref, a debugger-attached timing signature, or an override whose descriptor is wrong. The lesson from CDP and instrumentation leaks applies with the protocol name changed: the protocol is the signal, and hiding the flag does not unhook the socket. Measure the Firefox build you actually ship, not the one the documentation describes.

WebKit and Safari

Safari's UA has been deliberately thinned. User-agent reduction froze the OS micro-version token and stopped advertising the browser build precisely, so a current Safari reports a coarse Version/17.x with no hardware detail; navigator.vendor is the string "Apple Computer, Inc."; and WebKit does send low-entropy client hints with a "Safari" brand rather than a Chromium brand list. On the privacy side, ITP partitions cookies by top-level site, caps script-written cookie lifetimes at seven days, and applies storage-iteration limits that change how much of a profile survives.

The problem is that headless WebKit is not a product. Playwright ships a patched WebKit with its own markers and a synthetic macOS Safari UA, and every one of those markers is a liability. An honest Safari is also close to unbuildable off a Mac: Safari is closed-source, the Metal-backed GPU strings are hardware-specific, and there is no supported way to shape its navigator surface the way a Firefox user.js shapes Gecko's. Treat a WebKit profile as viable for a minority of your traffic on a minority of targets, never as the default, and run the same coherence audit on it that you would run on the Firefox bundle.

Comparing the Three Engines

Chromium Firefox WebKit
UA surface Version token plus sec-ch-ua brand list rv: and Gecko/20100101, no client hints by default Reduced: coarse Version/, "Safari" brand
Automation marker navigator.webdriver plus CDP side effects Marionette sets it; Juggler builds avoid it Playwright's patched build, own markers
Main native sources Skia, ANGLE/GPU process, platform audio Cairo/WebRender, own rasteriser, own audio path CoreGraphics, Metal, CoreAudio
How to patch it JS init script, or a source patch user.js prefs, or a source patch Little; mostly launch-level only
Cost to shape Lowest, because the surface is well documented Medium, mostly configuration Highest, and least reliable

The row that decides most architectures is the last one. Chromium is cheap because everyone has published its internals; WebKit is expensive because almost nobody can, which is also why so few scrapers bother.

Validating a Firefox Bundle

Coherence is a property of a bundle, so validate the bundle rather than each field. The check below loads a stored profile and runs sixteen internal-consistency rules: the UA's rv: against its Firefox/ token, the Gecko date, oscpu against the UA's OS, navigator.platform, the UI language against Accept-Language and the exit IP, header order against the recorded Firefox order, the timezone against the exit country, the core font set, the WebGL graphics API against the claimed OS, and whether the privacy and remote-agent prefs are in a state a plain desktop user would be in.

import re

PROFILE = {
    "general.useragent.override": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0",
    "general.buildID": "20100101",
    "navigator.platform": "Win64",
    "window.oscpu": "Linux x86_64",
    "navigator.language": "en-US",
    "navigator.languages": ["en-US", "en"],
    "intl.accept_languages": "en-US, en;q=0.9",
    "intl.timezone": "Asia/Tokyo",
    "exit_ip_country": "US",
    "privacy.resistFingerprinting": True,
    "marionette.enabled": False,
    "webgl.renderer": "ANGLE (Intel, Mesa Intel(R) UHD Graphics 620, OpenGL 4.6)",
    "fonts": ["Arial", "Calibri", "Segoe UI", "Tahoma", "Times New Roman", "Courier New"],
    "header_order": ["user-agent", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
                     "accept", "accept-language", "accept-encoding", "sec-fetch-dest",
                     "sec-fetch-mode", "sec-fetch-site", "referer"],
}

FIREFOX_ORDER = ["user-agent", "accept", "accept-language", "accept-encoding",
                 "upgrade-insecure-requests", "sec-fetch-dest", "sec-fetch-mode",
                 "sec-fetch-site", "sec-fetch-user", "te", "priority", "connection"]

CORE_FONTS = {"Windows": {"Arial", "Calibri", "Segoe UI", "Tahoma"},
              "macOS": {"Helvetica", "Menlo", "Times New Roman"},
              "Linux": {"DejaVu Sans", "Liberation Sans"}}
ZONES = {"US": "America/New_York", "GB": "Europe/London", "DE": "Europe/Berlin"}


def ua(p):
    return p["general.useragent.override"]


def os_from_ua(text):
    for token, name in (("Windows NT", "Windows"), ("Mac OS", "macOS"), ("Linux", "Linux")):
        if token in text:
            return name
    return "unknown"


def grab(text, pattern):
    m = re.search(pattern, text)
    return m.group(1) if m else None


def oscpu_os(p):
    low = p["window.oscpu"].lower()
    return next((n for k, n in (("windows", "Windows"), ("mac", "macOS"),
                                ("linux", "Linux")) if k in low), "?")


def graphics_os(renderer):
    for api, name in (("Direct3D", "Windows"), ("Metal", "macOS"), ("OpenGL", "Linux")):
        if api in renderer:
            return api, name
    return "unknown", "unknown"


def first(value):
    return value.split(",")[0].strip()


def r_versions(p):
    rv, fx = grab(ua(p), r"rv:(\d+\.\d+)"), grab(ua(p), r"Firefox/(\d+\.\d+)")
    return rv == fx is not None and "Gecko/20100101" in ua(p), "rv={} gecko={}".format(
        rv, "ok" if "Gecko/20100101" in ua(p) else "wrong")


def r_oscpu_os(p):
    a, b = os_from_ua(ua(p)), oscpu_os(p)
    return a == b, "UA says {}, oscpu says {}".format(a, b)


def r_platform(p):
    want = {"Windows": "Win", "macOS": "Mac", "Linux": "Linux"}[os_from_ua(ua(p))]
    return p["navigator.platform"].startswith(want), "{} vs UA {}".format(
        p["navigator.platform"], os_from_ua(ua(p)))


def r_language(p):
    return p["navigator.language"] == first(p["intl.accept_languages"]), "{} vs header {}".format(
        p["navigator.language"], first(p["intl.accept_languages"]))


def r_region(p):
    tag = first(p["intl.accept_languages"]).split("-")[-1].upper()
    return tag == p["exit_ip_country"], "Accept-Language {} vs exit IP {}".format(
        tag, p["exit_ip_country"])


def r_hints(p):
    hints = [h for h in p["header_order"] if h.startswith("sec-ch-")]
    return not hints, "{} chromium hints present".format(len(hints))


def r_order(p):
    if p["header_order"] == FIREFOX_ORDER:
        return True, "exact match"
    i = next(i for i, h in enumerate(p["header_order"]) if h != FIREFOX_ORDER[i])
    return False, "slot {}: {!r} vs {!r}".format(i, p["header_order"][i], FIREFOX_ORDER[i])


def r_timezone(p):
    want = ZONES[p["exit_ip_country"]]
    return p["intl.timezone"] == want, "exit IP {} implies {}".format(p["exit_ip_country"], want)


def r_rfr(p):
    return not p["privacy.resistFingerprinting"], "resistFingerprinting={}".format(
        p["privacy.resistFingerprinting"])


def r_fonts(p):
    missing = sorted(CORE_FONTS[os_from_ua(ua(p))] - set(p["fonts"]))
    return not missing, "missing " + (", ".join(missing) or "nothing")


def r_webgl(p):
    api, implied = graphics_os(p["webgl.renderer"])
    return implied == os_from_ua(ua(p)), "{} API, UA says {}".format(api, os_from_ua(ua(p)))


RULES = [
    ("U1", "rv: matches Firefox/ and Gecko/", r_versions),
    ("U2", "general.buildID is 20100101", lambda p: (p["general.buildID"] == "20100101",
                                                     p["general.buildID"])),
    ("U3", "window.oscpu present", lambda p: (bool(p["window.oscpu"]), repr(p["window.oscpu"]))),
    ("U4", "oscpu OS matches UA OS", r_oscpu_os),
    ("P1", "navigator.platform matches UA", r_platform),
    ("L1", "language matches Accept-Language", r_language),
    ("L2", "languages[0] is the UI language",
     lambda p: (p["navigator.language"] == p["navigator.languages"][0], str(p["navigator.languages"]))),
    ("L3", "Accept-Language region matches exit IP", r_region),
    ("H1", "no Chromium Sec-CH-UA hints", r_hints),
    ("H2", "header order is the Firefox order", r_order),
    ("Z1", "timezone matches the exit IP country", r_timezone),
    ("F1", "resistFingerprinting off by default", r_rfr),
    ("F2", "no bare general.useragent.override",
     lambda p: (not p["general.useragent.override"], "override={} in user.js".format(
         bool(p["general.useragent.override"])))),
    ("F3", "core system fonts present", r_fonts),
    ("G1", "WebGL API matches claimed OS", r_webgl),
    ("D1", "marionette disabled", lambda p: (
        not p["marionette.enabled"], "marionette={}, so no webdriver marker".format(
            p["marionette.enabled"]))),
]

print("firefox profile: {} stored fields, {} consistency rules".format(len(PROFILE), len(RULES)))
print()
print("{:<4} {:<42} {:<7} {}".format("RULE", "CHECK", "STATUS", "DETAIL"))
print("-" * 100)
fails = []
for rid, label, check in RULES:
    ok, detail = check(PROFILE)
    if not ok:
        fails.append((rid, detail))
    print("{:<4} {:<42} {:<7} {}".format(rid, label, "PASS" if ok else "FAIL", detail))
print()
print("{} passed, {} failed".format(len(RULES) - len(fails), len(fails)))
print("not shippable until every FAIL is resolved:")
for rid, detail in fails:
    print("  {} {}".format(rid, detail))
firefox profile: 14 stored fields, 16 consistency rules

RULE CHECK                                      STATUS  DETAIL
----------------------------------------------------------------------------------------------------
U1   rv: matches Firefox/ and Gecko/            PASS    rv=124.0 gecko=ok
U2   general.buildID is 20100101                PASS    20100101
U3   window.oscpu present                       PASS    'Linux x86_64'
U4   oscpu OS matches UA OS                     FAIL    UA says Windows, oscpu says Linux
P1   navigator.platform matches UA              PASS    Win64 vs UA Windows
L1   language matches Accept-Language           PASS    en-US vs header en-US
L2   languages[0] is the UI language            PASS    ['en-US', 'en']
L3   Accept-Language region matches exit IP     PASS    Accept-Language US vs exit IP US
H1   no Chromium Sec-CH-UA hints                FAIL    3 chromium hints present
H2   header order is the Firefox order          FAIL    slot 1: 'sec-ch-ua' vs 'accept'
Z1   timezone matches the exit IP country       FAIL    exit IP US implies America/New_York
F1   resistFingerprinting off by default        FAIL    resistFingerprinting=True
F2   no bare general.useragent.override         FAIL    override=True in user.js
F3   core system fonts present                  PASS    missing nothing
G1   WebGL API matches claimed OS               FAIL    OpenGL API, UA says Windows
D1   marionette disabled                        PASS    marionette=False, so no webdriver marker

9 passed, 7 failed
not shippable until every FAIL is resolved:
  U4 UA says Windows, oscpu says Linux
  H1 3 chromium hints present
  H2 slot 1: 'sec-ch-ua' vs 'accept'
  Z1 exit IP US implies America/New_York
  F1 resistFingerprinting=True
  F2 override=True in user.js
  G1 OpenGL API, UA says Windows

Seven failures in a bundle that looks plausible on a spreadsheet. U4 and G1 are the same defect seen twice: the operating system was never changed when the UA was. H1 and H2 are the fingerprint of copying a Chromium request template into a Firefox profile. The two Firefox-specific ones -- F1 and F2 -- are the mistakes described above, and they are the two a scanner will find first.

Checklist

Before a Firefox profile goes into production:

  • The UA is untouched, or the whole bundle around it was rebuilt. No general.useragent.override sitting in user.js next to fifteen fields that still say Firefox.
  • rv:, Firefox/, the frozen Gecko 20100101 token and the build id all agree, because a mismatched rv: is a version that never shipped.
  • window.oscpu and navigator.platform name the same OS as the UA, and the font list is that OS's core set rather than a copy of another platform's.
  • Header order is Firefox order and carries no Sec-CH-UA headers, because Firefox does not send them.
  • privacy.resistFingerprinting and reduceTimerPrecision are off, unless you are modelling a privacy-hardened user completely: letterboxing, standardised UA and coarsened timers together.
  • Marionette is not enabled. If you use Playwright's Firefox you have measured that specific patched build rather than assuming stock Gecko.
  • The timezone and Accept-Language region match the exit IP's country, and the whole bundle validates with zero failures before the profile is handed to the pool.

The Legitimate Route

Choosing a browser engine because it fits your authorised test plan, or because you are collecting data you have a right to collect, is straightforward engineering. Choosing one because it defeats a specific site's paywall, its login boundary or its stated terms is not something this course covers, and no amount of fingerprint coherence changes that. When a target offers an official API, a partner feed, a data licence or a documented rate limit, that route is both lawful and far cheaper than maintaining a hand-tuned Gecko profile; the official APIs and alternative data lesson is the better first move, and this page is the fallback for the cases where it genuinely does not exist.