We're on Product Hunt today! Leave a comment

How it works

A technical overview for security reviewers. Everything here is verifiable against the source — including the parts that are less flattering, which we state rather than omit.

Architecture

The extension is a Manifest V3 Chrome extension that generates TOTP codes locally with OTPAuth. Generating codes, storing accounts and encrypting them involve no backend, and there are no user accounts. We run one server, api.authenticator.sh, for the free plan and Pro — described below — and it never receives a secret, an account name, an issuer or a code.

That server holds no key material for anyone's accounts: there is no key escrow and no recovery path controlled by us. If a user forgets their password and loses their recovery code, we cannot help them — by design.

Cryptography

Password protection is optional and off by default. With it on:

Cipher
AES-256-GCM, random 96-bit IV per record and per rewrite
Key derivation
PBKDF2-HMAC-SHA256, 600,000 iterations, 128-bit random salt (OWASP guidance)
Master key
256-bit, from crypto.getRandomValues, generated once; the password only wraps it
Fingerprints
HMAC-SHA256 under an HKDF-derived subkey, used to merge records across devices
Unlocked key storage
chrome.storage.session — memory only, never written to disk, cleared when the browser closes, unreachable from content scripts
Auto-lock
Every open / 5 / 15 / 60 minutes idle / until the browser closes
Implementation
WebCrypto only. No primitive is hand-rolled and no crypto library is bundled.

Why two levels of key

Data is never encrypted with a password-derived key directly. A random master key encrypts the records; PBKDF2 output only wraps that master key. Two consequences follow, and both matter:

  • Changing the password rewrites 32 bytes instead of re-encrypting every record, every backup and every export. That bulk rewrite is where storage systems lose data.
  • A 160-bit recovery code can wrap the same master key independently, so a forgotten password is recoverable. The code is shown once, must be typed back to confirm, and is rotated after each use. Nothing is written to disk until the user has confirmed it.

What is encrypted, and what is not

With password protection on, the entire account record is encrypted except its identifier and fingerprint — including the service name, so a stolen profile does not reveal which services the user holds accounts with. Enabling it encrypts the records, verifies them by decrypting and comparing, and only then removes every cleartext copy: browser local storage, sync, and all seven rolling backup snapshots.

Stated plainly, because it matters for your assessment:

  • With password protection off, accounts are stored unencrypted and, if Chrome Sync is on, replicated through the user's own Google account. We never receive them, but Google holds them. Sync can be switched off, which also removes what is already there.
  • The per-site usage history — which account is used on which domain — is stored locally and is not covered by the vault while it is being collected. Enabling password protection deletes it; it can also be switched off independently.

Threat model

Protects against

  • A stolen or copied browser profile directory
  • Infostealer malware that exfiltrates browser data in bulk
  • Someone with physical access to an unattended machine
  • Account records reaching Google through Chrome Sync
  • A backup file being lost or stolen (password-protected export)

Does not protect against

  • Malware running as the user while the vault is unlocked
  • A keylogger capturing the password as it is typed
  • A compromised browser or operating system
  • Compromise of the third-party services the codes are for
  • An unencrypted export you chose to write — plain JSON, otpauth:// links or CXF are readable by anyone who opens the file

In short: this protects data at rest. It does not, and cannot, defend a device that is already under an attacker's control while in use.

Permissions

The manifest declares five permissions and no host permissions:

storage
Accounts and settings in local storage, the unlocked key in session storage, and the optional sync copy
activeTab
Reads the active tab's hostname to highlight the matching account, and captures the tab when the user scans a QR code from screen
contextMenus
Adds the “Insert 2FA code” item to the right-click menu, and only on editable fields on http(s) pages
scripting
Injects the one function that puts the code in the field — for a single tab and a single invocation, only after that menu item is chosen
sidePanel
Opens the app in Chrome's side panel when that is the chosen surface; it grants no access to any page

There are no content scripts, no tabs, cookies or webRequest permissions, and no web-accessible resources. Nothing of ours exists in a page until you ask for a code there; what runs then is injected for that one tab and that one invocation, under the grant your own click provides, and it leaves nothing behind.

Camera

QR scanning by camera runs on an extension page opened in a tab, not in the popup — a popup is destroyed when it loses focus, which a permission prompt does. This needs no manifest permission: it uses the browser's standard camera prompt, granted per extension origin and revocable in site settings, and it is never requested until the user opens the scanner. Video is decoded locally and never transmitted or stored.

Free plan, Pro and the daily check-in

Since 1.14.0 the extension checks in with api.authenticator.sh once a day — when the browser starts, or after the popup has rendered, never on the way to showing a code. The request is a closed list of fields, built in one function in src/billing/sync.ts and held to that list by a test:

iid
A random id created by this installation. Not linked to a Google account, an email address or the browser — unless the user buys Pro: the purchase record then links the installation bought from, and any installation where the license key is active, to the email address paid with
ver, ui_lang, browser_lang
Extension version, interface language, browser language
installed_at, legacy, legacy_src
When the installation appeared, and whether it predates billing
accounts
How many accounts it holds — a number, never which ones
vault, sync, open_mode, style, dark
Whether password protection and sync are on, and how the extension opens, and which style it uses, light or dark
pid, lid, has_key, key_hint
Which signed policy and license the installation currently holds, whether it already has the license key, and the last four characters of that key
opens
How many times the extension was opened on each day since the last check-in (at most the last 14 days) — a count per day, nothing about what was looked at
events
Queued billing events from a closed list: the limit was reached, the paywall was shown or closed, a plan was chosen, a checkout or a key was tried

Never sent: secrets, account names, issuers, codes, or the sites they are used on. The server takes the country from the request at its edge and does not store the IP address. The uninstall page receives the same install id as ?i=: it tells the server the installation was removed, and links an optional survey answer to it.

Kept in chrome.storage.local for this: the install id, the last signed policy and license, the license key this installation holds (typed in, or received after buying on it), the daily open counts until the server has them, and the queued events — no account data. While sync is on, whether the installation predates billing and that license key also go into Chrome Sync, so the user's other Chrome profiles pick them up and turn Pro on by themselves; turning sync off removes both from Chrome Sync, and turning it back on puts them back.

What comes back, and how the extension treats it:

Tokens
The policy (the free plan's account limit) and the license (Pro) are JWS signed with ES256 (ECDSA P-256)
Verification
WebCrypto, against the public keys compiled into the build (src/billing/keys.ts). A Chrome Web Store build trusts the production key only, and the build check refuses one that carries any other
Offline
The limit and the license are checked locally, from the last signed tokens. Until a signed policy has been received, nothing is limited — an unreachable server can never lock anyone out
What the limit can do
Stop an account from being added, and nothing else. It cannot hide, lock, remove or alter an account, or block codes, export, backups, restoring or sync
Purchases
Stripe Checkout in a new tab, with Link as the merchant of record. The buyer's email reaches us from Stripe, to issue and recover the license key; keys are stored as a hash, plus an encrypted copy so a lost key can be sent again
License key
The check-in's answer carries the key itself only to the installation Pro was bought on, and only until the four characters it reports in key_hint match that key — so a key bought later replaces an older one; an installation where the key was typed in is never sent one

The limit is not DRM: the code is open source, and a fork can remove it. What must never be possible is for one person to obtain or use another person's license, or to make someone else's installation limited — that is a vulnerability, and in scope of our security policy.

Network egress

Two kinds of request happen on their own: the clock check and the daily billing check-in. Everything else below happens because the user clicked something. Manifest V3 forbids remote code, and nothing here carries accounts, secret keys or generated codes. In full:

HostWhenCarries user data?
time.akamai.com
timeapi.io
cloudflare.com
Clock-drift check, cached; when none of the sources can be reached, Settings says so. TOTP breaks on a drifted clock.No
authenticator.shWelcome page on install; feedback page on uninstall, with the install id in its address; help and support pages when opened from the popup.The install id, on the uninstall page only; otherwise no (ordinary web server logs)
api.authenticator.shBilling check-in once a day; checkout, license and portal calls when the user buys Pro, enters a key, restores a purchase or manages a subscription.The fields listed above — never account data. A license key or an email address only when the user types one in
checkout.stripe.com
billing.stripe.com
Stripe Checkout and customer portal, opened in a tab when the user buys Pro or manages a subscription.What the buyer enters there. Stripe's and Link's own terms apply
chromewebstore.google.comStore listing, opened in a tab on click: the review form from the rating prompt, our password manager from the cross-promo banner.No (Google sees a store page visit like any other)
authenticator.featurebase.appPublic feature board, opened in a tab by the feature-request link in the support footer.Only what the user posts there. Third-party service, its own terms.

No fonts, scripts, styles or images load from third-party hosts. Everything needed to render the interface ships inside the package.

Verifying what we publish

You do not have to take any of the above on trust. The extension is open source, and each release is reproducible:

  1. Download the published .crx from the Chrome Web Store and unzip it
  2. Check out the matching git tag
  3. Run npm ci && npm run build on Node 20 LTS
  4. Compare the resulting dist/ with the unzipped package

A SHA-256 for every file in the produced dist/ is published with each GitHub release as SHA256SUMS-v<version>.txt, in sha256sum format, so step 4 can be a single sha256sum -c run. Differences should be limited to file ordering inside archives and whitespace in minified output across Node patch versions.

Runtime dependencies are deliberately few — OTPAuth, React, jsQR, Zustand, Framer Motion and Lucide — pinned by a committed lockfile. A CycloneDX SBOM is available on request.

Read the source

What we do not have

We are a small independent vendor. If any of the following is a hard requirement for your process, it is better that you know now than three weeks into an evaluation:

  • No SOC 2, ISO 27001 or comparable certification
  • No third-party penetration test commissioned to date
  • No paid bug bounty programme

What we offer instead is full source transparency, a verifiable build, a minimal permission surface, and a server that is never sent your secrets, account names or codes in the first place.

Talking to us

Vulnerability reports and security questions: security@authenticator.sh. Our disclosure policy, scope, response times and safe-harbour terms are on the security policy page; what is stored and what leaves the device is set out in the privacy policy.

We are happy to answer a written vendor assessment questionnaire or walk a security team through the code.

For anything that is not a security matter — installation, account recovery, billing questions from a deployment — see support. Please do not send vulnerability reports there; that inbox is not treated as confidential.