Summary
- The app is a static website. There's no server-side code, database or account system, and the site's code sets no cookies and runs no analytics.
- Your keys are encrypted with AES-256-GCM under a key derived from your passphrase with PBKDF2-SHA256 and 600,000 iterations. Only the encrypted vault is written to storage.
- The encrypted vault lives in your browser's
localStorageon your device. It is never uploaded. - A strict Content Security Policy blocks all network connections from the app and allows scripts, styles and fonts only from this site.
- All cryptography uses the browser's built-in Web Crypto API. There's no custom encryption code.
Threat model
Defended
- Theft of the stored vault or a backup file. Without the passphrase it's AES-GCM ciphertext. Guessing is slowed by 600,000 PBKDF2 rounds per attempt, and the app rejects passphrases shorter than 12 characters or ones that are easy to guess.
- Tampering with the stored vault or a backup. AES-GCM authenticates the data, so any modified byte makes decryption fail instead of producing altered keys.
- Breach of the host, or data collection. The host only serves public files and never receives your keys or passphrase, and the app cannot make network requests.
- Third-party script compromise. No CDN, tag manager or third-party script is loaded. The QR decoder (jsQR, Apache-2.0) and the fonts (SIL OFL) are served from this site.
- Clickjacking. The app refuses to run when it's loaded inside a frame.
- Unattended unlocked sessions. The vault auto-locks after inactivity (default 5 minutes) and can lock as soon as you switch tabs. Locking drops the decrypted keys and the encryption key from the page.
- Insecure connections. The app refuses to run outside a secure context (HTTPS or localhost).
Not defended
- A compromised device. Malware, a malicious browser extension or someone with control of your unlocked session can read what the page shows. This applies to every software authenticator.
- A weak or reused passphrase. If an attacker has your encrypted vault and can guess your passphrase, they get your keys.
- Malicious changes to the code itself. If whoever controls this site or its repository published altered code, the next visit would run it. Run your own copy if you don't want to rely on that (see below).
- Phishing of the codes themselves. A one-time code typed into a fake login page can be relayed by an attacker within its 30-second window. For your most important accounts, prefer hardware security keys or passkeys, which resist phishing.
Keeping your TOTP keys in the same place as your passwords reduces the benefit of two-factor authentication. Use 2FA Keyring separately from your password manager where you can.
What the host and your browser can see
The app never sends your keys, passphrase, account names or codes anywhere. Some things outside the app's control are worth knowing:
- The host's request logs. Loading any website tells its host your IP address, browser type, the time and which files were requested. For
2fa-keyring.com, that's Cloudflare. These requests carry nothing you type or store in the app. - Browser spell check and translation. Some browsers can send text you type to a cloud service, such as Chrome's enhanced spell check or Edge's Microsoft Editor, or translate a page through an online service. The app turns spell check, autocorrect and translation off for all its fields, so service names, accounts and keys aren't sent this way.
- Password managers. Your browser may offer to save your vault passphrase. If you accept and your browser syncs passwords, the passphrase is protected by that account. Decide whether that's what you want.
- Clipboard history. A copied code can be kept by clipboard history or cloud clipboard sync (for example Windows' Win+V history). Codes expire within their period, but turn off clipboard sync if this matters to you.
Cryptography
| Purpose | Algorithm | Parameters |
|---|---|---|
| Key derivation | PBKDF2 with HMAC-SHA-256 | 600,000 iterations, 16-byte random salt per vault, 256-bit output |
| Vault encryption | AES-GCM | 256-bit key, new 12-byte random IV on every save, 128-bit authentication tag |
| Code generation | HOTP / TOTP (RFC 4226, RFC 6238) | HMAC-SHA-1, SHA-256 or SHA-512, 6–8 digits, 1–300 second period |
| Randomness | crypto.getRandomValues | The browser's cryptographically secure generator |
The derived AES key is created as non-extractable, so page scripts can use it but cannot read its bytes. The iteration count matches the current OWASP recommendation for PBKDF2-SHA256. The TOTP code is tested against all 18 test vectors published in RFC 6238.
Storage and memory
- On disk: one
localStorageentry holds the encrypted vault: format version, salt, iteration count, IV and ciphertext. A second entry holds non-secret preferences (auto-lock time, last backup date). - In memory while unlocked: the decrypted key list and the non-extractable AES key. Both are dropped when the vault locks.
- Never stored: your passphrase, or any key in plain text.
- Clipboard: copying puts only the current 6–8 digit code on the clipboard, which expires within its period. The setup key is only shown on screen when you ask for it.
Content Security Policy
Each page declares its policy in a <meta http-equiv="Content-Security-Policy"> tag, which the browser enforces. The app's policy is:
default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self';
img-src 'self' blob:; connect-src 'none'; base-uri 'none';
form-action 'none'; object-src 'none'; manifest-src 'none'; worker-src 'none'
connect-src 'none'stopsfetch, XHR, WebSockets and beacons. Images (img-src) and form submissions (form-action) can't reach other sites either.- No browser policy can block every route out. A page can still navigate itself to another address, for example. So the policy is a safety net against injected code, and the real protection is that the app's code is small, open and has no third-party parts.
script-src 'self'with no'unsafe-inline'or'unsafe-eval'means only this site's own script files run. There are no inline scripts or styles.img-src blob:exists only so QR screenshots you choose can be decoded on your device.- This security page runs no scripts at all (
script-src 'none').
Browsers ignore frame-ancestors in a meta tag, which is why the app checks for framing itself. If you host your own copy, also send the policy as an HTTP header with frame-ancestors 'none'.
Hosting and origin
The official site is 2fa-keyring.com, served by Cloudflare as static files. Browser storage belongs to a single origin (scheme, host and port), so this domain is used for nothing but this app, and no other website can read its storage.
Other addresses redirect to https://2fa-keyring.com, so every user has exactly one vault location. Always check the address bar shows 2fa-keyring.com before entering your passphrase.
Cloudflare sends these HTTP security headers with every file:
- Content Security Policy: the same policy as an HTTP header, plus
frame-ancestors 'none'so no other site can frame the app - HTTPS only:
Strict-Transport-Securitykeeps browsers on HTTPS - File and frame safety:
X-Content-Type-Options: nosniffandX-Frame-Options: DENY - Device features off: a
Permissions-Policythat turns off the camera, microphone, location and similar features - Cross-origin isolation:
Cross-Origin-Opener-PolicyandCross-Origin-Resource-Policyisolate the page from other websites
Every deploy is followed by an automatic check of the live site that these headers are present and that no repository files are served.
Give it a domain or subdomain of its own. Avoid shared origins such as username.github.io, where every project site of that account shares one origin and could read or script your copy. GitHub Pages also can't send security headers, so a copy there relies only on each page's meta policy and the app's own framing check.
Backups
- A backup is the same encrypted envelope stored in your browser, as a JSON file. It can only be opened with the passphrase that was in use when it was made.
- After you change your passphrase, older backups still open with the old one. Make a fresh backup and delete the old files.
- Store backups somewhere private, such as an encrypted drive or a personal cloud folder. Never post one in a chat, email thread, issue tracker or public repository.
Verify it yourself
- Open the app, then your browser's developer tools (F12, or ⌥⌘I on a Mac), and go to the Network tab. Reload, create a vault, add a key and copy a code. Every request goes to this site, and nothing is sent after the page has loaded.
- In the Console, run
fetch("https://example.com"). The browser blocks it and reports a Content Security Policy violation. - In Application → Local Storage, open
keyring.vault.v1. You'll see only the encrypted vault (version, salt, IV and ciphertext), never your setup keys. - Compare the files served by this site with the repository on GitHub. The app consists of
app/index.html,app/app.js,app/app.css,assets/totp.jsandassets/vendor/jsQR.js.
Run your own copy
The site is plain static files with no build step. To remove any dependence on this website:
- Download or clone the repository and review the code.
- Serve the folder over HTTPS on a domain you control, or locally with
python3 -m http.serverand openhttp://localhost:8000/app/. - If your host supports headers, send the app's policy as a
Content-Security-Policyheader withframe-ancestors 'none'added.
Browsers keep a separate vault for each origin, so move your keys to a new copy with an encrypted backup.
Recommendations
- Use a passphrase of four or more random words that you don't use anywhere else.
- Export an encrypted backup after adding keys, and again after changes.
- Save each service's recovery codes separately from this app, such as on paper in a safe place.
- Keep your browser and operating system up to date, and install only browser extensions you trust.
- Use hardware security keys or passkeys for your email and financial accounts where they're offered.
Report a vulnerability
Please report security problems privately, not in a public issue. Use GitHub's private vulnerability reporting on the repository: Security → Report a vulnerability at github.com/estuty/2FA-Keyring/security. Include steps to reproduce and the browser you used. Reports are acknowledged as soon as possible, and fixed issues are credited if you wish.