Camoufox in Production: Why I Run a Firefox Stealth Browser, Not Playwright Chromium
I built a Camoufox-based browser engine that scores zero detection where Chromium fails twelve checks, and run it across a multi-site empire for legitimate SEO operations. Here is why CDP is the real tell, how the profile-per-platform model works, and the install gotcha that wastes your first hour.

I run an engine built on Camoufox across a multi-site empire, and the single test that converted me away from Playwright Chromium was bot.sannysoft.com. Side by side, on the same box, with the same automation code: stock Chrome failed twelve of the detection checks. The Camoufox build failed zero. That gap is the whole argument, and the reason it exists is not what most stealth tutorials tell you.
The real tell is CDP, not the WebGL string
Every Chromium-based automation tool, Playwright and Puppeteer included, drives the browser over the Chrome DevTools Protocol. CDP is a remote debugging channel, and anti-bot vendors have learned to fingerprint its presence directly. You can patch navigator.webdriver, spoof your WebGL vendor, randomise your canvas, and inject a hundred stealth shims, and a sophisticated detector can still notice that a debugging protocol is attached to the page. A pile of JavaScript stealth patches plateaus for exactly this reason: you are decorating a browser that is announcing itself at the protocol layer.
Camoufox sidesteps the entire category. It is a patched build of Firefox, and Firefox does not speak CDP. The hardening happens inside the browser binary, not as a layer of JS evals a detector can catch mid-execution. My own first anti-detection stack was sixteen JavaScript patches plus behavioural layers on top of a Chromium CDP session. It worked, but the explicit next step in my build notes was "Camoufox engine, eliminates CDP protocol detection." Moving the engine off Chromium did more for the detection score than any single JS patch.
Use it for the right reasons: QA against your own bot-sensitive flows, accessibility and load testing, and data collection on platforms where your access is legitimate and you stay inside their rules. It is not a license to ignore terms, robots directives, login walls, or rate limits. I will come back to that line, because it is load-bearing.
Install on Python 3.12, including the binary fetch
Camoufox is two things: a Python package, and a patched Firefox binary it manages separately. The most common wasted hour is installing the package and forgetting the binary, then watching the first launch fail with no browser to drive.
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install "camoufox[geoip]"
camoufox fetch
python -m pip freeze | grep -i camoufox
The [geoip] extra pulls a GeoIP database so the browser can correlate its profile against proxy geography, which matters the moment you route through an exit node in another country. The camoufox fetch line is the one people skip: it downloads the patched Firefox build into a local cache, and without it the package has nothing to launch. The engine I run was pinned at Camoufox 0.4.11 against browser 135.0.1-beta.24. Pin yours the same way after testing, because both the wrapper and the underlying Firefox move quickly:
python -m pip install "camoufox[geoip]==0.4.11"
On a modern Ubuntu box you will hit PEP 668 if you try to install against system Python, which returns error: externally-managed-environment. Use the venv as shown. Do not reach for --break-system-packages. The Python environment traps on Ubuntu 24.04 cover this error and the stdlib name-collision bug that bites the same projects.
First sanity check, against a fingerprint page only
Confirm the install works against a harmless target before you point it anywhere real. You are separating browser setup bugs from target-site behaviour, and that separation saves hours later.
from pathlib import Path
from camoufox.sync_api import Camoufox
def main() -> None:
with Camoufox(headless=True) as browser:
page = browser.new_page()
page.goto("https://bot.sannysoft.com", wait_until="domcontentloaded")
Path("detection-check.png").write_bytes(page.screenshot(full_page=True))
print(page.title())
if __name__ == "__main__":
main()
bot.sannysoft.com is the page I benchmark against because it renders a clear pass or fail per check. Screenshot it on a fresh install, screenshot it after every Camoufox upgrade, and keep the images. A stealth browser is a dependency, and dependencies regress.
The async API is the one you actually ship
The sync API is fine for a sanity check. Real pipelines are async, because you are feeding a queue or a worker, not running a script by hand.
import asyncio
from camoufox.async_api import Camoufox
async def main() -> None:
async with Camoufox(headless=True) as browser:
page = await browser.new_page()
await page.goto("https://example.com", wait_until="domcontentloaded")
print(await page.title())
asyncio.run(main())
The wrapper I run is an async Camoufox helper with one rule baked in: a persistent profile per platform, never one shared profile across everything. This is the gotcha that bites people graduating from one-off scripts to running across many sites. If a single browser identity touches twenty platforms, those platforms can correlate the same fingerprint across all of them, linking activity that should stay separate. One persistent profile per platform keeps each session internally consistent over time, which reads far more human than a fresh random fingerprint on every launch. Randomising everything every time is itself a signal.
What to verify in the fingerprint
Camoufox aims for internal consistency, and consistency matters more than any single spoofed value. Inspect these surfaces yourself rather than trusting fixed claims:
| Surface | What to inspect | Production note |
|---|---|---|
navigator.webdriver |
Must not announce automation | Re-verify on every pinned version |
| WebGL vendor and renderer | Plausible together, not a mismatched pair | Do not mix random profiles per launch |
| Canvas | Stable within a session, not identical across all sessions | Watch for drift after upgrades |
| Fonts | Match the platform profile | Measure, do not assume a font count |
| Timezone and locale | Match proxy geography | Keep proxy, locale, and timezone aligned |
When I repaired a fingerprint validator against CreepJS, the lesson was that the old single "trust score" gauge had been replaced by three separate ratings: headless, like-headless, and stealth, each scored zero to a hundred, plus a count of detected lies. A browser is only as clean as its worst rating, because each is a different lens on the same exposure. Score against the worst signal, not an average. An average hides the one check that gives you away.
from camoufox.sync_api import Camoufox
SCRIPT = """() => {
const canvas = document.createElement("canvas");
const gl = canvas.getContext("webgl");
const ext = gl ? gl.getExtension("WEBGL_debug_renderer_info") : null;
return {
webdriver: navigator.webdriver,
platform: navigator.platform,
languages: navigator.languages,
webglVendor: gl && ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : null,
webglRenderer: gl && ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : null
};
}"""
with Camoufox(headless=True) as browser:
page = browser.new_page()
page.goto("https://example.com")
print(page.evaluate(SCRIPT))
Log this on every upgrade. The day a value shifts is the day to find out before a job does.
Proxies change the path, not the legitimacy
A proxy only changes your network route. If your browser profile says one country and your IP geography says another, you have made detection easier, not harder. Read credentials from the environment, fail loudly when they are missing, and rotate at the job boundary, never mid-page.
import os
from camoufox.sync_api import Camoufox
proxy = {
"server": os.environ["PROXY_SERVER"],
"username": os.environ.get("PROXY_USERNAME"),
"password": os.environ.get("PROXY_PASSWORD"),
}
with Camoufox(proxy=proxy, geoip=True, headless=True) as browser:
page = browser.new_page()
page.goto("https://httpbin.org/ip", wait_until="domcontentloaded")
print(page.inner_text("body"))
Passing geoip=True lets Camoufox align its locale and timezone hints to the exit IP, which is the point of installing the GeoIP extra. Keep one proxy identity per coherent session.
How I actually use it, and the line I do not cross
This is the part most tutorials cannot write, because they have not run it at scale. My links operation uses Camoufox as the browsing layer for legitimate SEO work at a velocity competitors cannot match by hand: submitting to a curated allowlist of real, moderated directories, rescuing competitor dead links with Wayback proof and a relevant replacement, reclaiming unlinked brand mentions through genuine outreach, and seeding posts in communities where I hold earned reputation. The speed is the edge, because Google's link classifier evaluates link networks relationally and in real time, so the only durable strategy is to do legitimate things faster than a manual team could.
The fence is absolute, and it is what keeps the speed safe. No private blog networks. No comment spam, forum spam, or profile-link padding. No Web 2.0 farms or link wheels. No submitting to an unvetted directory. And the hard one, stated plainly: I do not brute-force Cloudflare Turnstile, hCaptcha, or any challenge a real human would have to solve. If a person cannot pass it, my automation does not pass it either. Outreach never instructs anyone on anchor text. Stealth here means not tripping crude bot walls on platforms where I have a legitimate reason to be, not bypassing human verification. The moment a tool crosses into deceiving a person, it stops being SEO and becomes the thing that gets a whole empire of sites penalised at once.
Waiting and retries that observe, not pretend
Wait for the application state you need, not a blind sleep, so a failure is visible instead of a silently empty result.
import time
from camoufox.sync_api import Camoufox, TimeoutError
def fetch_html(url: str, attempts: int = 3) -> str:
last_error: Exception | None = None
for attempt in range(1, attempts + 1):
try:
with Camoufox(headless=True) as browser:
page = browser.new_page()
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
page.wait_for_selector("main", timeout=15_000)
return page.content()
except TimeoutError as exc:
last_error = exc
time.sleep(min(2 ** attempt, 10))
raise RuntimeError(f"Failed to fetch {url} after {attempts} attempts") from last_error
Retries handle network blips and transient errors. They never hammer a target that is actively refusing you, because that is both rude and a fast route to a hard block.
Quick takeaways
- Camoufox beats Playwright Chromium because Firefox does not speak CDP, the debugging protocol anti-bot vendors fingerprint directly. The hardening is in the binary, not patched-on JS.
- I measured zero detection on
bot.sannysoft.comwhere stock Chrome failed twelve checks. Benchmark it yourself and re-screenshot after every upgrade. - Install with
pip install "camoufox[geoip]"thencamoufox fetch. The binary fetch is the step people forget, and the package cannot launch without it. - Run one persistent profile per platform, not a shared identity, and align proxy, timezone, and locale. Randomising everything per launch is its own tell.
- Score your fingerprint against the worst signal, never an average. Modern detectors expose multiple ratings, and the most-detected one defines your exposure.
- The ethics are the architecture. Legitimate platforms only, no challenge brute-forcing, no spam networks. Speed inside the rules is a moat; speed outside them is a sitewide penalty.
Related
More AI Coding

Building a Custom MCP Server in Python: Claude Reaches My Stack
Claude Code is sharp until it hits the edge of your machine and your private tools. I wrote three small MCP servers in Python to close that gap. Here is the real pattern, the real gotcha that bit me, and what it costs.

Claude Code Subagents in Practice: Fork Flag, Cache Leak, Worktree Trap
Fanning out subagents in Claude Code looks free until you hit the cap or your forks clobber each other's commits. These are the real fixes I learned running fanouts: the fork env flag that shares the parent's cache, the WebFetch cache leak, and the worktree pattern for parallel writers.

I Gave My AI Agents a Memory With SQLite FTS5 (No Vector DB)
Most agent-memory setups reach for Pinecone or pgvector by reflex. I put 2000+ markdown files behind SQLite FTS5 with BM25 ranking, and my agents now answer their own 'who is X' questions in under a second for zero tokens. Here is the schema, the query, and the one place lexical search loses.