Camofox is a stealth browser for AI agents, and by default it tells every page it visits that it is sitting in San Francisco. Your traffic is not. Unless you configure a proxy, server.js pins the browser context to en-US, America/Los_Angeles, and the coordinates of downtown San Francisco, while every request still exits from your own IP. The browser contradicts its own network location, which is the single thing an anti-detection browser must never do.
This is the setup I run instead: Camofox in Docker on one host, bound to loopback, wired into Claude Code over MCP. Every line of configuration below is there because a default was wrong. I will give you the files first and the reasoning after, so you can copy the config now and argue with me later.
Versions matter for this piece. Everything here is measured against the server image 1.13.0, the MCP adapter 1.13.1, and master as of 4 August 2026. Measurements marked as mine were taken on that setup, on linux/arm64, and I have not verified them anywhere else.
What the default actually does
Camofox wraps Camoufox, a patched Firefox that generates a coherent fingerprint at launch. Camoufox has a geoip option that derives locale, timezone, and geolocation from the exit IP. That is the correct behaviour, and you cannot have it, because it is gated behind the proxy:
geoip: !!launchProxy,
No proxy, no geoip. The branch that runs in its place asserts a location instead:
// When geoip is active (proxy configured), camoufox auto-configures
// locale/timezone/geolocation from the proxy IP. Without proxy, use defaults.
if (!CONFIG.proxy.host) {
contextOptions.locale = 'en-US';
contextOptions.timezoneId = 'America/Los_Angeles';
contextOptions.geolocation = { latitude: 37.7749, longitude: -122.4194 };
}
Read the comment and then the code. The comment says "use defaults", which sounds harmless. What it does is assert a specific false location on a connection that will visibly originate somewhere else. A page needs one line of JavaScript to compare Intl.DateTimeFormat().resolvedOptions().timeZone against the country its own server logged for your IP. The check costs nothing and catches the lazy half of automated traffic. I assume commercial anti-bot vendors run it, and I have not audited one to confirm.
So a fresh Camofox deployment without a proxy is worse than a stock browser on the same machine.
I am not the first to notice. PR #3109 proposed a stealth plugin covering exactly this in May 2026. It is still open, still unmerged, and master carries the same three lines today. In July another user showed up on the thread asking only how to change the timezone. I will come back to what the maintainer said.
The Compose file
The setup is a Compose file, a JSON config, and one small plugin. Start with the deployment:
services:
camofox:
image: ghcr.io/jo-inc/camofox-browser:1.13.0@sha256:<arm64-digest>
container_name: camofox
ports:
- "127.0.0.1:9377:9377"
volumes:
- ./data:/root/.camofox
# The plugin loader only scans /app/plugins, and it treats the
# `plugins` object in camofox.config.json as an allowlist. Both
# mounts are required or the plugin is ignored silently.
- ./plugins/geo:/app/plugins/geo:ro
- ./camofox.config.json:/app/camofox.config.json:ro
environment:
CAMOFOX_BIND_HOST: 0.0.0.0
CAMOFOX_PORT: "9377"
CAMOFOX_CRASH_REPORT_ENABLED: "false"
MAX_OLD_SPACE_SIZE: "512"
# Must match plugins.geo.timezone below. Reason in the next section.
TZ: ${GEO_TZ}
CAMOFOX_GEO_LOCALE: ${GEO_LOCALE}
CAMOFOX_GEO_TZ: ${GEO_TZ}
CAMOFOX_GEO_LAT: ${GEO_LAT}
CAMOFOX_GEO_LON: ${GEO_LON}
shm_size: 2gb
restart: unless-stopped
With an .env beside it. These four values are an example. Set them to match wherever your traffic actually leaves from:
GEO_LOCALE=en-GB
GEO_TZ=Europe/London
GEO_LAT=51.5072
GEO_LON=-0.1276
Four things in that file are deliberate.
127.0.0.1:9377:9377 publishes to loopback only. Camofox has an access-key mechanism and it is off by default, so a plain 9377:9377 hands anyone on your network a browser that will fetch any URL they name, from your IP, with your cookies. The port binding is the whole of the access control here, which is enough for a single-host agent tool and nothing else.
The digest pin next to the tag guards the fingerprint. Camoufox generates the fingerprint, so a silent image change is a silent fingerprint change, and the failure mode is a session that worked yesterday getting challenged today with nothing in your own config to explain it.
shm_size: 2gb because Firefox in a container with Docker's 64 MB default /dev/shm crashes on heavy pages, and the crash surfaces as a timeout rather than an out-of-memory error.
Both plugin mounts are read-only, which means the image is untouched and deleting three lines reverts everything in this article to upstream behaviour. Worth keeping true. You will want a clean comparison the first time a site starts blocking you.
./data holds profiles, cookies, local storage, and traces for every session. It is real browser state and real logins. Keep it out of version control, and out of any screenshot or bug report.
Then the config, which is also the plugin allowlist:
{
"plugins": {
"persistence": { "enabled": true },
"vnc": { "enabled": false },
"geo": {
"enabled": true,
"locale": "en-GB",
"timezone": "Europe/London",
"latitude": 51.5072,
"longitude": -0.1276
}
}
}
Where a plugin can and cannot reach
Before the plugin code, the constraint that shapes it. Camofox exposes hooks at two points, and they are far apart in capability:
By the time browser:launching fires, the fingerprint has already been generated and serialised, so os, UA family, WebGL renderer, and screen dimensions are fixed. No plugin changes them. Patch the image or accept them.
session:creating fires immediately before newContext(), which is the exact moment upstream writes its San Francisco defaults, and therefore the moment to overwrite them:
export async function register(app, ctx, pluginConfig = {}) {
const { events, log } = ctx;
// If a proxy is ever configured, camoufox's geoip derives location from
// the exit IP. Overriding it with a fixed profile would reintroduce the
// mismatch this plugin exists to remove.
if (ctx.config?.proxy?.host) {
log('info', 'geo plugin inactive: deferring to camoufox geoip');
return;
}
// ... read locale / timezone / lat / lon, validate each one ...
events.on('session:creating', ({ contextOptions }) => {
Object.assign(contextOptions, applied);
});
}
The early return means adding a proxy later does not quietly break the thing the plugin was built to fix. And validation happens before anything reaches Playwright: Intl.getCanonicalLocales for the locale, a throwaway Intl.DateTimeFormat for the IANA timezone, plus a bounds check on the coordinates. A malformed timezone string that reaches newContext() throws at session creation, so every tab fails and the log points at Playwright rather than at your typo.
The part that surprised me
Setting timezoneId on the context covers only part of the page.
Playwright's per-context locale and timezoneId reach the main thread and dedicated Workers. In my testing they do not reach ServiceWorker or SharedWorker scopes, which fall through to the container's system timezone. Without TZ in the environment, the main thread reported Europe/London and a ServiceWorker in the same page reported UTC.
That is a worse state than the bug I set out to fix. An unusual timezone proves little. Two timezones inside one page is positive evidence of emulation, because no real browser does that. Hence TZ in the Compose file, and hence a warning in the plugin when the two values disagree, since a silent version of this failure is expensive.
Then I made the same mistake in the other direction. navigator.languages and Accept-Language on a real browser usually look like a weighted list:
en-GB,en;q=0.9,fr;q=0.8
So I set intl.accept_languages to a list like that, which is more realistic than the bare locale by any reasonable measure. It made things worse. Playwright's per-context locale forces navigator.languages to a single entry on the main thread, and the browser-level preference is what worker scopes read, so the realistic list produced a main-thread-versus-worker disagreement in the same way the timezone did. I reverted to the bare locale.
A consistent but uncommon value beats a realistic but inconsistent one. A one-entry navigator.languages is merely unusual, and plenty of real browsers produce it. Two different answers to the same question, from two scopes of the same page, is a positive detection. A checklist of stealth tweaks tends to make a browser easier to spot rather than harder, because each item looks more human on its own.
Wiring it into Claude Code
The MCP adapter is a thin stdio process that forwards REST calls. Pin it to match the server image:
npm install -g @askjo/camofox-browser-mcp@1.13.1
claude mcp add camofox-browser -s user \
--env CAMOFOX_BASE_URL=http://127.0.0.1:9377 \
-- camofox-browser-mcp
Then restart Claude Code, because user-scope MCP config is read once at startup.
Note what the adapter covers. It defines 11 tools. The server's openapi.json documents 35 paths. Missing from MCP entirely: /act, /extract, /press, /wait, /viewport, /links, /images, /upload, /downloads. Your agent cannot press Escape, cannot wait on a selector, and cannot set a viewport size, and nothing in the tool list says so.
Calling those routes directly needs the adapter's session id, which is where it gets silly. The adapter picks one at startup:
const USER_ID = process.env.CAMOFOX_USER_ID || `mcp-${randomUUID()}`;
No tool returns it. No endpoint enumerates sessions. GET /tabs requires the exact userId and answers with an empty list for anything else, so you cannot even probe for it. I recover it by scraping candidate ids out of the container log, newest first, then confirming a candidate by asking whether it actually owns the target tab. Validating rather than trusting the newest line is what keeps that correct when two agent sessions share the server.
Leaving CAMOFOX_USER_ID unset is a deliberate trade. Each session gets a random id, so cookies and local storage are partitioned per session and two agents cannot correlate to one stored identity. Cookie linkage identifies a browser far more reliably than any fingerprint attribute, so this matters more than most of the fingerprint work above.
Logins do not survive a session and have to be redone. Set CAMOFOX_USER_ID to a fixed string if you would rather keep the logins, and know what you traded for them.
The MCP layer cannot press a key
The best example of the gap is typing. camofox_type accepts tabId, ref, selector, text, and pressEnter. There is no mode, so every call lands on the server default:
const { userId, ref, selector, text, mode = 'fill', delay = 30, ... } = req.body;
mode: 'fill' calls Playwright's fill(), which sets value and emits one input event. Zero keydown, zero keypress, zero keyup. Two consequences. React onKeyDown handlers, search-as-you-type boxes, and contenteditable fields see nothing at all, so the form looks filled and behaves as though it is empty. And a login form completed with no keyboard events in front of it is its own bot signal.
The server can do better. mode: 'keyboard' focuses the element and calls page.keyboard.type(text, { delay }), which produces real per-character events with isTrusted: true and correct code values. On my setup, --delay 40 measured a mean interval near 52 ms once per-character overhead is counted, so treat the parameter as a floor.
Reaching it means bypassing MCP and posting to /tabs/<tabId>/type yourself, with mode and the session id from the log. I wrapped that in a shell script and told the agent, in a skill file, to prefer the script wherever behaviour might be scored and to use camofox_type for bulk fields where only the final value matters. Fill mode is faster and it is the right call most of the time.
In the same route, clear appears in the OpenAPI schema for /tabs/{tabId}/type and the handler never destructures it, so it is accepted and ignored. Meanwhile mode and delay are absent from that schema entirely, despite being the two parameters that decide what the endpoint does. And keyboard mode focuses without selecting, so typing appends. Replacing a pre-filled field takes an empty fill() first.
camofox_click is humanized by default and I measured roughly 120 interpolated mousemove events per click, with a ~21 ms button press. That is careful work by someone who understood the threat. Typing through the same tool set emits a single synthetic input event. One product, two opposite standards of realism, and the fingerprint work is the part that gets the attention.
What I left alone
Three known gaps, all deliberate.
WebRTC still exposes the real IP through STUN srflx candidates. I could block it. I did not, because a server-wide WebRTC block is itself an unusual signal, and with no proxy the address is already visible over TCP on every request. Suppressing ICE would cost entropy and hide nothing. The maintainer's objection to PR #3109 said the same thing, and they are right.
Fonts resolve 2 of 51 in the detection suites I tested. That is Camoufox's bundled Linux font profile, not a container defect, and installing system fonts inside the image changes nothing a page can observe.
Screen dimensions and GPU strings re-roll on every browser launch, and one global browser instance serves every session. So all your sessions share one fingerprint and that fingerprint changes when the container restarts. An agent doing short independent tasks survives this. Anything that needs to look like a returning visitor does not, and no plugin fixes it from where the hooks are.
The review I did not ask for
I built this before reading the PR thread properly. When I went back, the maintainer's requested changes were: skip the override when a proxy is configured, validate locale, IANA timezone, and coordinate bounds before creating a context, keep the plugin opt-in, preserve proxy-derived behaviour by default, and document the WebRTC trade-off.
Five of those describe what my plugin already does, which was reassuring and slightly deflating. The two I do not have are the ones that keep the PR open: configuration reads centralised into lib/config.js, and a session-scoped API instead of one global location profile applied to every user. Both are correct and neither is optional for upstream, because a server-wide location is wrong the moment two users want different ones.
Which is the honest limit on all of this. What I run is a single-tenant workaround for a single-tenant problem, on a host where every session should claim the same location because every session leaves from the same IP. It is about 75 lines of code that stop the browser from lying about where it is. Upstream needs the harder version, and until someone writes it, master will keep telling every page it is in San Francisco.
AKENA is an engineering studio working across AI, blockchain, and software infrastructure. We build the systems AI agents run on: agent-facing RPC and MCP endpoints, on-chain data pipelines, and the payment, identity, and metering layers in front of them. If you're running agent infrastructure and you want to know what its defaults claim on your behalf, we should talk.

