Manifest V3 webRequest blocking alternative: the declarativeNetRequest migration
7 min readModerok team
Why blocking webRequest listeners stop working in Manifest V3, the declarativeNetRequest rules that replace them, and what still breaks after.
If your Manifest V3 extension logs You do not have permission to use blocking webRequest listeners., or shows the install warning 'webRequestBlocking' requires manifest version of 2 or lower., the Manifest V3 webRequest blocking alternative is declarativeNetRequest. Drop the permission, declare a rule file:
{
"manifest_version": 3,
"permissions": ["declarativeNetRequest"],
"declarative_net_request": {
"rule_resources": [
{ "id": "ruleset_1", "enabled": true, "path": "rules.json" }
]
}
}
[
{
"id": 1,
"priority": 1,
"action": { "type": "block" },
"condition": { "urlFilter": "||example.com", "resourceTypes": ["main_frame"] }
}
]
That is the fix. Chrome's migration guide puts it the same way: "In the manifest.json replace the "webRequestBlocking" permission with the "declarativeNetRequest" permission" (Replace blocking web request listeners). The rest is why the old listener fails and what still does not work.
Why blocking webRequest fails in Manifest V3
Two separate checks fire, which is why one root cause produces two messages.
The manifest warning comes from the permission's availability rules. In Chromium's _permission_features.json, webRequestBlocking carries "max_manifest_version": 2 for ordinary extensions, plus a second entry allowing manifest version 3 and above only when the install "location" is "policy" (_permission_features.json). A permission failing that check produces "'%s' requires manifest version of %d or lower." (simple_feature.cc). It is a warning, not a load failure: the extension installs, minus the permission.
The runtime message comes from listener registration. Chrome's reference states what the permission gates: "webRequestBlocking Required to register blocking event handlers. As of Manifest V3, this is only available to policy installed extensions" (webRequest). When you pass "blocking" in extraInfoSpec without it, Chromium reports kBlockingPermissionRequired, whose full text is: "You do not have permission to use blocking webRequest listeners. Be sure to declare the webRequestBlocking permission in your manifest. Note that webRequestBlocking is only allowed for extensions that are installed using ExtensionInstallForcelist." (web_request_api_constants.cc).
Note what that message is not: a thrown exception at your addListener() call. Chromium writes it to the listener's console or returns it as an API error, then returns early, before the call that would add the listener to the event router. The listener is never wired up, so it receives no requests and your return { cancel: true } never runs.
The policy carve-out is narrow. Chrome's migration page: "For policy installed extensions, the webRequestBlocking permission is still available in Manifest V3." The known-issues FAQ scopes that to "complex enterprise (or education) use cases" (Known issues). A Web Store listing does not qualify.
The three rule shapes
Chrome's migration page gives equivalents for the three things blocking listeners were mostly used for. Blocking is the rules.json above. Redirecting:
[
{
"id": 1,
"priority": 1,
"action": {
"type": "redirect",
"redirect": { "url": "https://example.com/new" }
},
"condition": {
"urlFilter": "https://old.example.com/",
"resourceTypes": ["main_frame"]
}
}
]
And header modification, here stripping a cookie header:
[
{
"id": 1,
"priority": 1,
"action": {
"type": "modifyHeaders",
"requestHeaders": [{ "header": "cookie", "operation": "remove" }]
},
"condition": { "resourceTypes": ["main_frame"] }
}
]
Rules need not be static. updateDynamicRules() edits a set that survives browser restarts, updateSessionRules() one cleared at shutdown:
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: [1001],
addRules: [
{
id: 1001,
priority: 1,
action: { type: "block" },
condition: {
urlFilter: "||tracker.example/",
resourceTypes: ["script", "xmlhttprequest"],
},
},
],
});
What still breaks after the fix
Host permissions for redirects and headers. The declarativeNetRequest permission is not a free pass. MDN: "The "declarativeNetRequest" permission allows extensions to block and upgrade requests without any host permissions. Host permissions are required if the extension wants to redirect requests or modify headers on requests or when the "declarativeNetRequestWithHostAccess" permission is used instead of the "declarativeNetRequest" permission." It also adds a trap for subresources: "For all requests, except for navigation requests (i.e., resource type main_frame and sub_frame), host permissions are also required for the request's initiator" (declarativeNetRequest). A rule that works on a top-level navigation can silently do nothing for an image on a host you did not declare.
An install warning you may not want. Chrome's reference describes "declarativeNetRequest" as a permission that "Triggers a permission warning at install time but provides implicit access to allow, allowAllRequests and block rules". For "declarativeNetRequestWithHostAccess", "A permission warning is not shown at install time, but you must request host permissions before you can perform any action on a host". The two "provide the same capabilities. The difference between them is when permissions are requested or granted." An extension that already holds host permissions can take the second and avoid stacking another warning on top of the one it already shows, at the price that even blocking then needs one. Chrome's migration sample for redirects takes that route: "Notice that redirecting also requires the "declarativeNetRequestWithHostAccess" permission in addition to the host permission."
Rule limits. "An extension can specify up to 100 static rulesets as part of the "rule_resources" manifest key, but only 50 of these rulesets can be enabled at a time", and "Collectively, those rulesets are guaranteed at least 30,000 rules". Chrome's note matters for older releases: "Prior to Chrome 120, extensions were limited to a total of 50 static rulesets, and only 10 of these could be enabled at the same time." Dynamic rules get "at least 5000", with a higher quota for what the docs call safe rules: "Safe rules are defined as rules with an action of block, allow, allowAllRequests or upgradeScheme." Regex is capped separately: "the total number of regular expression rules of each type cannot exceed 1000" (declarativeNetRequest).
You cannot look at the request any more. That is the design: "This lets extensions modify network requests without intercepting them and viewing their content, thus providing more privacy." MDN states the trade plainly: "The webRequest API is more flexible than the declarativeNetRequest API because it allows extensions to evaluate a request programmatically." Any decision that needed the request body, a token fetched at request time, or an await before deciding cannot be expressed as a rule. Observational webRequest listeners still work in Manifest V3, but cannot change the outcome, and need host permissions plus a live service worker, which stops on its own schedule.
No response bodies in Chrome, before or after. Neither API offers them: rules act "without intercepting them and viewing their content", and Chrome's webRequest reference documents no equivalent of Firefox's webRequest.filterResponseData().
Debugging is permission-gated. onRuleMatchedDebug is "Only available for unpacked extensions with the "declarativeNetRequestFeedback" permission as this is intended to be used for debugging purposes only". getMatchedRules() is "only available to extensions with the "declarativeNetRequestFeedback" permission or having the "activeTab" permission granted for the tabId specified in filter", and drops history: "Rules not associated with an active document that were matched more than five minutes ago will not be returned." A packed, published build can therefore read matches only for a tab it has been granted activeTab for, never a general view across your users.
One blocking event survives. onAuthRequired is the exception. Chrome's reference lists webRequestAuthProvider as "Required to use the onAuthRequired method", and asyncBlocking is "only allowed for onAuthRequired", so auth credentials can still be supplied from JavaScript.
Firefox differences
Firefox did not remove blocking webRequest in Manifest V3. MDN's own tutorial redirects requests from a "manifest_version": 3 extension whose manifest reads "permissions": ["webRequest", "webRequestBlocking"], with the instruction to "Add the webRequestBlocking permission", because "This extra permission is needed when an extension wants to modify a request" (Intercept HTTP requests). Response filtering is still available there, with an extra key in Manifest V3: "From Firefox 110, Manifest V3 extensions must also request the "webRequestFilterResponse" permission to use this API" (filterResponseData). A cross-browser extension can ship declarative rules for Chrome and keep a blocking path for Firefox, at the cost of both.
Firefox supports declarativeNetRequest too, with three differences. Debugging is gated on a preference rather than on being unpacked. MDN, on getMatchedRules and onRuleMatchedDebug: "in Chrome, these APIs are only available to unpacked extensions", while "in Firefox, these APIs are only available after setting the extensions.dnr.feedback preference to true". Tie-breaking differs: "After rule priority and rule action, Firefox considers the ruleset the rule belongs to, in this order of precedence: session > dynamic > static rulesets. This cannot be relied upon across browsers, see WECG issue 280." And the dynamic rule quota split landed at different versions: "In Safari and up to Chrome 119 and Firefox 127" a combined limit, "From Chrome 120 and Firefox 128" separate ones.
Seeing the migration land in production
What makes declarativeNetRequest fast also makes it quiet: rules are evaluated in the browser with no extension process involved, so a rule that matches nothing produces no log in a published build. What still runs in your service worker is the code around the rules: the updateDynamicRules() call that rejects when a user's list pushes past the quota, the updateEnabledRulesets() toggle behind a settings switch.
Moderok's SDK records uncaught exceptions and unhandled promise rejections as error events in any context where you call Moderok.init(), so a rejected updateDynamicRules() in a worker with no open console still reaches you, and Moderok.captureError(error, { action: "update_dnr_rules", rule_id: 1001 }) attaches the context that separates a quota rejection from a malformed rule. The error tracking guide covers both paths.