Published “Header Relay”: A Chrome Extension to Forward Response Headers to the Next Request Under MV3
I published Header Relay, a Chrome extension for API development and testing.
- Chrome Web Store: https://chromewebstore.google.com/detail/olmfkdclaloaacojbgaabokehlbphilf
- Official site: https://hsblabs.github.io/header-relay/
It captures values from response headers and automatically attaches them to subsequent requests to the target origin. The mechanism sounds simple, but Manifest V3 offers no straightforward way to do this out of the box. This post covers what problem it solves and how it was designed under those constraints.
The Problem
Some APIs expect the browser to receive a session token, trace ID, or gateway header in a response and send it back on the next request. When verifying this in a browser, there were essentially three options:
- Manual copy-paste from DevTools. You have to redo this every time the token rotates.
- Static header extensions like ModHeader. These can only inject fixed values and cannot track servers that return different values each time.
- Proxies like mitmproxy or Charles. They can track dynamic values, but a full TLS-intercept setup is overkill for something as simple as “get the browser to relay a token.”
There is a gap between options 2 and 3. Header Relay fills it. No proxy, no copy-paste — the browser forwards the headers itself.
Feature Overview
- Captured Header: Reads a specified header from the response of a target origin, stores it, and attaches it to subsequent requests
- Fixed Header: Statically attach a header on every request
- Profile: A per-origin configuration module. Multiple profiles can be enabled simultaneously
- Excluded Path: Exclude paths by glob pattern (with a built-in tester you can try before saving)
- URL Probe: Preview what headers would be applied to a given URL, even against unsaved draft settings
- Audit Logs: Local only, auto-pruned to the last 1,000 entries within 7 days. Request URLs are not persisted
The UI supports English, Japanese, and Korean.
Design
Separating Observation from Rewriting
The decision to have the browser itself relay the headers follows naturally. The hard part comes after. In the past, you could hook a blocking webRequest listener to inject headers just before a request — but Manifest V3 specifically abolished that rewriting capability. The most obvious approach is simply not an option.
So the responsibilities were split:
- Observation: A non-blocking
webRequest.onHeadersReceivedlistener watches response headers for active origins and saves configured Captured Header values tostorage.session(memory only) - Rewriting: The set of enabled Profiles and their per-profile session state is compiled into a single declarativeNetRequest session ruleset. Attachment uses an attach rule; Excluded Paths use a remove rule that strips only the headers the matching Profile would have added
No JavaScript is involved at request time — attachment is handled natively by Chrome’s DNR engine. Since session rules remain active even when the Service Worker is sleeping, this design fits well with MV3’s lifecycle constraints.
Single Compilation Pass
Origin matching, excluded path matching — these are needed by DNR rule generation, by URL Probe, and by Runtime Status display. Implemented naively, the same matching logic grows in three places. Three places will eventually drift. “Probe says it should be attached, but it isn’t on the real request” is exactly the kind of bug that comes from this.
So all decisions are consolidated in the output of compileConfig(). The enabled Profiles and session state are transformed into an intermediate representation CompiledConfig, and DNR rule generation, URL Probe, and Runtime Status display all take only this result as input. With matching logic in exactly one place, divergence between display and actual behavior is structurally difficult to introduce. Header resolution priority (e.g., fixed headers win over captured ones on name conflicts) is also consolidated in the same module.
Conflicts Are Errors, Not Silent Priority Wins
What should happen when two active Profiles try to attach the same header name to the same origin? Resolving this automatically via priority is possible — DNR rules already have a priority field, so the implementation would be easy. But in a debugging tool, not knowing which value was applied is the same as being silently broken. Nothing at all is better than an unknown value.
So Header Relay treats this as a compile error, deletes all owned rules, and falls back to attaching nothing. An error appears in the UI and Runtime Status; disabling one of the conflicting Profiles recovers the state.
Permission Model
A extension that reads network headers belongs to a category that rightly invites scrutiny. Permissions were therefore designed to be fail-closed.
The only required host permission is http://localhost/* (Chrome’s match pattern syntax does not support port-specific patterns, so all ports are covered). Everything else uses optional_host_permissions, and the permission for a given host is requested via permissions.request() only when triggered by a user action such as saving a Target Origin or enabling a Profile.
- No DNR rules are generated for unauthorized origins.
webRequestevents are never delivered for them either. - If Chrome revokes a permission,
permissions.onRemoveddetects it, clears the captured values for the affected Profile, and re-syncs the rules.
Header Name Policy
Names that carry connection or proxy semantics — Set-Cookie (response-only), Host, Content-Length (message framing), Transfer-Encoding, and similar — are rejected at save time. On the other hand, blocking Authorization or Cookie would make the extension useless for development, so these are allowed with a UI warning (Cookie explicitly notes that it may conflict with the browser’s own cookie store).
Privacy
- Captured values are stored in
storage.sessiononly — they are cleared on browser restart, extension reload or update, Profile deactivation, permission revocation, or manual clear. The UI masks values by default. - No data is ever sent externally. Analytics events and console output are disabled in production builds.
- Request URLs are not persisted in Audit Logs (origin matching happens in memory only).
Testing the Entire Extension Headlessly
Browser extension tests tend to accumulate chrome.* API module mocks if left unchecked. To avoid this, all runtime browser interactions were isolated behind three ports: StoragePort, DnrPort, and AuditPort.
Tests build the entire extension using createTestRuntime() with in-memory adapters, then:
- Feed in a configuration
- Inject a fake response
- Assert the resulting ruleset
This entire flow can be written in Vitest with no module mocks. The in-memory DnrPort does not reproduce Chrome DNR semantics exactly — those are covered by parity tests on the ruleset semantics, and actual behavior is covered by Playwright e2e tests, forming a multi-layer approach.
Stack
- WXT: Handles MV3 extension build, HMR, and zip generation
- SolidJS + Tailwind CSS: Popup and settings UI
- TypeScript + Zod: Configuration schema
- changesets + GitHub Actions: A merge to main automatically generates a tag, GitHub Release, and store-submission zip
Closing
After installation, you can try it immediately with the default Profile for localhost:3000 (capturing x-session-token and adding a fixed x-client-id: header-relay). If you’re tired of copy-pasting from DevTools, give it a try. For static header injection, it also works as a drop-in replacement for ModHeader, which was removed from the store.
The source is not public, but bug reports and use case requests are welcome via GitHub Issues.
- Chrome Web Store: https://chromewebstore.google.com/detail/olmfkdclaloaacojbgaabokehlbphilf
- Official site: https://hsblabs.github.io/header-relay/
- Issues / Feedback: https://github.com/hsblabs/header-relay/issues
hsb.horse