Keeps self-hosted apps in lockstep with logged-in browser sessions, for sites you approve one at a time, so nobody ever pastes a cookie out of DevTools again.
Any app can use it. You connect one by typing its address; it publishes a profile saying which sites it needs a session for, and the browser asks you about each of those by name before anything is read.
Generalized from the single-purpose rocketmoney-mcp/extension, which has been
doing exactly this against the hardest possible target (a session that rotates
on every response) since July.
The extension seeds. The receiver keeps.
| Extension (browser) | Receiver (your server) | |
|---|---|---|
| Fires when | the jar changes, plus a 15-min heartbeat | continuously, on its own clock |
| Job | push the current jar, once | persist it, follow rotations, prevent idle expiry, report liveness |
| If it is offline | nothing is lost; the receiver carries on | the session eventually dies and pings you |
That split is the whole design. The extension makes no attempt to refresh or renew anything, so it does not matter that it only works in the one browser it is installed in, or that the browser is closed most of the day. It is a seeder.
The receiver refreshes on its own flow: every real request it makes goes
through the persisted jar and absorbs any Set-Cookie that comes back, and a
keepalive probe touches the session on a timer so it never ages out between
uses. Recovery is the nice part - when a session finally does die, the ntfy
alert's only instruction is "open the site in your browser," and the extension
reseeds automatically.
Session cookies worth brokering are HttpOnly, which exists precisely to stop
page scripts from reading them:
$ curl -sI https://www.qobuz.com/us-en/discover | grep -i set-cookie
set-cookie: qobuz-session=...; path=/; secure; httponly
document.cookie cannot see that. Only the extension chrome.cookies API,
which sits below the HttpOnly boundary, can. There is no lighter-weight option.
This is, structurally, an infostealer. Treat it like one:
- Never list it in an extension store. Load unpacked, always. The source is public so it can be read before it is trusted, which is the opposite of a store listing: that would put an auto-updating binary holding live session tokens on other people's machines, and hand whoever holds the publisher account a supply chain into every one of them. Read this, then build from what you read.
- Chrome's permission dialog is the security boundary. Nothing is granted
by installing a profile. The extension works out which hosts that profile
would need, shows you the list, and asks Chrome, which asks you by name. A
profile that wants your bank has to say so in that dialog. Grants are visible
and revocable at any time from
chrome://extensions, and are asked for again rather than assumed. The manifest declares that hosts may be requested; it no longer decides which, because a fixed list cannot know about an app you install next year. - A profile is data, never code. This is the one place it deliberately
parts company with a userscript manager. Installed code inside something that
can read
HttpOnlycookies for a list of sites is not a plugin, it is every account on that list. Profiles are JSON, checked against a schema, rebuilt field by field, and anything outside the schema is dropped. - A profile cannot lie about what it is asking for.
cookieUrlhas to be part ofcookieDomain, so it cannot present itself as one site and collect another's cookies. Endpoints are paths, not addresses, so it cannot redirect a jar off the origin you consented to. A sign-in page has to belong to the site it signs you into. All three are enforced inprofile.jsand tested by breaking them. - The receiver refuses to run unauthenticated.
make_blueprintraises without$COOKIE_BROKER_TOKENrather than defaulting to open. - Tokens live in
chrome.storage.localonly, entered on the options page. Nothing secret belongs in this repo. - The banner holds nothing. A content script runs inside a page the store
controls, so it gets no token and makes no cross-origin call. It asks the
service worker to act and the worker answers with one sentence. Its reach is
the
matcheslist on thecontent_scriptsentry, which is a separate grant fromhost_permissionsand deliberately does not widen it.
Plaintext is refused rather than discouraged. A profile whose receiver is
plain http to a public address is not installable, because what travels over
that connection is a live session cookie, which is the whole account. It is
allowed to loopback, to the RFC 1918 ranges, to 100.64/10 where a tailnet
lives, and to .local, since there something else is already carrying the
encryption or the traffic never leaves your network.
extension/ Chrome MV3, load unpacked
profile.js what a profile may say, and what it may ask for. Pure, tested
registry.js what is installed right now, and which grants it holds
background.js service worker: read jar, push on change + heartbeat
appapi.js Library Wishlist client, called from the worker only
content/banner.js the store return banner, injected on store pages
content/present.js one attribute, so the app can tell the extension is here
options.html/js connect an app, manage installed profiles and their tokens
popup.html/js per-site status, manual sync, diagnostics, settings gear
ui.css shared tokens. Chrome's own palette, light and dark
icons/ generated, see tools/make-icons.py. Do not hand-edit
profiles/ example profiles, for pasting or for copying into an app
receiver/
cookie_broker.py SessionKeeper + Flask blueprint, stdlib only
docs/PROTOCOL.md the wire format. Everything an app needs to implement
tools/make-icons.py redraws the icon at every size Chrome asks for. Stdlib only
tests/ `node tests/<name>.test.mjs`, nothing to install
The second thing this extension does, unrelated to pushing jars, and asked for
by the app rather than configured here. An app that declares the
purchase-return capability in its profile gets a bar on the shops it names:
mark the URL you send a buyer to with #<marker>=<id> and the bar shows that
item, an I bought it button, and a way back to the list. Without the
extension the marker is inert and the old flow is unchanged.
It also stamps the app's own pages with one attribute, which is how the app knows to send a Buy click to this tab rather than a new one. That is what makes the return trip worth taking: going back is then ordinary history navigation, so the browser hands back the list exactly as it was, scroll position and typed filter included.
Nothing about it is configured in the extension, and nothing about it needs a
manifest edit. The profile says which shops, which marker and which paths; the
content scripts are registered at runtime from that. What the profile may not do
is say what is drawn or what is called, which is the line the whole design rests
on and is spelled out in docs/PROTOCOL.md.
Design, constraints and the manual checks: docs/store-return-banner.md.
chrome://extensions-> Developer mode -> Load unpacked ->extension/.- Set
COOKIE_BROKER_TOKENon your app and mount the blueprint (seereceiver/README.md). Until that token is set the ingest endpoint does not exist, and neither does the profile describing it. - Extension Options -> Connect an app -> type its address.
- Chrome asks whether this extension may contact that address. Allow it, and the profile is read and shown to you: the app's name, and every host it wants.
- Paste the token, click Install, and Chrome asks a second time, naming each site. That list is the whole cost of installing; read it.
- The popup should read synced with
server: live.
Nothing is stored before both of those questions are answered, so a dismissed prompt leaves nothing behind.
The badge marks trouble and says nothing otherwise: out logged out
somewhere, cfg missing a token, err a push failed. Working is the state
this spends nearly all its life in, so it gets no badge at all; a mark that is
always there is a mark nobody reads. Open the popup for the reason, and for the
difference between healthy, idle and nothing-installed.
Nothing here changes. Publish a profile from the app:
GET https://your-app.example/.well-known/cookie-broker.json
Someone types your app's address in Options and the rest follows. The full
format, and what an app has to do with the jar once it has one, is
docs/PROTOCOL.md. profiles/library-wishlist.json is a working example.
Leave receiver.base out of it. The extension uses the address it fetched the
profile from, so a document that names its own address is correct for exactly
one deployment, which is usually yours and nobody else's.
If your app cannot publish a document, the same JSON can be pasted into the
options page instead. Then receiver.base is required, because nothing else
knows where the app is.
One thing worth copying if you are writing a profile for a site behind a load
balancer: read the jar at the origin the session is actually pinned to. Rocket
Money, for instance, keeps its AWSALB stickiness cookies on
client-api.rocketmoney.com rather than the app origin, and pushing the
session id without them lets requests scatter across backends and 401 after a
couple of calls.
Built and deployed 2026-08-02.
Proven end to end against a live Qobuz session: the extension read the
HttpOnly qobuz-session, pushed an 11-cookie jar, the receiver probed it
LIVE, enumerated the account's real purchases, and a later keepalive tick
with no browser involvement still reported live.
Running against Library Wishlist, which vendors cookie_broker.py and
serves /auth/ingest and /auth/status itself. Keep the token in a file the
service reads at start (mode 600) rather than inline in a unit file or a
launchd plist: an inline value is readable by anything that can read that file,
and on some systems by anything that can list processes.
One thing to check on your own deployment: the wishlist app binds 0.0.0.0 by
default, so these endpoints answer on every interface the host has, not only
the one you had in mind. The bearer token is the only thing gating them, which
is why make_blueprint refuses to construct without one.
- Does Qobuz rotate
qobuz-sessionon authenticated requests? Four requests in,last_rotationwas still unset, which suggests a stable id whose server-side expiry refreshes on access. If that holds, the keepalive touch is what matters andSet-Cookieabsorption is just insurance. - Real idle TTL is unknown. The 6h keepalive is sized well under the ~5 days the previous session survived.
- Chrome API details never verified against docs (the research agent hit a
session limit):
chrome.alarmsminimum period, and whether extension service workers face Private Network Access limits. Both are coded around rather than relied on.
AGPL-3.0-or-later. See LICENSE. Running a modified copy as a network service
means publishing your changes.