Privacy Policy — Proxy Captain
Last updated: 14 August 2026
Proxy Captain is a browser extension that decides which proxy your browser uses for which site. This document says exactly what it stores, where it stores it, and what leaves your machine.
It is written against the code, not against an intention. Every claim below names the file that implements it, so you can check it yourself in the packaged extension or in the source.
The short version
- Your proxies and rules are stored on your own machine, in
chrome.storage.local. They are not uploaded anywhere. - Proxy passwords are stored in plain text in that same local store. See Credentials are stored in plain text — this is the most important sentence in this document and we are not going to bury it.
- Three things ever cause an outbound request: the connection check you press, the licence check, and your own browsing through the proxy you configured.
- There is no analytics, no telemetry, no crash reporting, no advertising identifier and no third-party script. Nothing counts how often you open the popup.
- The extension never reads, rewrites or records page content. It has no content script.
What is stored, and where
Everything Proxy Captain remembers lives in chrome.storage.local — the browser's own per-extension storage on your disk. It is one JSON document under one key (proxycaptain_state_v1) plus a backup copy (proxycaptain_state_v1.backup), written by core/storage.js through engine/adapter.js.
| Stored | Contains | Where |
|---|---|---|
| Proxies | Label, scheme (http / https / socks4 / socks5), host, port, user name, password, remote-DNS flag | chrome.storage.local |
| Rules | Type, pattern, target proxy id, enabled flag | chrome.storage.local |
| Profiles | Label, rule ids, default target | chrome.storage.local |
| Settings | Your chosen connection-check address, auth behaviour, UI preferences | chrome.storage.local |
| Licence cache | Plan, feature list, expiry, a device token | chrome.storage.local (proxycaptain_licence_v1) |
chrome.storage.local is not chrome.storage.sync. Nothing here is copied into your browser profile sync, so your proxy passwords do not travel to Google's or Mozilla's servers with your bookmarks.
Browsing history is not stored. Proxy Captain never writes down which sites you visited. Routing decisions are made request-by-request, in memory, and are not logged anywhere that survives them. There is no history view in this extension because there is no history.
Credentials are stored in plain text
Proxy user names and passwords are stored unencrypted in chrome.storage.local.
There is no encryption step anywhere in this codebase — you can confirm that by grepping the package for crypto.subtle, which appears nowhere. The stored document is a JSON string containing your password as you typed it.
This is the same thing SwitchyOmega does, and the same thing every browser proxy switcher we are aware of does, for a reason worth stating plainly: there is nowhere better to put it. A proxy password has to be readable by the extension at the moment a request needs authenticating, with no human present to unlock anything. Encrypting it with a key that also lives in chrome.storage.local moves the plaintext one indirection to the left and protects nobody. A real answer needs an OS keychain, and extensions have no access to one.
What that means for you, concretely:
- Anyone with read access to your OS user account can read the password. On macOS the file lives under
~/Library/Application Support/<Browser>/Default/Local Extension Settings/<extension id>/, on Windows under%LOCALAPPDATA%, on Linux under~/.config. Full-disk encryption protects it at rest; another logged-in process running as you does not. - Anyone with physical access to an unlocked machine can read it.
- A backup tool that copies your browser profile copies the password with it.
If that is not acceptable for a particular proxy, use a proxy that authenticates by IP allow-list instead of by password, or run a local relay that holds the credential outside the browser.
Exports redact passwords by default. exportJson and exportCsv (core/transfer.js) strip passwords unless you explicitly ask for them to be included. A redacted password is omitted rather than replaced with a placeholder — a placeholder that round-tripped through an import would become a real, wrong password — and the record is marked passwordRedacted: true so the import can tell you which proxies need a password re-entered. Export also redacts any setting whose name looks credential-shaped — the exact test is /(pass|secret|token|licen[cs]e|credential|cookie|auth|session|apikey|api_key|\bkey\b)/i in core/transfer.js, deliberately over-broad: redacting a preference that was not a secret costs you one re-entry, and missing one costs you the secret.
Credentials are never written to a log or a diagnostic panel. engine/auth.js records host, port and proxy id only. A password in a diagnostics screenshot is a password in a support ticket.
What leaves your machine
There are exactly three outbound paths. Every fetch( in the shipped code belongs to one of them; there is no other network call, no XMLHttpRequest, no sendBeacon and no WebSocket anywhere in the package.
1. Your own browsing, through the proxy you chose
This is the product. When a rule sends example.com through your SOCKS5 proxy, your request to example.com goes to that proxy, which is a machine you chose and we have nothing to do with. Proxy Captain does not operate a proxy, does not resell one, and has no relationship with whoever runs yours. We never see this traffic.
Two details you should know:
- DNS. With a
socks5h://orsocks4a://proxy, host names are resolved at the proxy. With plainsocks5:///socks4://, and with HTTP proxies in some configurations, the browser resolves them locally first — so your DNS resolver sees the host name even though the connection is proxied. Proxy Captain records which you asked for and shows it; it cannot change what the browser then does. - The generated PAC never calls
dnsResolve,isInNet,myIpAddressorisResolvable(core/pac.js). Those helpers block proxy resolution on a name lookup that is itself not proxied — callingdnsResolveto decide whether to proxy a host leaks the very host name you are trying to hide, before any connection is made. The test suite installs them as throwing stubs so a future regression fails the build.
2. The connection check — only when you press it
The check that tells you whether your browser is actually proxied works by reading your egress IP address twice: once with the proxy in the path, once with it deliberately out of the path. If the two readings are the same, the proxy is not carrying your traffic, whatever the settings say.
Reading an egress IP means asking somebody outside your machine what address your request arrived from. That is an outbound request to a third party, so:
- It only ever runs when you click it.
verify()inengine/verify.jsrequiresuserInitiated: trueand returns a refusal otherwise. It does not run on install, on startup, on a timer, or when you open the popup. There is no batching, no retry loop and no background schedule. - The endpoint is named:
https://www.cloudflare.com/cdn-cgi/trace, run by Cloudflare. Two alternatives are built into the extension and nothing else is:https://api.ipify.org?format=json(ipify.org) andhttps://icanhazip.com(operated by Cloudflare). The address is a stored setting (verifyEndpoint) and must behttps://— a different address is refused. This version ships no settings control for it, so unless an imported configuration carries a different (valid) address, every check goes to the Cloudflare endpoint — and whatever the setting holds, nothing is contacted until you press the button. - What that endpoint receives is what any web server receives from any request: your IP address as seen from the internet — which is the entire point of asking — plus your browser's user agent and TLS fingerprint. It is a plain
GETwith no query beyond the format flag, no API key and no account. - What is deliberately not sent: the request is made with
credentials: 'omit', so no cookie of yours goes with it, and withredirect: 'error', so the endpoint cannot walk the request to a host you never agreed to. No part of a proxy record beyond its internal id ever reaches the result, and no host name from your rules is included. - Cloudflare, ipify and icanhazip are third parties with their own policies. We do not control what they log. That is exactly why this document names the address in full rather than calling it "a third-party service" — and why the bullet above tells you the address is pinned in this version instead of implying you can steer it. To be unambiguous, because an earlier draft of this file managed to say both things four lines apart: the extension shows you no screen anywhere that displays or edits the check address. The only places it is written down are this policy and one line of source,
engine/verify.js. If a build ever adds that screen, this sentence changes in the same commit.
An egress-IP check reveals to that endpoint that this browser is running a proxy tool. Doing it silently would be a telemetry channel nobody asked for. That is why it is a button and not a background job.
3. The licence check
Proxy Captain asks https://api.montereystudio.com whether this browser holds a paid licence (shared/licence.js). This happens when you pair a browser to a licence, on a periodic refresh (hourly alarm, cached for six hours), and when you open the upgrade screen.
- Sent: the product slug (
proxycaptain), a random device token generated on this machine, and — during pairing only — the short pairing code you type in. - Not sent: your proxies, your rules, your host names, your browsing, your IP-check results, or anything derived from them. The licence client has no access to your rule state and never reads it.
- Cached, and works offline. The answer is stored locally with a seven-day grace window, so the extension keeps working when the network is down. Free features never wait on this call at all — the global toggle and JSON import/export answer from a local constant, so an unpaired browser with no network can still turn its proxy on.
Payment, if you ever buy anything, is handled by Stripe. We never see your card number.
Permissions, and why each one is needed
| Permission | Why |
|---|---|
proxy | The whole product. Applies the generated PAC through chrome.proxy (Chromium) or answers proxy.onRequest (Firefox). |
storage | Saves your proxies, rules and profiles locally. Nothing else. |
webRequest | Carries one listener and only one: onAuthRequired. There is no onBeforeRequest handler, no onBeforeSendHeaders handler, and nothing that reads a request or response body. |
webRequestAuthProvider (Chromium) / webRequestBlocking (Firefox) | Answers the HTTP 407 Proxy Authentication Required challenge with the credentials you stored, for your own proxy only — the reply goes out only when details.isProxy is true and the challenging host and port match a proxy you saved, so a website or a captive portal can never be handed them. The two stores' builds differ here because the platforms do: Chromium MV3 exposes proxy auth through webRequestAuthProvider, Firefox through a blocking listener. |
<all_urls> | A proxy rule can be written for any host, so the routing decision has to be available for any host. <all_urls> is also what makes onAuthRequired reach us for the proxy challenge. It is not used to inject scripts or read pages — there is no content script in this extension at all. |
tabs | Exactly one thing needs this permission: popup/popup.js reads the host name of the active tab so the popup can offer "add a rule for this site". Non-web tabs (chrome://, about:, file://) are dropped before anything is read. Tab history and tab content are never accessed. (shared/licence.js also calls chrome.tabs.create twice, to open a purchase or pairing page in a new tab. That call needs no permission at all and cannot read anything — it is listed here only so that grepping for tabs. and finding three hits does not look like an omission.) |
alarms | Used only by the licence client: an hourly alarm that re-checks the cached licence answer only once it is more than six hours old, and a short-lived poll while you are pairing a purchase. Nothing about routing uses a timer, and the connection check cannot be scheduled at all. |
Requesting <all_urls> is close to the heaviest permission a browser extension can ask for, and we would rather explain it than hope you did not notice.
Data we do not have
- We do not have your browsing history.
- We do not have your proxy passwords — they never leave your machine.
- We do not have your IP address, except as any web server has it when you contact ours to check a licence.
- We do not have an analytics account, a session recorder or an error reporter.
- We do not sell, rent or share anything with anybody, because there is nothing to sell.
Deleting everything
Uninstalling Proxy Captain removes chrome.storage.local for the extension, which is every proxy, rule, profile and setting it ever held. There is no server-side copy to delete because there was never a server-side copy.
If you paired a paid licence, that pairing exists on the licence service. Write to the address below to have the licence record removed.
Children
Proxy Captain is a developer and network tool. It is not directed at children and does not knowingly collect anything from anybody, of any age.
Changes
Material changes to this document will be published in the extension's release notes with the version they take effect in. The "last updated" date above is the authority.
Contact
Monterey Studio — privacy@montereystudio.com
Verify this yourself
Every claim above is checkable in the packaged extension:
grep -ran --exclude=PRIVACY.md \
"fetch(\|globalThis.fetch\|XMLHttpRequest\|sendBeacon\|WebSocket" .
grep -ran --exclude=PRIVACY.md "crypto.subtle" # no hits — nothing is encrypted
grep -ran --exclude=PRIVACY.md "storage.sync" # one hit: a comment in engine/adapter.js
# explaining why sync is NOT used. Nothing syncs.
grep -n "content_scripts" manifest.json # no hits — there is no content script
Neither flag is decoration.
-a, as insurance. No file in this package needs it today — on the current build grep -a and plain grep return identical results, and you should check that rather than take our word for it. It stays in the recipe because the failure it guards against is silent: a binary byte anywhere in a file causes grep to report nothing for that file instead of reporting a problem, and a verification recipe that can go quietly blind is worse than no recipe at all.
--exclude=PRIVACY.md, because this file ships inside the package you are grepping — it is not in the packaging exclusion list, so the zip you install carries it — and the prose you are reading quotes every term the recipe searches for. Without the exclusion the first command returns three groups rather than two, crypto.subtle returns two hits rather than none, and storage.sync returns three rather than one: all of the extra hits this document talking about itself, none of them code. Excluding it is the difference between a command whose output matches the sentence under it and one that does not.
The first grep returns two groups and no third:
- six hits in
shared/licence.js, of which five are calls — every one of the five a request tohttps://api.montereystudio.com(theAPI_BASEconstant near the top of that file), which is path 3 above. The sixth, atshared/licence.js:122, is the string `fetch(` inside a comment. It is counted here rather than quietly subtracted, because a document that tells you to run a grep and then reports a different number than the grep prints is asking to be trusted instead of checked; - two adjacent lines in
engine/verify.js— a capability check andglobalThis.fetch.bind(globalThis), insidecreateVerifier. That is the connection check, path 2. It reachesfetchthrough an injected implementation (fetchImpl) so the module can be tested without a network, which is why the literal stringfetch(does not appear in that file at all. Its endpoints are the three named inENDPOINTSat the top of that file, plus a validhttps://address carried in an imported configuration.
core/, popup/, options/ and sw.js contain no network call of any kind.