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.

Published September 18, 2026 Updated September 18, 2026 8 min read
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.

Picking a redirect targetThe four redirect target forms compared on what they can express, whether the original URL is preserved, and the condition each one requires.Target formWhat it doesNeedsKeeps the original URLurlAbsolute replacementAny conditionNoextensionPathTo a packaged fileweb_accessible_resourcesNotransformEdit scheme, host, path, …Any conditionPartlyregexSubstitutionRebuild from capturesregexFilter conditionYes, via \1 …
regexSubstitution is the only form that can carry part of the original URL into an arbitrary new one.

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.

Order of evaluation for one requestA request is matched against allow rules first, then block, then redirect; a redirected request is re-evaluated, and header modifications apply to whatever survives.PageRule engineNetworkYour extensionGET /articles/4821allow? block? redirect?redirect → reader.example.netre-match the new URLsend with modified headersonRuleMatchedDebugunpacked only
Header rules run on the final URL — a redirect earlier in the chain changes which header rules can 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\1 is 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 \1 substitutions.

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.

Choosing between urlFilter and regexFilterA decision tree: domain anchors and wildcards belong in urlFilter, captures require regexFilter, and anything needing lookaround needs a different design.What does the pattern need to express?A domain or path prefixurlFilter||host^ and *Costs no regex budgetprefer alwaysPart of the URL reusedregexFilter + substitutionnumbered capturesValidate with isRegexSupportedbefore registeringLookaround or backreferenceNot expressibleRE2 rejects itSplit into several rulesor match more broadly and allow
Reaching for regexFilter should be a deliberate spend from a budget of a thousand, not a default.

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. modifyHeaders requires host access to the request’s initiator as well as its URL.
  • Firefox: supports redirect and modifyHeaders, but Firefox also still allows blocking webRequest, so a Firefox-only build can do header work imperatively with far fewer constraints.
  • Safari: redirect support is limited and regexSubstitution in particular is unreliable across versions. Prefer transform or an absolute url on 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

  1. 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.

  1. 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.
  2. For a modifyHeaders rule, 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.

Other Core APIs & Cross-Browser Data Management Resources