Redirecting and Rewriting Headers with Rules
Use declarativeNetRequest redirect, transform and modifyHeaders actions in MV3 — regexSubstitution, extension-relative targets, and the host permissions each one demands.
Table of Contents
Blocking a request needs no host permission. The moment you want to send it somewhere else or change a header on the way, the browser requires access to both the initiator and the destination, and a rule that looks correct will simply never fire until that access is granted. Redirect and header rules are also where declarativeNetRequest’s expression language is at its least forgiving. This guide is part of declarativeNetRequest rules.
The three ways to rewrite a URL
A redirect action takes exactly one of url, extensionPath, transform or regexSubstitution. They are not interchangeable: transform edits parts of the matched URL, regexSubstitution rebuilds it from capture groups, and url replaces it wholesale.
Step-by-step
1. Declare the permissions the action needs
1{
2 "permissions": [
3 "declarativeNetRequest",
4 "declarativeNetRequestWithHostAccess" // required for redirect + modifyHeaders
5 ],
6 "host_permissions": [
7 "https://tracker.example.com/*", // the request being rewritten
8 "https://cdn.example.org/*" // where it is being sent
9 ]
10}
Execution context: parsed at install. declarativeNetRequestWithHostAccess limits the rules to hosts the user granted, which reviewers prefer to the broad declarativeNetRequest permission — the argument to make is covered in writing a permission justification that passes.
2. Redirect to a packaged file
The common case: replace a third-party script with a local stub so the page keeps working.
1{
2 "id": 1,
3 "priority": 1,
4 "action": {
5 "type": "redirect",
6 "redirect": { "extensionPath": "/stubs/analytics-noop.js" }
7 },
8 "condition": {
9 "urlFilter": "||analytics.example.com/tag.js",
10 "resourceTypes": ["script"]
11 }
12}
Execution context: evaluated by the network stack before the request leaves the browser; no extension context runs. The stub file must be listed in web_accessible_resources for the target origins, or the redirect resolves to a blocked URL and the page sees a network error instead of your stub.
3. Rewrite parts of a URL with transform
transform is the safest form because each component is validated independently.
1{
2 "id": 2,
3 "priority": 1,
4 "action": {
5 "type": "redirect",
6 "redirect": {
7 "transform": {
8 "scheme": "https",
9 "queryTransform": {
10 "removeParams": ["utm_source", "utm_medium", "utm_campaign", "fbclid"]
11 }
12 }
13 }
14 },
15 "condition": { "urlFilter": "*", "resourceTypes": ["main_frame"] }
16}
Execution context: the network stack. removeParams is applied to the matched URL, so a page that carries no tracking parameters is not redirected at all — the rule only fires when something actually changes, which avoids a redirect loop.
4. Rebuild a URL with regexSubstitution
1{
2 "id": 3,
3 "priority": 2,
4 "action": {
5 "type": "redirect",
6 "redirect": { "regexSubstitution": "https://reader.example.net/p/\\1" }
7 },
8 "condition": {
9 "regexFilter": "^https://news\\.example\\.com/articles/([0-9]+)",
10 "resourceTypes": ["main_frame"]
11 }
12}
Execution context: the network stack. Capture groups are referenced with \1, which must be escaped as \\1 in JSON. regexFilter uses RE2 — no backreferences, no lookahead — and a pattern the engine rejects disables the whole ruleset rather than the single rule.
5. Add, set and remove headers
modifyHeaders takes separate lists for request and response headers, and each entry names an operation.
1{
2 "id": 4,
3 "priority": 1,
4 "action": {
5 "type": "modifyHeaders",
6 "requestHeaders": [
7 { "header": "referer", "operation": "remove" },
8 { "header": "x-client", "operation": "set", "value": "reader-ext" }
9 ],
10 "responseHeaders": [
11 { "header": "x-frame-options", "operation": "remove" }
12 ]
13 },
14 "condition": {
15 "urlFilter": "||api.example.org/",
16 "resourceTypes": ["xmlhttprequest"]
17 }
18}
Execution context: the network stack, applied after any redirect resolves. append is only valid for headers the specification allows to repeat; using it on a singleton header is rejected at registration.
6. Apply and check at runtime
1await chrome.declarativeNetRequest.updateDynamicRules({
2 removeRuleIds: [1, 2, 3, 4],
3 addRules: rules,
4});
5
6const outcome = await chrome.declarativeNetRequest.testMatchOutcome({
7 url: "https://news.example.com/articles/4821",
8 type: "main_frame",
9 initiator: "https://news.example.com",
10});
11console.log(outcome.matchedRules);
Execution context: the service worker. testMatchOutcome evaluates the live rule set without issuing a request, which makes it the fastest way to confirm a regex compiled the way you meant — more in debugging rules that don’t match.
Writing a regexFilter the engine will accept
regexFilter uses RE2, not the JavaScript regular-expression engine, and the difference is not cosmetic: RE2 guarantees linear-time matching by refusing every construct that could backtrack. A pattern the browser rejects does not disable one rule — it disables the entire ruleset it belongs to, which is why a single bad regex can make an extension look completely inert.
What RE2 will not accept:
- Backreferences —
(\w+)\s\1is rejected outright. - Lookahead and lookbehind —
(?=…),(?!…),(?<=…)are all unsupported. - Possessive quantifiers and atomic groups — no
a*+, no(?>…). - Named groups in the JavaScript spelling — use numbered groups and
\1substitutions.
What it does accept covers most real filter patterns: character classes, alternation, anchors, bounded repetition and capture groups. The practical rule is that if the pattern would be readable to someone who learned regular expressions from a textbook rather than from Perl, it will compile.
1// Validate at build time rather than discovering it at install time.
2import { readFileSync } from "node:fs";
3
4const REJECTED = /\\[0-9]|\(\?[=!<]/; // backreference or lookaround
5for (const rule of JSON.parse(readFileSync("src/rules/core.json", "utf8"))) {
6 const rx = rule.condition?.regexFilter;
7 if (rx && REJECTED.test(rx)) throw new Error(`unsupported regex in rule ${rule.id}: ${rx}`);
8}
Execution context: Node, during the build. This is a heuristic rather than a compiler, but it catches the two constructs that account for nearly every rejection, and it fails the build instead of shipping a dead ruleset.
The runtime check is chrome.declarativeNetRequest.isRegexSupported, which asks the browser directly and is the authoritative answer:
1const { isSupported, reason } = await chrome.declarativeNetRequest
2 .isRegexSupported({ regex: "^https://news\\.example\\.com/articles/([0-9]+)" });
Execution context: the service worker. Run it over user-supplied patterns before accepting them into a dynamic rule — a rejected regex there would otherwise take out every dynamic rule the user has.
Cross-browser variation
- Chrome / Edge: redirects are capped at five hops per request; a rule that redirects onto itself trips the loop guard and the request fails.
modifyHeadersrequires host access to the request’s initiator as well as its URL. - Firefox: supports redirect and
modifyHeaders, but Firefox also still allows blockingwebRequest, so a Firefox-only build can do header work imperatively with far fewer constraints. - Safari: redirect support is limited and
regexSubstitutionin particular is unreliable across versions. Prefertransformor an absoluteurlon Safari, and verify against a real device rather than the simulator. - All three: you cannot read a header value in a rule. Conditions match on the URL, resource type, initiator and tab — never on header contents.
Verification
- Load unpacked and watch matches live:
1chrome.declarativeNetRequest.onRuleMatchedDebug.addListener((info) => {
2 console.log(info.rule.ruleId, info.request.url);
3});
Execution context: the service worker, unpacked builds only — onRuleMatchedDebug is unavailable in a packed extension, which is deliberate.
- Open DevTools Network on a target page, find the request, and confirm the Request URL is the rewritten one and the header list matches your rule.
- For a
modifyHeadersrule, confirm in Network → Headers that the Provisional headers warning is absent — that warning means the request was served from cache and your rule never ran.
FAQ
Why does my redirect work unpacked but not after upload?
Almost always host permissions. An unpacked build during development often has broader access granted, while the packed build has only what the user approved. Check chrome://extensions → Details → Site access.
Can I redirect to a data: or blob: URL?
No. Redirect targets must be http, https or an extension path. Use extensionPath and ship the content as a packaged file instead.
How do I avoid an infinite redirect loop?
Make the condition impossible to satisfy after the rewrite — match on the parameter you are removing, or on the host you are redirecting away from. The browser’s five-hop guard is a backstop, not a design.
Related
- Dynamic vs static rulesets — where to put the rules you just wrote.
- Debugging rules that don’t match — the tools for a rule that never fires.
- Migrating webRequest to declarativeNetRequest step by step — porting imperative header logic.
- declarativeNetRequest rules — the parent guide to request filtering.