Writing Custom Extensions for Anti-Detect Browsers: Extending Functionality
Ready to protect your online identity?
Choose your plan and start running undetectable browser profiles today.
Anti-detect browsers are powerful tools for managing multiple isolated browser identities, but their built-in feature sets inevitably fall short of every user’s specific workflow. Some teams need to auto-fill complex forms across dozens of profiles, others want to inject custom JavaScript into every page for data extraction, and some require per-profile dashboards that aggregate information from multiple tabs. Browser extensions — the same WebExtension API used by Chrome and Firefox add-ons — provide a clean, maintainable way to add this functionality without relying on external automation frameworks like Selenium or Playwright.
This article covers the architecture of browser extensions for anti-detect environments, practical examples of common automation tasks, and the technical considerations that make extension development in isolated profiles different from standard browser extension development.
Why Extensions Instead of Selenium or Playwright
External automation tools like Selenium, Playwright, and Puppeteer connect to the browser through debugging protocols (CDP for Chromium, Juggler for Firefox). This approach has three fundamental problems in anti-detect contexts:
Detection surface: Websites actively scan for automation framework artifacts. The navigator.webdriver flag, the presence of CDP endpoints, injected Playwright-specific JavaScript objects, and even the timing patterns of programmatic interactions all serve as signals. While anti-detect browsers suppress some of these, the debugging protocol connection itself creates network-level artifacts that sophisticated anti-bot systems can detect.
Resource overhead: Each Selenium/Playwright session requires a separate controller process. At scale (50+ profiles), the controller processes themselves consume significant CPU and RAM, and the IPC overhead between the controller and browser adds latency.
Isolation boundaries: Extensions run inside the browser’s extension sandbox, which is part of the profile’s isolated environment. They share the profile’s cookies, local storage, and network configuration (including proxy). Selenium scripts run outside the browser and must be explicitly configured to match each profile’s network setup.
Extensions avoid all three problems. They execute within the browser’s existing process tree, have no external debugging connections, and automatically inherit the profile’s isolation boundary.
Extension Architecture for Anti-Detect Profiles
A WebExtension for anti-detect use consists of the standard components:
my-extension/
├── manifest.json # Extension manifest (v2 or v3)
├── background.js # Service worker (MV3) or background script (MV2)
├── content.js # Content script injected into web pages
├── popup.html # Popup UI (optional)
├── popup.js # Popup logic
├── options.html # Settings page (optional)
├── options.js # Settings logic
└── icons/
├── icon-16.png
├── icon-48.png
└── icon-128.png
Manifest Version Considerations
Firefox-based anti-detect browsers (including Camoufox-derived ones like Santiago) support both Manifest V2 and V3. Chromium-based anti-detect browsers are transitioning to MV3-only. For maximum compatibility, target MV2 with a migration path to MV3:
{
"manifest_version": 2,
"name": "Profile Automator",
"version": "1.0.0",
"description": "Automates common tasks within anti-detect profiles",
"permissions": [
"activeTab",
"storage",
"tabs",
"webRequest",
"webRequestBlocking",
"<all_urls>"
],
"background": {
"scripts": ["background.js"],
"persistent": false
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"browser_action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png"
}
}
}
The <all_urls> permission is broad but necessary for extensions that need to operate across arbitrary websites. In an anti-detect context, this is typically acceptable because the extension is installed in a controlled profile, not distributed to end users.
Example 1: Automated Form Filling Without Selenium
One of the most common automation needs is filling forms across websites — registration forms, checkout flows, profile update pages. The following content script detects forms on the page and fills them with per-profile data stored in the extension’s local storage.
// content.js — Automated form filler
(async function () {
'use strict';
// Get profile-specific data from extension storage
const data = await browser.storage.local.get('formData');
if (!data.formData) return;
const formData = data.formData;
// Wait for DOM to be fully interactive
await waitForReady();
// Field mapping: common field identifiers → data keys
const fieldMap = {
// Name fields
'first_name|fname|given-name|firstName': 'firstName',
'last_name|lname|family-name|lastName|surname': 'lastName',
'full_name|name|fullName': 'fullName',
// Contact fields
'email|e-mail|emailAddress': 'email',
'phone|tel|telephone|mobile|phoneNumber': 'phone',
// Address fields
'address|street|address1|streetAddress': 'address',
'city|locality': 'city',
'state|region|province': 'state',
'zip|postal|postalCode|zipCode': 'zip',
'country': 'country',
};
function findFormFields() {
const fields = [];
const inputs = document.querySelectorAll(
'input[type="text"], input[type="email"], input[type="tel"], ' +
'input[type="number"], input:not([type]), textarea, select'
);
for (const input of inputs) {
const identifiers = [
input.name,
input.id,
input.getAttribute('autocomplete'),
input.placeholder,
input.getAttribute('aria-label'),
]
.filter(Boolean)
.join('|')
.toLowerCase();
for (const [patterns, dataKey] of Object.entries(fieldMap)) {
if (patterns.split('|').some((p) => identifiers.includes(p))) {
fields.push({ element: input, dataKey });
break;
}
}
}
return fields;
}
function simulateHumanInput(element, value) {
// Focus the element
element.focus();
element.dispatchEvent(new Event('focus', { bubbles: true }));
// Clear existing value
element.value = '';
element.dispatchEvent(new Event('input', { bubbles: true }));
// Type character by character with random delays
let i = 0;
function typeNext() {
if (i < value.length) {
element.value += value[i];
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(
new KeyboardEvent('keydown', {
key: value[i],
bubbles: true,
})
);
element.dispatchEvent(
new KeyboardEvent('keyup', {
key: value[i],
bubbles: true,
})
);
i++;
setTimeout(typeNext, 30 + Math.random() * 70);
} else {
// Trigger change and blur after typing completes
element.dispatchEvent(new Event('change', { bubbles: true }));
element.dispatchEvent(new Event('blur', { bubbles: true }));
}
}
typeNext();
}
// Find and fill fields
const fields = findFormFields();
for (const { element, dataKey } of fields) {
const value = formData[dataKey];
if (value && !element.value) {
// Delay between fields to simulate human behavior
await new Promise((r) =>
setTimeout(r, 200 + Math.random() * 500)
);
simulateHumanInput(element, value);
}
}
function waitForReady() {
return new Promise((resolve) => {
if (document.readyState === 'complete') {
resolve();
} else {
window.addEventListener('load', resolve);
}
});
}
})();
The simulateHumanInput function is critical: instead of directly setting element.value, it dispatches the full sequence of keyboard events that a real user would generate. Many modern web applications (especially React and Vue apps) listen for input events rather than reading .value on submit, and some anti-bot systems track event sequences to detect automation.
Per-Profile Form Data Configuration
The popup UI provides a way to configure the form data for each profile:
<!-- popup.html -->
<!DOCTYPE html>
<html>
<head>
<style>
body { width: 320px; padding: 12px; font-family: system-ui; }
.field { margin-bottom: 8px; }
.field label { display: block; font-size: 12px; margin-bottom: 2px; color: #666; }
.field input { width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px; }
button { width: 100%; padding: 8px; background: #2563eb; color: white;
border: none; border-radius: 4px; cursor: pointer; margin-top: 8px; }
button:hover { background: #1d4ed8; }
.status { font-size: 12px; color: #16a34a; text-align: center; margin-top: 8px; }
</style>
</head>
<body>
<h3>Profile Form Data</h3>
<div class="field">
<label>First Name</label>
<input id="firstName" type="text">
</div>
<div class="field">
<label>Last Name</label>
<input id="lastName" type="text">
</div>
<div class="field">
<label>Email</label>
<input id="email" type="text">
</div>
<div class="field">
<label>Phone</label>
<input id="phone" type="text">
</div>
<div class="field">
<label>Address</label>
<input id="address" type="text">
</div>
<div class="field">
<label>City</label>
<input id="city" type="text">
</div>
<div class="field">
<label>Country</label>
<input id="country" type="text">
</div>
<button id="save">Save</button>
<div id="status" class="status"></div>
<script src="popup.js"></script>
</body>
</html>
// popup.js
const fields = [
'firstName', 'lastName', 'email', 'phone',
'address', 'city', 'country',
];
// Load saved data
browser.storage.local.get('formData').then(({ formData }) => {
if (formData) {
for (const field of fields) {
const input = document.getElementById(field);
if (input && formData[field]) {
input.value = formData[field];
}
}
}
});
// Save data
document.getElementById('save').addEventListener('click', () => {
const formData = {};
for (const field of fields) {
formData[field] = document.getElementById(field).value;
}
browser.storage.local.set({ formData }).then(() => {
document.getElementById('status').textContent = 'Saved!';
setTimeout(() => {
document.getElementById('status').textContent = '';
}, 2000);
});
});
Since browser.storage.local is isolated per profile in an anti-detect browser, each profile has its own form data — no cross-contamination between identities.
Example 2: Page Element Interaction and Data Extraction
For workflows that require extracting data from web pages (prices, product information, account balances), a content script with messaging to the background script provides a clean architecture:
// content.js — Data extraction with page interaction
(function () {
'use strict';
// Listen for extraction requests from the background script
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'extract') {
const result = extractPageData(message.selectors);
sendResponse(result);
}
if (message.action === 'click') {
const element = document.querySelector(message.selector);
if (element) {
element.click();
sendResponse({ success: true });
} else {
sendResponse({ success: false, error: 'Element not found' });
}
}
if (message.action === 'waitAndExtract') {
waitForElement(message.selector, message.timeout || 5000)
.then((element) => {
sendResponse({
success: true,
text: element.textContent.trim(),
html: element.innerHTML,
});
})
.catch((err) => {
sendResponse({ success: false, error: err.message });
});
return true; // Keep the message channel open for async response
}
return true;
});
function extractPageData(selectors) {
const result = {};
for (const [key, selector] of Object.entries(selectors)) {
const element = document.querySelector(selector);
if (element) {
result[key] = {
text: element.textContent.trim(),
href: element.href || null,
src: element.src || null,
value: element.value || null,
};
} else {
result[key] = null;
}
}
return result;
}
function waitForElement(selector, timeout) {
return new Promise((resolve, reject) => {
const element = document.querySelector(selector);
if (element) {
resolve(element);
return;
}
const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) {
observer.disconnect();
resolve(el);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
setTimeout(() => {
observer.disconnect();
reject(new Error(`Element ${selector} not found within ${timeout}ms`));
}, timeout);
});
}
})();
The background script orchestrates multi-step interactions:
// background.js — Orchestrate page interactions
async function runWorkflow(tabId, steps) {
const results = [];
for (const step of steps) {
switch (step.type) {
case 'navigate':
await browser.tabs.update(tabId, { url: step.url });
await waitForTabLoad(tabId);
break;
case 'click':
const clickResult = await browser.tabs.sendMessage(tabId, {
action: 'click',
selector: step.selector,
});
results.push({ step: step.name, ...clickResult });
break;
case 'extract':
const extractResult = await browser.tabs.sendMessage(tabId, {
action: 'extract',
selectors: step.selectors,
});
results.push({ step: step.name, data: extractResult });
break;
case 'waitAndExtract':
const waitResult = await browser.tabs.sendMessage(tabId, {
action: 'waitAndExtract',
selector: step.selector,
timeout: step.timeout,
});
results.push({ step: step.name, ...waitResult });
break;
case 'delay':
await new Promise((r) => setTimeout(r, step.ms));
break;
}
}
return results;
}
function waitForTabLoad(tabId) {
return new Promise((resolve) => {
function listener(id, changeInfo) {
if (id === tabId && changeInfo.status === 'complete') {
browser.tabs.onUpdated.removeListener(listener);
// Extra delay for JS-heavy pages
setTimeout(resolve, 1000);
}
}
browser.tabs.onUpdated.addListener(listener);
});
}
// Example: check account balance on a banking site
async function checkBalance(tabId) {
return runWorkflow(tabId, [
{ type: 'navigate', url: 'https://bank.example.com/dashboard' },
{
type: 'waitAndExtract',
name: 'balance',
selector: '[data-testid="account-balance"]',
timeout: 10000,
},
{
type: 'extract',
name: 'details',
selectors: {
accountName: '.account-name',
lastTransaction: '.last-transaction .amount',
currency: '.currency-indicator',
},
},
]);
}
Example 3: Request Interception and Modification
Extensions can intercept and modify network requests, which is useful for blocking tracking scripts, injecting headers, or modifying API responses:
// background.js — Request interception
// Block known tracking and fingerprinting scripts
const BLOCKED_DOMAINS = [
'*://www.google-analytics.com/*',
'*://mc.yandex.ru/*',
'*://px.ads.linkedin.com/*',
'*://bat.bing.com/*',
'*://cdn.segment.com/*',
'*://js.hs-scripts.com/*',
];
browser.webRequest.onBeforeRequest.addListener(
(details) => {
return { cancel: true };
},
{ urls: BLOCKED_DOMAINS },
['blocking']
);
// Add custom headers to all requests
browser.webRequest.onBeforeSendHeaders.addListener(
(details) => {
// Remove headers that might leak information
details.requestHeaders = details.requestHeaders.filter(
(h) =>
!['x-requested-with', 'sec-ch-ua-platform-version'].includes(
h.name.toLowerCase()
)
);
return { requestHeaders: details.requestHeaders };
},
{ urls: ['<all_urls>'] },
['blocking', 'requestHeaders']
);
// Log all API calls for debugging
browser.webRequest.onCompleted.addListener(
(details) => {
if (
details.type === 'xmlhttprequest' ||
details.url.includes('/api/')
) {
console.log(
`[API] ${details.method} ${details.url} → ${details.statusCode}`
);
}
},
{ urls: ['<all_urls>'] }
);
Installing Extensions in Anti-Detect Profiles
Anti-detect browsers provide several methods for installing extensions into isolated profiles:
Method 1: Pre-Install via Profile Configuration
Most anti-detect browsers support specifying extensions at profile creation time through the API:
// Install extension when creating a profile
const profile = await fetch('http://localhost:7891/api/profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Automated Profile',
extensions: [
{
path: '/path/to/my-extension/',
enabled: true,
},
],
}),
});
Method 2: Temporary Installation via about:debugging
For Firefox-based anti-detect browsers, load extensions temporarily during development:
- Open
about:debugging#/runtime/this-firefoxin the profile. - Click “Load Temporary Add-on.”
- Select any file in your extension directory.
The extension persists until the profile is closed. This is ideal for development and testing.
Method 3: Signed Extension Installation
For permanent installation, package and sign your extension:
# Package the extension as an XPI (Firefox) or CRX (Chromium)
cd my-extension/
zip -r ../my-extension.xpi . -x "*.git*"
# For Firefox-based browsers, self-sign with web-ext
npx web-ext sign --api-key=$AMO_KEY --api-secret=$AMO_SECRET
Signed extensions survive profile restarts and updates.
Per-Profile Extension State Isolation
A critical property of extensions in anti-detect environments: extension storage is isolated per profile. When you call browser.storage.local.set(), the data is stored within the profile’s isolated filesystem. This means:
- Form data configured in Profile A is invisible to Profile B.
- Extraction results saved by the extension in one profile cannot leak to another.
- Extension settings can be customized per profile without affecting others.
However, if you need to share data between profiles (for example, aggregating extraction results), you need an external communication channel:
// background.js — Send extraction results to an external API
async function reportResults(profileId, data) {
await fetch('http://localhost:3000/api/extraction-results', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
profileId,
timestamp: new Date().toISOString(),
data,
}),
});
}
This HTTP call from the extension goes through the profile’s proxy, so ensure your aggregation endpoint is accessible from the proxy’s network or use a localhost endpoint that bypasses the proxy.
Debugging Extensions in Anti-Detect Profiles
Debugging extensions in anti-detect profiles follows the standard Firefox/Chrome debugging workflow with one caveat: remote debugging must be enabled on the profile’s launch.
For Firefox-based anti-detect browsers:
- Launch the profile with remote debugging enabled (usually a launch flag or API option).
- Open
about:debuggingin the profile. - Click “Inspect” next to your extension to open the DevTools for the background script.
- Content scripts are debuggable from the regular page DevTools (F12) under the “Debugger” tab.
For Chromium-based anti-detect browsers:
- Navigate to
chrome://extensions/in the profile. - Enable “Developer mode.”
- Click “Inspect views” under your extension to debug the background service worker.
Key debugging tips:
- Use
console.log()liberally in content scripts; they appear in the page’s DevTools console. - Background script logs appear in the extension’s dedicated DevTools console.
- Use
browser.storage.local.get()in the DevTools console to inspect stored data. - Network requests made by the extension are visible in the background script’s DevTools Network tab.
Performance Considerations at Scale
When deploying the same extension across 200 profiles, performance matters:
Content script execution time: If your content script runs complex DOM queries on every page load, multiply that time by 200. Keep content scripts lightweight — use MutationObserver to react to DOM changes rather than polling.
Storage usage: browser.storage.local has a default quota of 5 MB per extension. If each profile stores extraction results locally, clean up old data periodically:
// Clean up results older than 24 hours
async function cleanupOldResults() {
const { results } = await browser.storage.local.get('results');
if (!results) return;
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
const cleaned = results.filter((r) => r.timestamp > cutoff);
await browser.storage.local.set({ results: cleaned });
}
Memory overhead: Each extension adds approximately 5–15 MB of RAM per profile (background script process + content script per tab). Across 200 profiles, this adds 1–3 GB of total overhead. Minimize by avoiding persistent background scripts when possible (use event-driven patterns) and limiting the number of content script matches.
Conclusion
Browser extensions provide the most natural and least detectable way to add automation functionality to anti-detect browser profiles. They execute within the browser’s security sandbox, inherit the profile’s isolation boundary (cookies, proxy, storage), and produce no external automation artifacts. The WebExtension API is powerful enough to handle form filling, data extraction, request interception, and multi-step workflow orchestration — all without installing Selenium, Playwright, or any external automation framework. For teams managing dozens or hundreds of profiles, a well-designed extension replaces complex external tooling with a single, lightweight, per-profile add-on.
Ready to protect your online identity?
Choose your plan and start running undetectable browser profiles today.
Earn 15% lifetime commission on every referral.
Become a Partner →