Hardening Firefox and WebKit Profiles
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.overridesitting inuser.jsnext to fifteen fields that still say Firefox. rv:,Firefox/, the frozen Gecko20100101token and the build id all agree, because a mismatchedrv:is a version that never shipped.window.oscpuandnavigator.platformname 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-UAheaders, because Firefox does not send them. privacy.resistFingerprintingandreduceTimerPrecisionare 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-Languageregion 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.