DEV Community

Temir Kussainov
Temir Kussainov

Posted on AI-assisted

ModHeader in Selenium stopped working. Here's what sets HTTP headers in tests in 2026

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:

  1. The extension bundled in chrome-modheader is 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.
  2. Google Chrome 137 and newer ignore --load-extension, which is how ChromeDriver installs any extension you pass with addExtensions. 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' } });
Enter fullscreen mode Exit fullscreen mode

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'}})
Enter fullscreen mode Exit fullscreen mode

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=*
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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: extraHTTPHeaders or 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)

Collapse
 
arhancanli profile image
Arhan Canli •

One caveat for the "no extension needed" section: extraHTTPHeaders and Network.setExtraHTTPHeaders attach 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?

Collapse
 
protemir profile image
Temir Kussainov •

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.