Move a logged-in browser session from your desktop browser into a remote browser, encrypted, without a third party ever seeing the tokens.
You are logged in to Facebook, Instagram, TikTok on your laptop. You want the same sessions inside a CloakBrowser / Playwright instance on a server, without logging in again and without a service holding your cookies.
Session Bridge captures the session in the browser (chrome.cookies +
on-demand page injection), encrypts it with a key derived from a one-time
transfer code, ships only ciphertext through a relay you control, and applies it
on the other side through Playwright MCP.
source browser your relay remote browser
┌────────────────────────┐ ┌────────────────────┐ ┌────────────────────────┐
│ Session Bridge ext. │ ciphertext │ relay.py │ciphertext│ apply.py │
│ • chrome.cookies │ ─────────▶ │ • bearer auth │────────▶ │ • decrypt locally │
│ • scripting injection │ │ • ciphertext only │ │ • storageState │
│ • AES-256-GCM │ │ • TTL, one-shot │ │ • MCP apply + verify │
└────────────────────────┘ └────────────────────┘ └────────────────────────┘
│ ▲
└──────────────── transfer code, separate channel ─────────────────┘
Every "copy your cookies to my server" tool asks you to trust the server with plaintext session tokens. Session Bridge splits that trust in three:
| Component | Sees plaintext? | Where it runs | Trust needed |
|---|---|---|---|
| Extension | Yes, it is your browser | Your machine | Full |
| Relay | No, ciphertext only | Any cheap VPS / container | Minimal |
| Applier | Yes, it holds the session | Destination machine | Full |
Compromising the relay yields ciphertext and metadata, not sessions. The AES key is derived from a transfer code that never travels with the package.
| Popup: capture and preview | Popup: transfer code |
|---|---|
![]() |
![]() |
Cookies come through chrome.cookies, so HttpOnly and Secure values are
included and Chrome's App-Bound Encryption never gets in the way. Everything
origin-scoped (localStorage, sessionStorage, IndexedDB, Cache Storage)
is captured on demand by injecting a self-contained function into matching tabs.
There is no content script and no page-facing API.
IndexedDB uses a tagged structured-clone serializer, so Date, Blob,
ArrayBuffer, typed arrays, Map and Set survive the round trip.
JSON.stringify would have turned most of those into empty objects without
complaining.
Packages are tamper-evident: format, version, alg, createdAt,
expiresAt, transferId, target and domains are bound as AES-GCM
additional authenticated data, so re-targeting a package breaks decryption. A
package also dies after five minutes, enforced independently by the extension
and the relay.
Where a transfer is incomplete, the extension says so. Partitioned (CHIPS) cookies are dropped and counted. Origins with no open tab are listed as gaps rather than quietly skipped. After applying, the applier closes the context to force Chromium's cookie flush and then reads the cookies back; a failed verification is reported as a failure, never as success.
| State | Why |
|---|---|
| Passkeys / WebAuthn | Hardware- or platform-bound; not extractable by any extension. |
| Device-bound sessions (e.g. Google DBSC) | Bound to a device key at the TLS/session layer. Replay is rejected by design. |
| Service worker / in-memory JS state | Ephemeral, not addressable. |
| Partitioned (CHIPS) cookies | Cannot be replayed outside their top-level site. Dropped and counted. |
The extension states these in-product instead of pretending the transfer is complete.
Full instructions: docs/INSTALL.md
git clone https://github.com/rayss868/session-bridge.git
cd session-bridge
# 1. relay (stdlib only)
python3 -c "import secrets; print(secrets.token_urlsafe(32))" > relay.token
chmod 600 relay.token
python3 relay/relay.py --port 8787 --store ./data/packages --token-file ./relay.token
# 2. extension
# chrome://extensions -> Developer mode -> Load unpacked -> select extension/
# 3. applier
python3 -m pip install --user cryptography# 1. In the extension popup: enter domains, Preview, Send to relay.
# You get a package id and a transfer code.
#
# 2. On the destination machine, with the code from a separate channel:
python3 tools/apply.py \
--relay https://relay.example.com \
--id '<package-id>' \
--relay-token "$SESSION_BRIDGE_TOKEN" \
--code 'XXXX-XXXX-XXXX-XXXX-XXXX-XXXX' \
--mcp-url https://<host>/mcp \
--mcp-token "$CLOAK_MCP_TOKEN" \
--sandbox <sandbox-name> \
--takeover \
--filename session-state.json \
--verify-url https://www.facebook.com/ \
--allow-wipe--allow-wipe is mandatory because browser_set_storage_state replaces the
entire remote profile. Export every domain you care about in one package, or
use one profile per account. See Two rules that bite.
No relay? Use Copy package in the popup and --file instead of --relay.
Copying cookies across an IP change is the textbook session-hijacking signal. Meta properties in particular answer with a checkpoint instead of a session. The transfer mechanism is not the weak link. The exit IP is.
CloakBrowser supports this natively:
PLAYWRIGHT_MCP_PROXY_SERVER=http://user:pass@host:port
PLAYWRIGHT_MCP_PROXY_BYPASS=localhost,127.0.0.1
CLOAK_PLAYWRIGHT_MCP_GEOIP_PROXY_MATCH=true
geoip: true aligns timezone and locale with the proxy's exit IP rather than the
machine's. Measured behaviour on a real Indonesian proxy exit:
{"ok": true, "country": "ID", "city": "Rembangan",
"timezone": "Asia/Jakarta", "language": "id-ID", "webdriver": false}Two practical notes:
- The geoip resolver has a 20 second default timeout and resolves the exit IP
through the proxy first. Slow residential proxies exceed it, so set
CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS=60. - Free public proxies do not work for Meta. In testing, 50 of 1,093
Indonesian candidates were alive for
ipinfo.io, but Instagram and Facebook both died at the tunnel (ERR_TIMED_OUT), while Instagram loaded fine from the same machine with no proxy. Use a paid residential/mobile proxy in the source country.
Verify any proxy before trusting it:
node tools/proxy-probe.mjs 'http://user:pass@host:port'
node tools/geo-ig-probe.mjs 'http://user:pass@host:port'1. browser_set_storage_state replaces the whole profile. It clears every
cookie and localStorage entry first. Applying an Instagram-only package deletes
Facebook, TikTok, and everything else in that profile. apply.py refuses to run
without --allow-wipe for exactly this reason.
2. Cookies flush to disk only on a clean context close. Before
browser_close, the profile's Cookies SQLite database has zero rows for the
new cookies. apply.py always closes, then verifies by reading back. If
verification cannot confirm the cookies, the run is a failure.
- AES-256-GCM, key = PBKDF2-HMAC-SHA256(transfer code, random 16-byte salt, 310 000 iterations, 32 bytes). Fresh key and IV per export.
- Header bound as AAD, so origin/expiry/target tampering fails decryption.
- Relay requires a bearer token on every route except
/api/health, compares it in constant time, stores ciphertext only, refuses any package whosealgis notAES-256-GCM, enforces its own TTL, and never returns package bodies in the list endpoint. - The extension renders counts, never values.
redactForLog()scrubs value-bearing fields before anything is logged. - No content scripts, no
externally_connectable, no page-triggerable API.
Threats this does not solve: a compromised source machine, a malicious extension with cookie permissions, clipboard history, and the fact that a package plus its code is a complete credential. Details: SECURITY.md.
extension/ Manifest V3 extension, no build step, load unpacked
src/core/session.js pure logic: cookie mapping, storageState, crypto
src/core/collector.js page-context read/write, injected on demand
test/session.test.mjs 20 unit tests
test/e2e-browser.mjs real-Chromium E2E over CDP
relay/ stdlib-only Python relay + Dockerfile
tools/apply.py decrypt, stage, apply via MCP, verify
tools/proxy-probe.mjs does traffic really exit through this proxy?
docs/ARCHITECTURE.md internals and failure modes
docs/PROTOCOL.md normative package format and relay API
docs/INSTALL.md step-by-step setup
cd extension && node --test test/session.test.mjs # 20 pass
python3 relay/test_relay.py # 13 pass
node extension/test/e2e-browser.mjs # real ChromiumThe E2E script needs a Chromium that honours --load-extension. Branded Chrome
headless builds (150) ignore it; the CloakBrowser Chromium build works, and so
does any desktop Chrome/Chromium.
Measured 2026-10-25 against a real remote CloakBrowser instance, not a mock:
- Extension service worker started and the popup rendered in Chromium 146.
- A live preview read a real cookie and a real
localStoragekey from a page. - The exported package contained no plaintext cookie value.
- Relay round-trip: store → fetch → decrypt → apply.
- After apply, the remote profile's
CookiesSQLite database contained theHttpOnlycookie, and its LevelDB log contained thelocalStoragevalue. - State survived a restart of the MCP unit.
- Docker image:
/api/health200, unauthenticated POST 401, authenticated POST 201, container healthcheckhealthy.
MIT. See LICENSE.
Use this only with accounts you own. Moving a session between machines may violate a service's terms of use, and replayed sessions are frequently flagged by anti-fraud systems. You are responsible for how you use it.


