If your browser tests set headers through ModHeader (chrome-modheader on npm, modheader_selenium for Python and Java), they probably broke this year, and not only because ModHeader was pulled from the stores in July. Two other things happened:
- The extension bundled in
chrome-modheaderis ModHeader 4.3.2, a Manifest V2 build. Chrome turned off MV2 extensions, so it never loads, and the package hasn't been updated since. - Google Chrome 137 and newer ignore
--load-extension, which is how ChromeDriver installs any extension you pass withaddExtensions. Chrome for Testing and Chromium still take it.
The package still gets about 150k downloads a month, mostly from CI, so a lot of pipelines are installing something that does nothing. Here's what works now, from the simplest option up.
Request headers on every request: no extension needed
Playwright has it built in:
const context = await browser.newContext({ extraHTTPHeaders: { 'X-Env': 'staging' } });
Selenium 4 with Chrome can ask the DevTools protocol:
driver.execute_cdp_cmd('Network.enable', {})
driver.execute_cdp_cmd('Network.setExtraHTTPHeaders', {'headers': {'X-Env': 'staging'}})
Both add the headers to every request from that context or tab, which covers most "log in as a test user" and "route to staging" cases. They don't change response headers, can't remove a header the browser sends, and don't limit headers to some URLs (in Playwright, page.route() can, at the cost of handling each request in your test).
Response headers, removing headers, some URLs only
That's where an extension still earns its place: CORS or CSP response headers, stripping Referer or Cookie, a header only for api.example.com. A proxy like mitmproxy can do all of that too, but then you manage certificates, and on a cloud grid that's often not an option.
Disclosure: I maintain Headerwise, an open source (MIT) MV3 header extension, and it has an automation build for this. It has a fixed extension id, and your test sets headers by opening one URL:
chrome-extension://ogjbgamdhnjnagcdgboifgmhlgddcdce/automation.html?X-Env=staging&res:Access-Control-Allow-Origin=*
The page waits until Chrome has the rules and then reports ready. Playwright, with its own Chromium:
const ext = '/path/to/headerwise-automation';
const context = await chromium.launchPersistentContext('', {
channel: 'chromium',
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');
Selenium needs Chrome for Testing, which Selenium Manager downloads when you ask for a version:
options = webdriver.ChromeOptions()
options.browser_version = 'stable'
options.add_argument('--load-extension=/path/to/headerwise-automation')
driver = webdriver.Chrome(options=options)
If your tests are written for ModHeader
The automation build also answers ModHeader's own webdriver.modheader.com/add, /clear and /load URLs (nothing is sent to modheader.com: the browser redirects them to the extension before any request leaves) and titles the page "Done", like ModHeader did. So a test like this only changes the extension it loads:
const { getAddHeaderUrl } = require('chrome-modheader');
const options = new chrome.Options().addExtensions('headerwise-automation.crx'); // was getExtension()
options.setBrowserVersion('stable');
const driver = await new Builder().forBrowser('chrome').setChromeOptions(options).build();
await driver.get(getAddHeaderUrl('X-Env', 'staging'));
await driver.wait(until.titleIs('Done'), 10000);
The .crx is signed, so it also works on grids that take a base64 extension in the capabilities. Ask the grid for Chrome for Testing (BrowserStack calls it chromefortesting), for the same --load-extension reason.
Setup for Selenium, Playwright and Puppeteer: https://protemir.github.io/headerwise/automation.html. CI runs those snippets on every change, including one test written for ModHeader with only the extension swapped.
Which one to pick
- Same request headers everywhere:
extraHTTPHeadersor CDP. Nothing to install. - Response headers, removals, per-URL rules, or a lot of existing ModHeader URLs in your tests: an extension (Headerwise or whatever you trust).
- Firefox, or traffic outside the browser too: a proxy.
If ModHeader itself got switched off in your everyday browser too, I wrote up how to get your profiles back. And if something in your test setup doesn't fit any of these, tell me in the comments.
Top comments (2)
One caveat for the "no extension needed" section:
extraHTTPHeadersandNetwork.setExtraHTTPHeadersattach the header to every request the page makes, including third-party origins (fonts, analytics, CDN scripts). A custom header like X-Env makes those cross-origin requests non-simple, so the browser sends a preflight and the third party has to allow the header in its CORS response. Many don't, and the symptom is a page that works by hand and loses some assets or fetches under test. The usual fix is the per-URL route you mention, or only setting the header for your own origin.Also, if I remember the Selenium side right, the CDP call applies to the one target you sent it on, so a popup or new tab opened by the page won't carry the header unless you set it there too, and a second call replaces the headers map rather than merging into it. Worth confirming on your version before you rely on either.
Did the automation build need the fixed extension id for anything beyond making the URL predictable, such as pinning the signed .crx on grids?
Good points. I actually tested the preflight one after reading this: a header the page or CDP adds triggers an OPTIONS request and the third-party fetch fails, exactly like you say. A header added by the extension doesn't, the request goes through as a simple one, I guess because declarativeNetRequest applies after the CORS check. So that part is one more reason to use an extension.
It still sends the header to every site though, which for Authorization isn't great. Your comment made me add a warning for that in the popup, with a one-click "only on this site". It'll be in the next update, should be in the store in a few days.
And yes, the CDP headers are per tab and a second call replaces them, the extension's rules apply to all tabs.
On the fixed id: ChromeDriver takes the id from the .crx signature, so the grid gets the same id as local runs. And the old webdriver.modheader.com URLs are redirected to the extension's page, which needs the id at build time.