Checked on 2026-08-24.
A framing header is one line in a response. The browser reads it, refuses to display the page, and blanks the frame. That isn't permanent: the header lands in your own browser, and software running in your own browser can delete the line before the decision happens. That's what a header-rewriting extension is for, and on Chrome there's now exactly one supported way to build one.
It's narrower than it looks, and when we measured one of its rule conditions it matched nothing at all in the case we cared about.
What Manifest V3 took away
Under Manifest V2 you registered a listener, marked it blocking, and Chrome handed you every request to read and rewrite. Chrome's summary of Manifest V3 gives the reason that stopped: "We are deprecating the blocking version of the webRequest API. This required extensions to proxy all network traffic to provide filtering capabilities, which came at a performance and privacy cost."
The migration guide is specific about the performance half. "In Manifest V2, blocking web requests could significantly degrade both the performance of extensions and the performance of pages they work with," it says, because "The webRequest namespace supports nine potentially blocking events, each of which takes an unlimited number of event handlers." The guide stops there; our own reading is that each listener ran in the extension's own process, with a page waiting on several extensions at once. "Manifest V3 guards against this problem by replacing callbacks with declarative rules."
The webRequest reference states the outcome for webRequestBlocking flatly: "As of Manifest V3, the ... permission is no longer available for most extensions." Observation survives; interception doesn't. One exception remains, and it isn't for the public — "Policy installed extensions can continue to use" the blocking permission.
declarativeNetRequest is the replacement, and the trade sits in its opening paragraph. It "lets extensions modify network requests without intercepting them and viewing their content, thus providing more privacy." You describe conditions and actions in JSON. Chrome does the matching. You don't see the response.
The rule that deletes a framing header
A framing refusal is a response header, so you want modifyHeaders with a responseHeaders array. Three operations exist. remove "Removes all entries for the specified header." set "Sets a new value for the specified header, removing any existing headers with the same name." append "Adds a new entry for the specified header," and on the request side it's restricted — Chrome's reference says "The append operation is only supported for the following request headers" and then lists twenty-one of them, cookie and user-agent among them. Nothing similar constrains what you remove from a response.
So the whole trick is small:
{
"id": 1,
"priority": 1,
"action": {
"type": "modifyHeaders",
"responseHeaders": [
{ "header": "x-frame-options", "operation": "remove" },
{ "header": "content-security-policy", "operation": "remove" }
]
},
"condition": {
"resourceTypes": ["sub_frame"],
"initiatorDomains": ["example.com"]
}
}
Two details deserve a second look, and the last line is a trap. Take the blunt one first.
You can't remove a directive. You remove a header. frame-ancestors travels inside Content-Security-Policy alongside whatever else that policy says, so deleting the header to win the frame also deletes the site's script and style restrictions for that document. There's no operation that drops one directive and keeps the rest.
You also can't delete just one of them and be finished. Of the 60 popular apps we measured, 53 refused to be framed: 42 sent X-Frame-Options, 24 sent a frame-ancestors directive, and 13 sent both. Remove one header from a site in that overlap and the other still refuses.
Static, dynamic, and session rules
Chrome offers three ruleset types. Lifetime separates them first. Static rules are "Packaged, installed, and updated when an extension is installed or upgraded" — they ship in JSON files named in the manifest. Dynamic rules "Persist across browser sessions and extension upgrades..." Session rules are "Cleared when the browser shuts down and when a new version of the extension is installed."
The quotas are generous, and worth knowing early. An extension may declare 100 static rulesets, enable 50 at once, and rely on at least 30,000 static rules. Session rules cap at 5,000, safe and unsafe alike. Dynamic rules cap at 30,000 — but only for what Chrome calls safe rules, which are "rules with an action of block, allow, allowAllRequests or upgradeScheme." A modifyHeaders rule isn't one of those, so it counts against the unsafe limit of 5,000 instead. Regular-expression rules cap at 1,000 per type, and each rule must compile to under 2KB.
One asymmetry matters more. The tabIds and excludedTabIds conditions are "Only supported for session-scoped rules." A static rule can't be scoped to a tab. Remember that; it becomes the fix.
The condition that matched nothing
initiatorDomains looks like the right way to keep your rule off everybody else's browsing. The reference describes it plainly: "The rule will only match network requests originating from the list of initiatorDomains. If the list is omitted, the rule is applied to requests from all domains." A note adds that "This matches against the request initiator and not the request url." Your page creates the frame, so your page is the initiator: name your domain and the rule won't touch anyone else.
In August 2026 we measured otherwise. The finding is ours, not Chrome's, and it's worth stating exactly. On 2026-08-14, debugging an unpacked build of the TulipLot Companion extension in Microsoft Edge 151, a rule conditioned on initiatorDomains never fired for a frame that the page itself created. A main-world control frame, with src set before the element went into the document, was still refused. The same frame rendered the moment the initiator condition came out of the rule and nothing else changed. Frames injected from the extension's own isolated world did match, which is what had made every earlier experiment look successful.
Chrome doesn't document this. We read the current reference again while writing, and it doesn't carry an exception for frames the page creates — the two sentences quoted above are the whole specification of the condition, and read plainly they promise a match. So treat this as one measurement, not a rule of the platform: one Chromium browser, one version, one afternoon. We haven't reproduced it on Google Chrome itself, and we haven't confirmed it as a Chromium defect.
Two documented facts sit next to it without explaining it. MDN says "The initiator of a request is usually the document or worker that triggered the request" — usually is MDN's word, not ours. And Chrome's webRequest reference makes the initiator optional on every event, noting that "If this is an opaque origin, the string 'null' will be used." A request whose initiator isn't what you assumed is a shape the platform already allows for.
Don't expect the debugging tools to settle it either. testMatchOutcome "Checks if any of the extension's declarativeNetRequest rules would match a hypothetical request," but you supply the initiator yourself, so it answers the question you already believe. It's also "Only available for unpacked extensions..." Its own field documentation hedges in the same direction: the initiator is "The initiator URL (if any) for the hypothetical request."
Two conditions express what people usually mean by only on my site. tabIds scopes a session rule to specific tabs, which is what shipped here: one session rule, resourceTypes of sub_frame, and the tab IDs of open dashboard tabs. Since Chrome 145 there's also topDomains, which matches "when the associated top-level frame's domain matches one from the list of topDomains" — the top-level frame, not the initiator. It isn't initiator-free everywhere, though: "For requests with no associated top-level frame ... the request initiator's domain is considered instead." We haven't tested topDomains against this case, so treat it as the condition that names what you meant, not a verified fix.
Why it asks about every site separately
Permission is the other constraint, and it's the one users notice. Chrome ships two permissions with, in its words, "the same capabilities." Declaring declarativeNetRequest "Triggers a permission warning at install time but provides implicit access to allow, allowAllRequests and block rules." Declaring declarativeNetRequestWithHostAccess means "A permission warning is not shown at install time, but you must request host permissions before you can perform any action on a host."
MDN draws the line where it actually falls. The first permission "allows extensions to block and upgrade requests without any host permissions," but "Host permissions are required if the extension wants to redirect requests or modify headers on requests..." Header rewriting sits on the side that costs a host permission. MDN also notes an exemption worth knowing: "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."
So a header-rewriting extension has two honest options: ask for every site up front, or ask per site. Per site means listing broad patterns under optional_host_permissions and calling permissions.request() from a user gesture. Chrome "prompts the user if adding the permissions results in different warning messages than the user has already seen and accepted," which in practice is one dialog per new host. That friction is the feature.
What the deletion still can't fix
A logged-out page. Removing the framing header buys you a frame that paints. It buys nothing about session state, which is governed by SameSite, third-party cookie blocking, and partitioning — a separate set of rules, described in why a site logs you out inside an iframe. We haven't tested whether a rule that rewrites Set-Cookie changes any of that, and we're not going to guess.
A refusal on the request. If the server reads Sec-Fetch-Dest and answers 403, there's no page and therefore no response header to delete. That article carries the asymmetry in full: a proxy sits on the request side and can rewrite what the server is told; an extension editing a response arrives after the decision.
Chrome's own note makes the timing concrete: rules with a response-header condition run after delivery, and the reference warns that "the request has already been sent to the server and the server has received data like the request body."
Questions
Can an extension unblock any site?
No. It handles sites that refuse by header, and 53 of the 60 we measured do — but it won't touch logins that redirect to another host, or pages that compare window.top with window.self. Can I embed this site in an iframe? has the per-site results.
Static rule or session rule?
Static rules are simpler and survive restarts. Choose a session rule when you can't fix the scope until runtime — a tab ID is the clear case, since tabIds works nowhere else.
My rule works in a test page but not in production. Why?
Check what creates the frame. Our own bug looked exactly like this in Edge 151: a frame injected by extension code matched the rule, and the identical frame created by the page didn't.