A TOTP Generator You Can Trust: Building an Encrypted Vault That Never Talks to a Server
How ToolRunner generates RFC 6238 TOTP codes entirely in the browser, and how the AES-GCM vault with PBKDF2 and WebAuthn PRF key wrapping protects the secrets.
by Dowon Oh
There is a category of developer-tool input where "we don't log it, promise" is not good enough, and a TOTP secret sits at the top of it. When I built the /otp-generator for ToolRunner, the constraint I set before writing a single line was that the secret must never be transmittable even in principle: no server round-trip, no analytics payload that could accidentally include it, no "temporary" backend cache. The code generation runs entirely in the browser via the otpauth library, and the stored secrets live in an encrypted vault built on the Web Crypto API — AES-GCM for the data, AES-KW for key wrapping, PBKDF2 or a WebAuthn PRF output to derive the wrapping key. This article walks through why a TOTP secret is uniquely dangerous to paste into a server-backed tool, and then through the actual crypto decisions in the vault, straight from the code.
Why a TOTP secret is the one thing you never paste into someone else's server
Most inputs you paste into an online tool are ephemeral. A JSON blob you prettify, a cron expression you validate — if a server logs it, that is a privacy smell, but the damage is bounded to that one artifact. A TOTP secret is different in kind, not degree, and the reason is in the spec itself. RFC 6238 defines TOTP as a function of a shared static secret and the current Unix time; the moving factor is the clock, not the key. RFC 4226, the HOTP algorithm underneath it, is equally explicit: whoever holds the key K can compute every valid code, forever, until the key is rotated.
So pasting a TOTP secret into a server-backed generator is not "sharing one code." It is handing over the permanent ability to mint valid second-factor codes for that account, on demand, silently. There is no expiry, no scope, no revocation short of re-enrolling 2FA on the account itself. The six digits on screen rotate every 30 seconds; the secret that produces them never does. A logged request body, a crash-report payload, a well-meaning debug trace on the server side — any one of these turns "I checked a code once in 2024" into a standing bypass of your second factor.
That is the whole design brief for this tool: the secret must be usable without ever being sendable. Everything below follows from it.
RFC 6238 in the browser: what actually generates the code
The generation path uses the otpauth library on top of the browser's own crypto. Each stored entry carries the four parameters RFC 6238 cares about — Base32 secret, HMAC algorithm, digit count, and time step — and the page constructs a TOTP instance from exactly those values:
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(entry.secret),
algorithm: entry.algorithm, // 'SHA1' | 'SHA256' | 'SHA512'
digits: entry.digits, // 6 | 8
period: entry.period, // 30 | 60 seconds
});
return totp.generate();
The defaults mirror what almost every service issues — SHA-1, 6 digits, 30-second period, which is why the codes match Google Authenticator, Authy, or 1Password for the same secret — but the UI exposes SHA-256, SHA-512, 8 digits, and a 60-second period for the services that use them. If a secret fails to parse, the card renders ------ instead of throwing; a countdown driven by period - (now % period) re-generates the code exactly on the time-step boundary.
Most people never type the secret at all. Enrollment QR codes encode an otpauth:// URI, and the tool accepts one directly — pasted as text, decoded from an uploaded or pasted screenshot via jsQR, or scanned live with the camera (using the native BarcodeDetector API where available, with a jsQR canvas fallback). The URI anatomy is worth knowing, because it is the thing your screenshots are leaking:
otpauth://totp/GitHub:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&algorithm=SHA1&digits=6&period=30
└──┬───┘ └┬─┘ └────────┬───────────┘ └──────┬─────┘
scheme type label (issuer:account) the permanent shared key
Everything after secret= is the key material from the previous section. This is also why the QR decoding happens client-side: a QR "reader" website that uploads the image to decode it has just received your 2FA secret as a side effect. If you ever need to re-encode a secret into a QR for another device, the /qr-code-generator renders it locally too.
The vault: envelope encryption, not a password-encrypts-data shortcut
Generating codes is the easy half. The hard half is storing secrets between visits without turning the browser profile into a plaintext key dump. Early versions of the tool kept entries in localStorage as-is; the current vault migrates those on first setup and encrypts everything with a two-key envelope scheme in src/lib/vault/crypto.ts.
The design has two keys with different jobs:
- A DEK (data encryption key): a random 256-bit AES-GCM key generated by
crypto.subtle.generateKey. This is the key that actually encrypts your entries. - A KEK (key encryption key): a 256-bit AES-KW key derived from your master password or your authenticator. Its only permitted operations are
wrapKeyandunwrapKey— it never touches the data.
The DEK is wrapped with the KEK using AES Key Wrap and only the wrapped blob is persisted. On every save, the entry list is serialized and sealed with AES-GCM under a fresh random 96-bit IV:
const iv = crypto.getRandomValues(new Uint8Array(12));
const plaintext = new TextEncoder().encode(JSON.stringify(entries));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
dek,
plaintext,
);
Ciphertext and IV go into IndexedDB (a dedicated toolrunner-otp-vault database with separate meta and vault object stores) as Base64 strings, alongside the wrapped DEK, the KDF salt, and the iteration count in the metadata record. Nothing readable ever hits disk: what an attacker with filesystem access sees is an AES-GCM blob and an AES-KW blob, both useless without the KEK.
Why the indirection instead of just deriving one key from the password and encrypting with it? Two reasons I cared about. First, changing the unlock method later — password to passkey, or a password rotation — only requires re-wrapping the 32-byte DEK, not re-encrypting the payload. Second, the MDN SubtleCrypto documentation makes a property of unwrapKey explicit that I lean on: the unwrapped key can be marked non-extractable. The generated DEK has to be extractable once so AES-KW can wrap it at creation time, but every subsequent unlock unwraps it with extractable: false. From that point, JavaScript in the page — including any script an attacker might manage to inject — can use the key handle to decrypt but can never read the key bytes back out. The raw DEK exists only inside the browser's crypto implementation.
Two ways to derive the KEK: a slow password, or a passkey
The vault offers two unlock methods at setup, and they derive the KEK differently.
Master password. The password is fed to PBKDF2 with SHA-256, a random 16-byte salt, and 600,000 iterations — matching the current OWASP recommendation for PBKDF2-HMAC-SHA256 — with the output typed as an AES-KW key. The iteration count is stored in the vault metadata rather than hardcoded at the call site, so it can be raised in a future version without stranding existing vaults: unlock always uses the count the vault was created with. PBKDF2 is the pragmatic choice here over Argon2 because it is the only password KDF SubtleCrypto ships natively; pulling in a WASM Argon2 build would contradict the project's no-extra-bundles rule for marginal gain at this iteration count.
Biometric / passkey, via WebAuthn PRF. This is the part I find genuinely elegant. The WebAuthn PRF extension lets a platform authenticator — Touch ID, Face ID, Windows Hello — evaluate a pseudo-random function over a fixed input and return a high-entropy secret, gated behind user verification. The vault creates a platform credential with userVerification: 'required' and a fixed PRF evaluation input, then runs the 32-byte PRF output through HKDF-SHA-256 (random 16-byte salt, a domain-separating info string of toolrunner-otp-vault-kek) to produce the AES-KW KEK. There is no password to forget and nothing to brute-force offline: the PRF secret never leaves the authenticator hardware, and each unlock requires a live biometric ceremony. The setup screen only offers this path after probing that a user-verifying platform authenticator exists and the PRF extension actually returns output — some authenticators silently don't, in which case the tool falls back to offering password setup rather than pretending.
What a wrong passphrase looks like
A detail I like about this construction: the vault never needs to store a password hash to check your passphrase against, and there is nothing resembling a login check. A wrong password simply derives the wrong KEK, and the AES-KW unwrap of the DEK fails its integrity check inside crypto.subtle.unwrapKey. The failure is total — you get an exception, not a garbled key that half-decrypts something — and the hook catches it and surfaces one deliberately unhelpful message: "Incorrect password or corrupted vault." Wrong password and tampered ciphertext are indistinguishable from the outside, which is exactly the property AES-GCM's authentication and AES-KW's integrity check are there to provide. No plaintext, partial or otherwise, is ever produced on a bad unlock.
The unlocked DEK itself lives only in a React ref, in memory, as a non-extractable CryptoKey. Locking the vault nulls that ref; there is nothing to wipe on disk because nothing readable was ever written there. And because an unlocked tab left open is the realistic weak point, the hook auto-locks after 10 minutes of inactivity, or 5 minutes after the tab is hidden.
The honest limits
I want to be precise about what this design does and does not defend against, because overselling client-side crypto is its own failure mode.
It does protect the at-rest story: someone who copies your IndexedDB data — a stolen laptop, a synced browser profile, another user on a shared machine — gets ciphertext they cannot use without your password or your authenticator. It protects against the entire class of server-side risk, trivially, because there is no server in the path; you can verify that in the network tab. And it keeps the key material out of reach of page JavaScript after unlock, thanks to non-extractable keys.
It does not make the export feature magic: the backup file is deliberately plain JSON (so you can import it into other tools or re-derive QR codes), which means an exported backup is exactly as sensitive as the secrets themselves — treat the file like a password list, and delete it once imported. It also cannot protect an unlocked session from code running in the same origin; that is what the auto-lock shortens the window for, and it is why the site's own hardening matters — the reasoning behind headers like CSP is covered in /blog/what-is-csp/ and the broader /blog/http-security-headers-guide/. Finally, browser storage is not a backup: clearing site data deletes the vault, and by design nobody — including me — can recover it. That is not a bug in a zero-knowledge design. It is the receipt.
Sources
Specifications
- RFC 6238 — TOTP: Time-Based One-Time Password Algorithm
- RFC 4226 — HOTP: An HMAC-Based One-Time Password Algorithm
- W3C Web Authentication Level 3 — PRF extension
Mozilla MDN
OWASP
Further reading
- Complete Guide to HTTP Security Headers — the hardening that protects the origin this vault runs on.
- HSTS Explained: Force HTTPS Without Breaking Anything — why the transport layer under a client-side crypto tool still matters.