Security
Last updated: October 23, 2026
What BrowserHat signs and with which key, what is not signed yet, what the desktop app sends from your computer, and how the password vault is stored. Where something is not done or not measured, this page says so. You can check both signatures yourself with the commands below.
Desktop app updates: signed with Ed25519
Every update bundle has a manifest that lists each file with its SHA-256 hash and size. The manifest is signed with Ed25519. Before applying an update, the app checks the signature with a public key built into the installed app (it changes only with a full reinstall), checks that the manifest is for the offered version and channel and is newer than the running version (no downgrade), and checks every file against its hash.
The small pointer file that names the current version (latest.json) is not signed. It can only offer a version; the signed manifest decides what is installed.
The BrowserHat browser core is downloaded as an archive whose SHA-256 is fixed inside the signed update bundle.
Update public key, SHA-256 of the DER-encoded key: 5cbf329c5b52777118f96ff71314ed6457438971aa722b5c1eae16ab97cc5cab. To check the current stable manifest yourself (Linux shell, OpenSSL 3.0 or newer):
# Update public key (Ed25519)
printf -- '-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAlE8Fc8HY6Kut8uC1CzRuoNmOjI2NJDVhgtYkVFSEug4=\n-----END PUBLIC KEY-----\n' > ota.pem
openssl pkey -pubin -in ota.pem -outform DER | sha256sum
# expected: 5cbf329c5b52777118f96ff71314ed6457438971aa722b5c1eae16ab97cc5cab
# Current stable manifest and its signature
V=$(curl -s https://dl.browserhat.com/bundles/stable/latest.json | sed 's/.*"bundleVersion":"\([^"]*\)".*/\1/')
curl -sO https://dl.browserhat.com/bundles/stable/$V/manifest.json
curl -sO https://dl.browserhat.com/bundles/stable/$V/manifest.sig
base64 -d manifest.sig > manifest.sig.bin
openssl pkeyutl -verify -pubin -inkey ota.pem -rawin -in manifest.json -sigfile manifest.sig.bin
# expected: Signature Verified Successfully
# Or with Node.js instead of the last two commands:
node -e "const c=require('crypto'),f=require('fs');console.log(c.verify(null,f.readFileSync('manifest.json'),c.createPublicKey(f.readFileSync('ota.pem')),Buffer.from(f.readFileSync('manifest.sig','utf8').trim(),'base64')))"
# expected: trueMarketplace packages: signed with key mk-2026-1
Each marketplace package is signed with a separate Ed25519 key, mk-2026-1, when our staff publish it. The signature covers a short statement: package name, version, browser engine, SHA-256 and size. Every time, the desktop app rebuilds that statement from the bytes it actually downloaded or holds in its cache, and refuses to install a package whose signature is missing, made by an unknown key or does not match.
What this protects against: a package changed after publication - on our file storage, in the delivery service, on the CDN or in the app cache. What it does not protect against: someone who takes over our backend server or a staff account. Whoever can publish a package there can also get it signed. Old signed versions also stay valid: a compromised delivery service could still serve an older, genuinely signed version of the same extension.
Today the marketplace has no third-party packages. Smart Paste ships inside the app and is covered by the update signature.
Marketplace public key mk-2026-1, SHA-256 of the DER-encoded key: 453da342a0c039051188ec4e75a5f8c40cad7ce9503e01797d2d647dff79c05a. To check a package, take extension_id, version, engine and signature from the marketplace delivery response and make sure its signature_key_id is mk-2026-1, save the downloaded package as package.zip, then:
# Marketplace public key mk-2026-1 (Ed25519)
printf -- '-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEASg//5q+eoEmkz9gCUCg6IbGnf7N9zgVDaQzcjuIodAw=\n-----END PUBLIC KEY-----\n' > marketplace.pem
openssl pkey -pubin -in marketplace.pem -outform DER | sha256sum
# expected: 453da342a0c039051188ec4e75a5f8c40cad7ce9503e01797d2d647dff79c05a
# From the delivery response (its signature_key_id must be mk-2026-1):
# SLUG=extension_id VERSION=version ENGINE=engine SIG=signature
SHA=$(sha256sum package.zip | cut -d' ' -f1)
SIZE=$(wc -c < package.zip | tr -d ' ')
printf '{"engine":"%s","sha256":"%s","size":%s,"slug":"%s","v":1,"version":"%s"}' "$ENGINE" "$SHA" "$SIZE" "$SLUG" "$VERSION" > statement.json
printf '%s' "$SIG" | base64 -d > statement.sig
openssl pkeyutl -verify -pubin -inkey marketplace.pem -rawin -in statement.json -sigfile statement.sig
# expected: Signature Verified SuccessfullyWhere the signing keys are
The private update key and the private marketplace key are not in our source code. Both are kept on our production server, which also builds releases and serves downloads; the owner also holds copies of the update key outside that server. A valid signature proves that a file left that server unchanged. It does not protect you if that server itself is compromised.
What is not signed yet
The Windows installer has no Authenticode code-signing certificate, so Windows SmartScreen and antivirus software may warn about it. When the app installs a full update, it checks the installer against the SHA-512 hash from our update service.
The SDK package on dl.browserhat.com comes with a SHA-256 file from the same server and no signature.
Extensions installed from the Chrome Web Store are checked against the SHA-256 that the Google update server returns for the .crx file. BrowserHat does not verify the CRX signature itself.
An extension file you load yourself through the SDK is your own file; BrowserHat does not check it.
What the desktop app sends
While you are signed in, a heartbeat to our server every 15 seconds. It keeps your session alive and lets us block an account used for abuse. The server records your IP address with sign-in events (code requested, failed or successful sign-in, sign-out) and with app start and quit.
Profile inventory, when it changes and at least every 30 minutes: for each profile its ID, operating system, browser engine, proxy country, and creation and last launch time. No profile names. This is service telemetry; the analytics switch does not turn it off.
Update checks at start and every 2 minutes: platform, architecture, update channel, app version and Electron version.
Key backup, if it is on (see Password vault): an encrypted copy of your data key, uploaded when needed (a new key, another account or a missing copy) and checked against our server at most every 6 hours.
Team policy: while you are signed in, the app reads your team list from our server every 6 hours and when its window gets focus (at most every 10 minutes), to see whether a team requires the key backup. This happens whether the backup is on or off.
Website logos in the password vault: the local service asks icons.browserhat.com for the domain of an entry (only the domain), again after 30 days, or after 24 hours if the domain had no logo. Turn this off with Website logos in the vault.
Product analytics through PostHog (United States): app start and quit, sign-in and sign-out, profile created, launched, stopped, deleted, restored and purged, update events, screens viewed and marketplace installs and uninstalls. Events are tied to your account ID and email and are sent in batches about every 10 seconds. On by default; turn it off in Settings (Usage analytics) or with BROWSERHAT_TELEMETRY=off.
Marketplace: opening it loads the catalog from market.browserhat.com. Catalog and install requests are sent with your account sign-in; installing a package also sends the browser engine, the core version and the ID of the profile it is for.
Connection checks. When you check or save a proxy, the local service connects through that proxy to ip-api.com and ipwho.is to see the exit address and its country; if ipwho.is does not answer, it asks ipinfo.io instead, through the same proxy. The same check also connects through the proxy to os.browserhat.com, our server that tells from the exit's own TCP connection which operating system the exit runs, and to www.google.com, to test that HTTPS works through the proxy. When you check a VLESS server, and when you launch a profile bound to one whose time zone is not known yet, the same requests go through the VLESS tunnel, so these services see the tunnel's exit address. While a profile that uses a proxy with a sticky session is running, it repeats this check about 20 and 60 seconds after launch and then every 5 minutes, to notice if the exit address changes. Before launching a profile with a sticky ProxyHat session whose session time has run out, it checks the proxy again; if Match the exit to the persona OS is on and the exit OS differs, it picks a new session and checks again, up to 5 checks in all. While the sessions screen is open, it also asks ipwho.is directly for your own internet address (and also through your system proxy, if one is set; the answer is reused for 90 seconds); if ipwho.is does not answer, it asks ipinfo.io instead, also directly from your real address. If it finds a VPN on your computer, it also asks api.ipify.org, checkip.amazonaws.com, icanhazip.com and ifconfig.me, and if they return other addresses, it looks up the country and network of each one directly at ipwho.is, or directly at ipinfo.io if ipwho.is does not answer. The same direct check also runs when you launch a profile without a proxy while Direct sessions take geo from the VPN exit is on, unless a recent answer is cached.
UDP check before launch. Before launching a profile whose proxy is SOCKS5 or a VLESS tunnel, unless WebRTC is turned off for it, the local service checks that the proxy passes UDP. Through the proxy's UDP association (SOCKS5 UDP ASSOCIATE) it sends a STUN request to stun.l.google.com:19302, or to stun.cloudflare.com:3478 if Google does not answer, and through a second association a DNS query for that server's name to 1.1.1.1:53. These servers see the proxy's exit address, not yours. A successful result is reused for 30 minutes; a proxy that does not pass UDP is not checked again for 24 hours. Measuring a VLESS server's latency (on the VLESS screen, automatically for the listed servers, or when you check a profile's VLESS connection) sends an HTTP request to cp.cloudflare.com/generate_204 through that server's tunnel.
Only when you ask for it: bug reports (a screenshot of the app window, a copy of the app window with password fields blanked, recent app errors and request addresses), messages to the AI assistant, and cloud sync of a profile you turned it on for.
None of the above contains the content of pages you open in profiles, the addresses you visit, your cookies or your passwords, with one exception: cloud sync uploads the whole profile, cookies included. Synced profiles are encrypted with keys held by our server, so they are not end-to-end encrypted: whoever controls our server could decrypt them.
What the browser itself contacts
The BrowserHat browser core is built from Chromium. We measured it on October 20, 2026: core build dev.55 started directly, not through the app (so without the settings the app passes to it), a fresh profile, no proxy, a blank tab, 10 minutes idle. In that time it contacted only Google services: the component updater (update.googleapis.com, edgedl.me.gvt1.com), Safe Browsing list updates (safebrowsing.googleapis.com), a time check (clients2.google.com), accounts.google.com and www.google.com. It contacted no BrowserHat server.
On the same day we repeated the measurement with a proxy, passed to the core the way the app passes a profile's proxy (--proxy-server), using a local proxy that logs every host: all of these requests went through the proxy, and no connection bypassed it. The only direct socket was the standard Chromium IPv6 reachability check, which points a UDP socket at a Google DNS address and sends no data. We keep these background services on, as in regular Chrome, so for a profile with a proxy they reach Google from the proxy's address (about 140 KB in 10 minutes in our measurement).
WebRTC and UDP. For a profile whose proxy (SOCKS5, or a VLESS tunnel) passes UDP, WebRTC goes over UDP through the proxy: the core hands its UDP to a relay that the local service runs on 127.0.0.1, and the relay forwards it through the proxy's UDP association (SOCKS5 UDP ASSOCIATE). The core then also looks up the names of STUN and TURN servers through that relay: the DNS query for the name goes to 1.1.1.1:53 through the proxy. In every other case with a proxy (an HTTP or HTTPS proxy, Tor, a proxy that does not pass UDP, or a UDP exit address that differs from the TCP one), WebRTC uses only TCP through the proxy and sends no UDP.
Custom DNS. If you choose your own DNS server (DoH) for a profile without a proxy, the core sends the names of the sites that profile opens to that server over HTTPS instead of to your system resolver (your provider or VPN). It looks up the DoH server's own name once through the system resolver, unless you enter the server as an IP address. If that server does not answer, pages do not open: their names are not sent to the system resolver instead (Chromium may still look up connectivitycheck.gstatic.com there to check the connection). With a proxy, VLESS or Tor the setting does nothing, and names are resolved as for any profile with that connection: at the exit, except with a SOCKS4 proxy, which cannot carry names, so the system resolver answers. Firefox profiles ignore the setting. On a Windows computer joined to a domain or with parental controls, Chromium may ignore it too and use the system resolver.
Password vault and local data
The password vault, imported cookies, proxy settings and VPN subscriptions are stored on your computer encrypted with AES-256-GCM, under a 32-byte data key created once per installation.
Without a master password, the data key is protected by the operating system (DPAPI on Windows) plus an extra layer of our own. That stops malware that only copies files. It does not stop malware running under your account: such malware can unwrap the key through the operating system at any time.
With a client master password, the data key is protected by a key derived from your password with scrypt (N=2^16, r=8, p=1), and you get a recovery key. The operating-system copy of the key is removed. Encryption still does not protect against malware running on your computer while the app is unlocked.
Key backup with BrowserHat is on by default. A copy of the data key is encrypted to a BrowserHat recovery key whose private half is kept offline, and is stored on our server; support can restore your access after verifying your identity. This means the vault is not strictly zero-knowledge. You can turn the backup off in Settings > Security > Key backup with BrowserHat; the copy is then deleted from our database, and daily database backups that still contain it are deleted after about two weeks. While a recovery request for that device is open, the copy stays until the request is closed. A team owner can make the backup mandatory for team members.
The password vault is not synced to the cloud.
Profiles and sign-in
Each profile runs with its own browser data folder (cookies, storage) and its own proxy setting.
Sign-in is passwordless: a one-time code sent to your email.
Profile password. A password on a profile is an access gate in the desktop app and the local daemon, not encryption: the profile files on disk are not encrypted with it, so without a client master password they are protected exactly as they would be without a profile password. Without the password, or an unlock earlier in the session, the profile cannot be launched (from the SDK and MCP too), exported, copied, renamed or moved to the trash, and its cookies and saved passwords can be neither read nor replaced. One exception, only while cloud sync is on for the profile: the app may take an encrypted sync snapshot of it without the password for 10 minutes after its session ends or after its password is changed, and after an automatic lock also for the rest of a session that was already running. Within that window any program holding the local daemon key on this computer could take a snapshot too. This means that cookie and password writes from the SDK and MCP into such a profile need an unlock first. Signing out or deleting your account first stops every running profile that has a password and, if cloud sync is on for it, publishes its session, and a profile that was starting at that moment is closed as soon as it starts, so its browser and its local debugging port do not stay open. An automatic lock (idle time, screen lock, sleep or locking by hand) only closes access: running profiles with a password keep working until you close them, unless Stop sessions when locking or Close password-protected profiles on lock is on in Settings. With the second setting an automatic lock closes every running profile that has a password and, if cloud sync is on for it, publishes its session; other profiles keep working. An unlock lasts until the app is locked, you sign out or you quit. Without the password the app and the local daemon show the profile name, system, browser, tags and status, but its fingerprint, proxy and connection settings stay hidden, and its session settings open only after the password. There is no recovery through our server: with a client master password you can remove a forgotten profile password, and that removal proof works only for the current password; without one, the only way out is to delete the profile, which works without the password. The password is no barrier for whoever holds the key to the cloud versions of the profile: our server (through key escrow) and your team members can open a synced snapshot whole, cookies included, and can guess the password offline, so it is only as strong as the password itself. A team member with that key can also publish a version that removes the password; if it is stamped no more than a day ahead, it wins on every device. Devices merge password changes by their clocks. If a device clock runs more than a day ahead, removing the password does not reach the other devices while that lead lasts: they stay locked with the old password. To get out of it, remove the password again on each device where it stayed, or remove it again after the clocks agree; that removal takes effect within at most 30 days. A clock running ahead can also let an earlier removal win over a password set later on another device before that device synced, and a device more than 30 days ahead ignores passwords set later. You set, change or remove the password of your own profile; on a profile shared by a team only the team owner can do this, our server confirms that role, and team members cannot. A team owner who does not know the password a team member set can remove it only with their client master password (the removal above); without one, they cannot.
Reporting a vulnerability
If you discover a security issue, please contact us at [email protected] so we can investigate. A formal disclosure policy will be published before general availability.