Skip to content
nviaOTP

OTP Cryptography Internals · Chapter 4 of 5

Secrets: generation, base32, entropy budget and provisioning URIs

Generate seeds that deserve the name, encode them for humans and QR codes, and build otpauth URIs that every authenticator parses the same way.

6 min read

Every guarantee in the previous chapters rests on one assumption: the attacker does not know K. This chapter covers the moment K comes into existence and the moment it leaves the server for the only time. Both are short pieces of code and both are where real systems most often fail.

Generation: a CSPRNG, nothing else

The seed must come from a cryptographically secure random number generator: crypto.getRandomValues in browsers and workers, crypto.randomBytes in Node, secrets.token_bytes in Python, /dev/urandom or getrandom(2) on Linux. Never derive a seed from the user’s ID, email, enrolment time or any other value that an attacker might learn. We have reviewed systems that computed HMAC(masterKey, userId) as the seed to avoid storing anything; that design means one leaked master key compromises every account at once and no individual seed can ever be rotated.

Generate the seed on the server, not in the browser. Client-side generation means the server must accept a seed chosen by the client, which lets malware on the enrolling machine pick a value the attacker already knows. The server should be the only source of seeds, and it should generate a fresh one on every enrolment attempt, including retries.

The entropy budget

RFC 4226 requires at least 128 bits and recommends 160. The reason for 160 is that HMAC-SHA1 has a 512-bit block and a 160-bit output; keys up to the block size are used directly, and 160 bits matches the internal state so there is no wasted margin. Twenty random bytes is the de facto standard, and every mainstream authenticator handles it. For SHA-256 or SHA-512 variants, use 32 or 64 bytes respectively.

It is tempting to think that more key bits make the six-digit code stronger. They do not. The code’s guessing resistance is fixed by its length; key length protects only against an attacker who tries to recover K from observed codes. With 160 bits that is computationally impossible, and with 80 bits — a length still seen in old tutorials — it is merely very expensive. Use twenty bytes and spend your attention elsewhere.

Base32: built for humans and QR codes

Raw bytes cannot be typed or shown, so provisioning uses RFC 4648 base32: the alphabet A–Z and 2–7, five bits per character. Twenty bytes become exactly 32 characters with no padding, because 160 is divisible by five. The alphabet avoids 0, 1 and 8, which are easily confused with O, I and B, and it is case-insensitive, which matters when a user types a manual-entry key on a phone keyboard.

const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
function base32(bytes: Uint8Array) {
  let bits = 0, val = 0, out = "";
  for (const b of bytes) {
    val = (val << 8) | b; bits += 8;
    while (bits >= 5) { out += B32[(val >>> (bits - 5)) & 31]; bits -= 5; }
  }
  if (bits > 0) out += B32[(val << (5 - bits)) & 31];
  return out; // no '=' padding: authenticators expect it stripped
}
The encoder the live card on our home page uses. Decoding must ignore spaces and accept lowercase.

When displaying a manual-entry key, group it in blocks of four for readability, and strip spaces before decoding. Accept lowercase input. Reject any character outside the alphabet rather than silently skipping it, because a silently mangled seed produces an authenticator that never works and a support ticket that is hard to diagnose.

Provisioning URIs

The de facto format for QR provisioning is the otpauth:// URI, originally documented for a popular authenticator and now parsed by nearly all of them. Its shape is otpauth://TYPE/LABEL?PARAMETERS, where TYPE is totp or hotp, LABEL is Issuer:account, and the parameters carry the secret and options.

otpauth://totp/Larkspur%20Ledger:amara.quist%40larkspur.example
  ?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP
  &issuer=Larkspur%20Ledger
  &algorithm=SHA1&digits=6&period=30
Line breaks added for readability. Percent-encode the label and issuer; always include issuer as a parameter as well as in the label.
Provisioning is a state machine. The secret stays pending until the user proves the authenticator produces matching codes.
  • algorithm, digits and period are optional; some apps ignore non-default values, so stick to SHA1/6/30 unless you control the authenticator.
  • issuer drives the account name and icon in most apps; inconsistent issuers create duplicate entries.
  • counter is required for hotp and ignored for totp.
  • Never put the URI in a URL that is logged, cached or sent to analytics — it is the secret.

Confirm before you activate

Do not mark the authenticator active when the QR code is displayed. Mark it pending and require the user to submit a valid code generated from it. This catches the many ways enrolment fails silently — a scan of the wrong code, a manual key with a typo, an app that ignored a non-default period — and it proves the user actually holds the device. If the pending secret is not confirmed within a few minutes, discard it.

Storage at rest

Because TOTP is symmetric, the verifier must be able to read the seed in plaintext at verification time; hashing it, as you would a password, is not an option. Encrypt seeds with a key held outside the database — a key-management service or at minimum an application-level key loaded from a secret store — so that a SQL dump alone is useless. Restrict which services can call the decrypt operation, and log those calls. Treat seed export, migration and backup with the same care as private keys.

Rotation deserves a plan before you need it. If you suspect seeds have leaked, from a database incident or a compromised backup, the only fix is re-enrolment: issue new seeds, require users to scan new QR codes and invalidate the old ones. Build that flow early. A forced re-enrolment for every user is painful, but it is far worse to discover during an incident that the flow does not exist.

Keep an inventory too. For every active authenticator, record when it was provisioned, which encryption key version protects its seed and whether it has been confirmed. With that metadata, rotating an encryption key means re-encrypting seeds in place without bothering users. Without it, every key-management change becomes a forensic exercise.

When an enrolment is abandoned halfway, delete the pending seed rather than keeping it. A table full of unconfirmed seeds is risk with no benefit.

0/3 correct · 0 answered

  1. 1. How many base32 characters encode a 160-bit secret?

  2. 2. Why mark a new authenticator as pending until a code is confirmed?

  3. 3. Which seed derivation is safe?

Discussion

Comments are public and shared with every reader. No account needed — just a display name. Comments reported by three readers are hidden automatically.

15 more characters needed

Loading comments…