Set HTTP headers in Selenium, Playwright and Puppeteer
ModHeader's test packages (chrome-modheader, modheader_selenium) shipped an old extension that modern Chrome won't run, and ModHeader itself is gone from the stores. Headerwise has a build for tests: load it, open one URL with the headers you want, and the page tells you when they're in effect. Tests written for ModHeader keep their URLs.
Get it
curl -L -o headerwise-automation.zip https://github.com/Protemir/headerwise/releases/latest/download/headerwise-automation.zip
unzip headerwise-automation.zip -d headerwise-automation
For Selenium's addExtensions and cloud grids there is a signed .crx with the same id: …/releases/latest/download/headerwise-automation.crx.
The automation build is the regular extension with three differences: a fixed id, ogjbgamdhnjnagcdgboifgmhlgddcdce, so your tests know its URL; access to all sites from the start, since nobody is there to click "Allow"; and no welcome tab. Free and MIT licensed, source on GitHub.
--load-extension. Google Chrome dropped the flag; Chrome for Testing and Chromium still have it. Playwright's Chromium and Puppeteer's Chrome for Testing work as they are; Selenium needs browser_version set so it downloads Chrome for Testing (below).Set headers with a URL
chrome-extension://ogjbgamdhnjnagcdgboifgmhlgddcdce/automation.html?X-Env=staging&Authorization=Bearer%20abc
| In the query | What it does |
|---|---|
Name=Value | Set a request header. An empty value removes the header. |
res:Name=Value | Set a response header (CORS, CSP, caching…). |
@url=pattern | Only on matching URLs, Chrome's urlFilter syntax: ||api.example.com^, *://localhost:*/*. Can repeat. |
@add | Keep the headers earlier calls set and add these (same name: replaced). |
@clear | No headers at all. |
@import=json | Replace all profiles with a Headerwise or ModHeader JSON export. |
| nothing | Change nothing, show what's in effect. |
Each call replaces the previous one unless it has @add. URL-encode the values (encodeURIComponent, urllib.parse.quote): a bare + turns into a space. When the rules are in the browser, the page sets document.documentElement.dataset.status to ready (or error, with the reason on the page), and the title to "Headerwise: ready". It lists header names, never values, so screenshots and logs don't leak tokens.
Playwright
Node.js
import { chromium } from 'playwright';
const ext = '/path/to/headerwise-automation';
const context = await chromium.launchPersistentContext('', {
channel: 'chromium', // full Chromium: the headless shell has no extensions
args: [`--disable-extensions-except=${ext}`, `--load-extension=${ext}`],
});
const page = await context.newPage();
await page.goto('chrome-extension://ogjbgamdhnjnagcdgboifgmhlgddcdce/automation.html?X-Env=staging');
await page.waitForFunction(() => document.documentElement.dataset.status === 'ready');
await page.goto('https://example.com'); // sent with X-Env: staging
Python
from playwright.sync_api import sync_playwright
ext = '/path/to/headerwise-automation'
with sync_playwright() as p:
context = p.chromium.launch_persistent_context('', channel='chromium',
args=[f'--disable-extensions-except={ext}', f'--load-extension={ext}'])
page = context.new_page()
page.goto('chrome-extension://ogjbgamdhnjnagcdgboifgmhlgddcdce/automation.html?X-Env=staging')
page.wait_for_function("document.documentElement.dataset.status === 'ready'")
page.goto('https://example.com')
Playwright's own extraHTTPHeaders is simpler when all you need is request headers on every request. Use Headerwise for response headers, removing headers, headers on some URLs only, or a setup shared with people who use the extension by hand.
Puppeteer
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ pipe: true, enableExtensions: ['/path/to/headerwise-automation'] });
// the extension installs after launch() returns
await browser.waitForTarget(t => t.type() === 'service_worker' && t.url().includes('ogjbgamdhnjnagcdgboifgmhlgddcdce'));
const page = await browser.newPage();
await page.goto('chrome-extension://ogjbgamdhnjnagcdgboifgmhlgddcdce/automation.html?X-Env=staging');
await page.waitForFunction(() => document.documentElement.dataset.status === 'ready');
await page.goto('https://example.com');
Selenium
Python
from selenium import webdriver
from selenium.webdriver.support.ui import WebDriverWait
options = webdriver.ChromeOptions()
options.browser_version = 'stable' # Chrome for Testing, which still loads extensions
options.add_argument('--load-extension=/path/to/headerwise-automation')
driver = webdriver.Chrome(options=options)
driver.get('chrome-extension://ogjbgamdhnjnagcdgboifgmhlgddcdce/automation.html?X-Env=staging')
WebDriverWait(driver, 10).until(lambda d: d.execute_script(
"return document.documentElement.dataset.status === 'ready'"))
driver.get('https://example.com')
JavaScript
import { Builder } from 'selenium-webdriver';
import chrome from 'selenium-webdriver/chrome.js';
const options = new chrome.Options().addArguments('--load-extension=/path/to/headerwise-automation');
options.setBrowserVersion('stable');
const driver = await new Builder().forBrowser('chrome').setChromeOptions(options).build();
await driver.get('chrome-extension://ogjbgamdhnjnagcdgboifgmhlgddcdce/automation.html?X-Env=staging');
await driver.wait(() => driver.executeScript("return document.documentElement.dataset.status === 'ready'"), 10000);
Java and C# take the same two settings (setBrowserVersion("stable") and the --load-extension argument). Headless works with --headless=new.
Selenium Grid and cloud grids
When the browser runs on another machine, send the extension inside the capabilities: options.add_extension('headerwise-automation.crx') (Python), addExtensions(...) (JavaScript, Java), or the base64 of the file in goog:chromeOptions.extensions. ChromeDriver checks the signature and installs it with the fixed id. It does that with the flag Google Chrome 137+ ignores, so ask the grid for Chrome for Testing; BrowserStack calls it browserName: 'chromefortesting'.
Coming from ModHeader
Your test code can stay as it is. The automation build catches ModHeader's webdriver addresses and handles them itself, so getAddHeaderUrl(), getClearHeadersUrl() and getLoadProfileUrl() from chrome-modheader keep working. Nothing goes to modheader.com: the browser redirects these addresses to the extension's page before any request leaves.
| ModHeader URL | Same as |
|---|---|
webdriver.modheader.com/add?Name=Value | automation.html?@add&Name=Value |
webdriver.modheader.com/clear | automation.html?@clear |
webdriver.modheader.com/load?profile=… | automation.html?@import=…, same JSON |
What you change is the extension: pass headerwise-automation.crx where you passed getExtension(), and ask for Chrome for Testing (setBrowserVersion('stable')), since Google Chrome no longer loads extensions from ChromeDriver. Waits for the title "Done" keep working: that is the page's title when it was opened through ModHeader's addresses. One difference: /add with a header name that's already set replaces the value instead of sending the header twice.
// before: new chrome.Options().addExtensions(getExtension())
const options = new chrome.Options().addExtensions('headerwise-automation.crx');
options.setBrowserVersion('stable');
// unchanged:
await driver.get(getAddHeaderUrl('X-Env', 'staging'));
await driver.wait(until.titleIs('Done'), 10000);
Only these three addresses are handled, and only when the test itself opens them: a web page can't open them in a frame or link to the extension's page.
Checked on every change
CI loads this build into Playwright, Puppeteer and Selenium (JavaScript and Python) and checks what a server really receives: headers set, replaced, limited to URLs and cleared, response headers, ModHeader's URLs, and that a mistake in the URL is reported instead of guessed at. One more run is a test written for ModHeader, unchanged except for the .crx: chrome-modheader's own helpers, addExtensions and waiting for "Done". The scripts are in scripts/automation-smoke. Something missing for your setup? Open an issue.