- Chrome Web Store
- Firefox
- Alerts
- Privacy
- Node.js
Browser extension monitoring: catch malicious updates early
Browser extension monitoring for developers and IT teams: watch publisher, contact, permission and code changes to catch a sold or hijacked extension early.
- Author
- Extenify Team
- Published
- Reading time
- 15 min read

On this page
Browser extension monitoring means watching the extensions you depend on after you install them, because the listing you checked on day one is not the code your browser runs a year later. Extensions update themselves silently. When an extension is sold to a new owner, or its developer account is phished, the next update reaches every existing user without a prompt. In 2026 that pattern has already hit extensions with a few hundred users and extensions with millions.
This guide is for two groups: developers who publish extensions and want an early warning if their own listing changes without them, and teams who rely on third-party extensions and want to know when one of them changes hands. It covers the signals that come before a malicious update, a tested Node.js script that fingerprints the code Chrome serves for any extension, a Firefox check that reads ownership data from the official add-ons API, and how to decide which changes deserve an alert. All checks were run on November 4, 2026.
Key takeaways
- The common attack is not a new malicious extension. It is a trusted one that changes owner, or whose developer account is phished, followed by an update that every user receives automatically.
- Watch the publisher signals (developer name, contact email, website, privacy policy), the access signals (permissions and host access) and the code itself (new or changed script files) on every listing you care about.
- No new permissions does not mean safe. Chrome disables an extension when an update adds a permission with a warning, so attackers tend to reuse the access the extension already has.
- A daily fingerprint of the package Chrome serves catches code changes, new files and takedowns. Firefox exposes authors, contact details and permissions through the addons.mozilla.org API.
- If you publish extensions, monitor your own listing too. A version you did not ship is the fastest sign of a hijacked account.
Why browser extensions need monitoring after install
Every major browser updates extensions in the background. That is good for security fixes and bad for trust, because trust is decided once, at install time, and never asked again. The cases from the last year show the two ways it goes wrong.
Sold, then weaponized. In March 2026, The Hacker News reported on two Chrome extensions from the same original developer. QuickLens, with about 7,000 users, was listed for sale on a marketplace in October 2025, changed hands on February 1, 2026 to an owner with a contact address on a .top domain, and pushed a malicious update on February 17. The update kept every original feature and added code that stripped security headers such as X-Frame-Options and polled a server for JavaScript to run on every page. ShotBird, which had carried the Featured badge, reportedly changed hands the same month and started showing a fake Chrome update prompt. The researcher who found QuickLens, John Tuckner of Annex Security, summed up why reviewing the package was not enough: "The actual malicious code never appears in the extension's source files."
Clean for years, then flipped. In December 2025, Koi Security described a campaign it called ShadyPanda, which reached 4.3 million Chrome and Edge users through extensions that behaved normally for years before an update turned them into spyware or a remote code execution backdoor.
Hijacked account. On December 24, 2024, an attacker who had phished a Cyberhaven employee with a fake "Chrome Web Store Developer Support" email and a malicious OAuth app called "Privacy Policy Extension" published version 24.10.4 of the company's extension. Security Affairs quotes Cyberhaven's timeline: browsers that auto-updated between 1:32 AM UTC on December 25 and 2:50 AM UTC on December 26 received it, the team detected it at 11:54 PM UTC on December 25, and the package was removed within 60 minutes. The same campaign compromised many other extensions.
None of these were new, suspicious-looking extensions. Each was already installed and trusted, and each changed something visible on its listing before or during the attack.
Sold extension or hijacked account: what changes
The two scenarios leave different traces, so watch for both.
| Signal | Extension sold to a new owner | Developer account hijacked |
|---|---|---|
| Developer name, email, website | Often changes, sometimes weeks before the bad update | Usually unchanged |
| Privacy policy URL | Often moves to the new owner's domain | Usually unchanged |
| Version and release timing | First release after a quiet period, or soon after the transfer | A release the real team did not ship, often at an odd hour or on a holiday |
| Permissions | Often unchanged | Often unchanged |
| Code | New script files, remote loading, new network endpoints | Small additions to a clean previous build |
| Reviews | New 1-star reviews about ads, redirects or popups | Same, if the version stays live long enough |
The permissions row surprises people. Chrome's own permission warnings guide explains why it is usually quiet: when an update adds a permission that triggers a warning, Chrome may disable the extension, and "the extension will be re-enabled only after the user agrees to accept the new permission." A new owner who wants every user to receive the update has a strong reason to stay inside the access the extension already has. An extension that already has access to all sites needs nothing new to do harm.
The signals to watch on every listing
These are the fields that change before or during the incidents above, and where you can read them.
| Signal | Where it is visible | Why it matters |
|---|---|---|
| Developer name and address | Chrome Web Store listing, "Developer" section; AMO author list | The clearest sign of a transfer |
| Contact email and website | Chrome listing "Email" and "Website"; AMO support email and homepage | QuickLens's new contact sat on a throwaway domain |
| Privacy policy link | Listing privacy section | Moves with the new owner's legal entity |
| Version and "Updated" date | Listing details | A dormant extension that suddenly ships is worth a look |
| Permissions and host access | The extension's manifest.json |
Rarely changes, but a new broad host pattern is serious |
| Script files | The package itself | Catches changes that permissions do not show |
| Listing availability | Whether the store still serves the package | Stores remove extensions once malware is confirmed |
| New low-star reviews | Listing reviews | Users often report ads and redirects before researchers do |
A Chrome Web Store listing shows most of the publisher fields in plain view. For Google Translate, for example, it lists the developer as Google Ireland, Ltd., with an address, a website, a contact email and the EU trader declaration, next to the version (2.0.18) and the date it was updated. The open source Under New Management extension checks the developer information of your installed extensions on a schedule and flags a change. Its Hacker News launch drew more than 780 points, which says a lot about how many people worry about this.
Fingerprint the code Chrome serves, in Node.js
Publisher fields tell you that an extension changed hands. They do not tell you what the new owner shipped. For that, look at the package itself. Chrome downloads extensions from a public update service, and you can request the same package for any public extension ID. A CRX3 file is a short header followed by an ordinary ZIP archive, so Node's built-in zlib is enough to read it.
Our guide to Chrome extension version history uses this to diff permissions between versions. For monitoring, add two more checks: which script files are new or changed, and whether the store still serves the package at all. The script below does both, with no dependencies (Node.js 22 or later):
// ext-watch.mjs: fingerprint the code Chrome serves for each extension and report changes.
// Usage: node ext-watch.mjs <extension-id> [<extension-id> ...]
import { inflateRawSync } from 'node:zlib';
import { readFile, writeFile } from 'node:fs/promises';
const STATE_FILE = './ext-watch-state.json';
const CHROME_VERSION = '155.0'; // the Chrome major version you want packages for
function crxUrl(id) {
const x = encodeURIComponent(`id=${id}&uc`);
return 'https://clients2.google.com/service/update2/crx' +
`?response=redirect&prodversion=${CHROME_VERSION}&acceptformat=crx3&x=${x}`;
}
// A CRX3 file is a small "Cr24" header followed by an ordinary ZIP archive.
function zipFromCrx(buf) {
if (buf.toString('latin1', 0, 4) !== 'Cr24') throw new Error('not a CRX file');
return buf.subarray(12 + buf.readUInt32LE(8));
}
// Walk the ZIP central directory: file name -> CRC-32, plus a reader for one file.
function readZip(zip) {
let eocd = zip.length - 22;
while (eocd >= 0 && zip.readUInt32LE(eocd) !== 0x06054b50) eocd--;
if (eocd < 0) throw new Error('ZIP end record not found');
const files = new Map();
let p = zip.readUInt32LE(eocd + 16);
for (let i = 0; i < zip.readUInt16LE(eocd + 10); i++) {
const nameLen = zip.readUInt16LE(p + 28);
const name = zip.toString('utf8', p + 46, p + 46 + nameLen);
files.set(name, {
method: zip.readUInt16LE(p + 10),
crc: zip.readUInt32LE(p + 16),
size: zip.readUInt32LE(p + 20),
offset: zip.readUInt32LE(p + 42),
});
p += 46 + nameLen + zip.readUInt16LE(p + 30) + zip.readUInt16LE(p + 32);
}
const read = (name) => {
const f = files.get(name);
const start = f.offset + 30 + zip.readUInt16LE(f.offset + 26) + zip.readUInt16LE(f.offset + 28);
const data = zip.subarray(start, start + f.size);
return f.method === 8 ? inflateRawSync(data) : data;
};
return { files, read };
}
async function fingerprint(id) {
const res = await fetch(crxUrl(id));
if (res.status === 204) return { served: false };
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { files, read } = readZip(zipFromCrx(Buffer.from(await res.arrayBuffer())));
const manifest = JSON.parse(read('manifest.json').toString('utf8').replace(/^\uFEFF/, ''));
const code = {};
for (const [name, f] of files) if (/\.(m?js|wasm|html)$/.test(name)) code[name] = f.crc;
return {
served: true,
version: manifest.version,
access: [...(manifest.permissions ?? []), ...(manifest.host_permissions ?? [])].sort(),
code,
};
}
function compare(before, now) {
if (!now.served) return before.served ? ['NO LONGER SERVED: removed, unpublished or blocked'] : [];
if (!before.served) return ['served again'];
const out = [];
if (before.version !== now.version) out.push(`version ${before.version} -> ${now.version}`);
const added = now.access.filter((a) => !before.access.includes(a));
if (added.length) out.push(`new access: ${added.join(', ')}`);
const names = Object.keys(now.code);
const newFiles = names.filter((n) => !(n in before.code));
const changed = names.filter((n) => n in before.code && before.code[n] !== now.code[n]);
if (newFiles.length) out.push(`new code files: ${newFiles.slice(0, 5).join(', ')}`);
if (changed.length) out.push(`changed code files: ${changed.length} (${changed.slice(0, 3).join(', ')})`);
return out;
}
const state = JSON.parse(await readFile(STATE_FILE, 'utf8').catch(() => '{}'));
for (const id of process.argv.slice(2)) {
try {
const now = await fingerprint(id);
const changes = state[id] ? compare(state[id], now) : ['baseline saved'];
console.log(`${id} ${now.served ? `v${now.version}` : '(not served)'}: ${changes.join(' | ') || 'no change'}`);
state[id] = now;
} catch (err) {
console.error(`${id}: check failed, ${err.message}`);
}
}
await writeFile(STATE_FILE, JSON.stringify(state));
The first run saves a baseline. Later runs print only what moved. These are real runs against Google Translate and the original uBlock Origin, which the Chrome Web Store no longer serves. The second run used a baseline edited by hand to stand in for an older release, so you can see what a change looks like:
aapbdbdomjkkjkaonfhkkikfgjllcleb v2.0.18: baseline saved
cjpalhdlnbpafiamejdnhcphjbkeiagm (not served): baseline saved
aapbdbdomjkkjkaonfhkkikfgjllcleb v2.0.18: version 2.0.17 -> 2.0.18 | new access: activeTab | new code files: options_compiled.js | changed code files: 2 (bubble_compiled.js, popup.html)
A few notes from testing:
- A 204 response means no package. For an extension that was removed or taken down, the update service answers
204 No Contentinstead of a file. That is the "NO LONGER SERVED" line, and for an extension you rely on it means uninstall now and find out why. An ID that never existed returns404. - Confirm a change on two runs. During a percentage rollout, the update service can serve either the old or the new version, so a version that flips back and forth is not an incident. Confirm a change on two consecutive days before you escalate it.
- Changed files are normal on every release. The useful part is the combination: changed code with no version bump never happens in a normal release, new files with names that do not match the project's build output deserve a look, and changed code right after a publisher change is the QuickLens pattern.
- Remote code does not show up here. QuickLens loaded its payload from a server at runtime. A fingerprint tells you that code changed, not what it fetches later, which is why the publisher signals matter as much as the package.
- Keep it gentle. One request per extension per day is plenty. Run it from a scheduler that survives restarts, as described in our guide to a Node.js cron job, and send the output somewhere people read, such as a channel set up with a Slack webhook in Node.js.
Firefox: read authors and permissions from the AMO API
Firefox makes ownership data easier to watch, because addons.mozilla.org has an official, documented API. One request returns the add-on's authors, homepage, support email, privacy policy flag, current version and the permissions declared by that version:
// amo-owner.mjs: the ownership and access signals of a Firefox add-on, from the AMO API.
// Usage: node amo-owner.mjs <slug-or-guid>
const slug = process.argv[2];
const res = await fetch(`https://addons.mozilla.org/api/v5/addons/addon/${encodeURIComponent(slug)}/?lang=en-US`);
if (!res.ok) throw new Error(`AMO answered HTTP ${res.status}`);
const a = await res.json();
const file = a.current_version.file;
console.log(JSON.stringify({
version: a.current_version.version,
lastUpdated: a.last_updated,
authors: a.authors.map((p) => `${p.name} (${p.id})`),
homepage: a.homepage?.url?.['en-US'] ?? null,
supportEmail: a.support_email?.['en-US'] ?? null,
supportUrl: a.support_url?.url?.['en-US'] ?? null,
hasPrivacyPolicy: a.has_privacy_policy,
permissions: [...file.permissions, ...file.host_permissions].sort(),
dataCollection: file.data_collection_permissions,
status: a.status,
disabled: a.is_disabled,
}, null, 2));
For Dark Reader on November 4, 2026, it returned version 4.9.134, the author "Dark Reader Ltd", the homepage https://darkreader.org/ and a support email. Store this object daily and diff it the same way. The author ID is more reliable than the display name, because a display name can be edited to look unchanged. Our post on Firefox add-on stats covers the same API for users and ratings, and JSON diff in Node.js shows how to turn two snapshots into readable alert lines.
Edge Add-ons has no comparable public API. Its listings show the version, the developer and the last update date, so check them on the same schedule, or use a tracker that records them for you.
Which changes deserve an alert
Most listing changes are harmless. A monitoring setup people trust sorts them before anyone reads them, the same idea as in our guide to store change alerts your team will actually read.
Act today:
- The store stops serving the package, or the listing disappears.
- A new version appears within a few weeks of a developer, email or website change.
- New broad host access, such as
<all_urls>or*://*/*, or a newexternally_connectableentry. - On your own extension: any version you did not publish.
Review this week:
- A developer, contact email, website or privacy policy change with no new version yet.
- A first release after six months or more of silence.
- Several new 1-star reviews that mention ads, redirects, new tabs or search changes.
Log only:
- A routine version bump with the same publisher, the same access and changed files that match the project's usual build output.
If you publish extensions: watch your own listing
Cyberhaven's timeline shows how much the first hours matter: the malicious version went out on December 24 and was found late on December 25. Treat your own listing as something to monitor, not just something to publish to:
- Compare the live version on every store with your release log once a day. A mismatch you cannot explain is an incident. Our checklist of what to watch after an extension release covers the normal case.
- Be suspicious of any email that claims your listing is about to be removed and asks you to sign in or authorize an app. That is exactly how the Cyberhaven account was taken. Our guide to an extension removed from the Chrome Web Store explains what real policy notices look like.
- If you sell an extension, remember that your users will not be told. Their trust moves to the buyer silently, and your name stays attached to the history of the listing.
If you manage extensions for a team
Monitoring tells you when something changed. Policy limits how much damage a change can do. Chrome's ExtensionSettings policy lets administrators block permissions outright: blocked_permissions "prevents users from installing and running extensions that request certain API permissions", and if an installed extension later requires a blocked permission, "it will no longer load." runtime_blocked_hosts keeps extensions away from the sites you name, such as your admin consoles and banking portals. Combine an allowlist with daily monitoring of the allowed extensions, and an ownership change becomes a review ticket instead of an incident.
Monitor extensions without running scripts
The scripts above work for a handful of IDs. If you follow many extensions, yours and others', across several stores, Extenify records each listing every day and alerts you when the developer, contact email, website, privacy policy, permission count or version changes, when a store blocks downloads of a listing, and when new reviews or a rating drop arrive. It is the same browser extension analytics dashboard teams use for installs, ratings and competitor app monitoring, so security changes arrive in the same email, Slack, Discord, Telegram or Teams digest as everything else. Every plan starts with a free trial; see Extenify pricing.
Browser extension monitoring checklist
- List every extension you publish or depend on, with its store IDs on Chrome, Edge and Firefox.
- Record the publisher fields: developer name, contact email, website, privacy policy URL.
- Save a code fingerprint and the declared access for each current version.
- Check once a day, and treat a package the store no longer serves as urgent.
- Alert on publisher changes, new broad access and versions you did not ship.
- Read new low-star reviews on extensions that just changed.
- For teams: enforce an allowlist and blocked permissions with ExtensionSettings.
Frequently asked questions
How do I know if a Chrome extension changed owner?
Compare the listing's developer section over time: the developer name, address, contact email and website. The Chrome Web Store does not announce transfers, so you need a saved copy of the old values to see a change. A tool that records the listing daily, or the Under New Management extension for your own browser, does this for you.
Won't the damage be done by the time I detect it?
Sometimes, which is why the publisher signals matter. In the QuickLens case, the ownership change came about two weeks before the malicious update. A daily check on publisher fields would have flagged the extension before it shipped anything harmful. For a hijacked account, the version itself is the first signal, so check daily rather than weekly.
Can a buyer keep the developer name the same?
On Chrome, the developer name and contact details belong to the publisher account, so a transfer to a different account usually changes them, as in both 2026 cases above. A buyer can choose a similar-looking name. That is why you should also watch the contact email and website domain, and on Firefox, the author ID rather than the display name.
Does Chrome warn me when an extension adds permissions?
Only when the new permission has a warning. Chrome then disables the extension until the user accepts. Updates that keep the same permissions, change code or load scripts from a server install silently, so the absence of a warning is not a sign of safety.
How often should I check my extensions?
Daily is a good default. Chrome checks for extension updates on its own every few hours, so a weekly check can miss a malicious version that is live for a day or two, which is roughly how long the Cyberhaven version was live.
Summary
Browser extension monitoring is a habit, not a one-time review. The 2026 attacks reused trusted extensions: some were sold and turned malicious, some were hijacked through phished developer accounts, and most kept their permissions unchanged. Watch the publisher fields, the declared access and the code Chrome actually serves, check daily, and alert on ownership changes, new broad access, versions you did not ship and packages the store stops serving. For your own extension, the same checks are the fastest way to notice that someone else is publishing in your name.
Image credits
- Cover photo: Turquoise combination padlock on a rusty latch by Nilo Velez on the WordPress Photo Directory, CC0.


