One-time passwords

HOTP (RFC 4226) and TOTP (RFC 6238): the six digits an authenticator app shows, generated and checked, with replay refused inside the verify.

Note

ELI5. A one-time password is a short number both sides can compute from a shared secret plus a counter. The phone counts time; the server counts the same time; both run the secret and the count through the same function and get the same six digits. Nobody sends the secret, and the number is useless a minute later. This is the second factor in two-factor login. It is not a replacement for a password, and it is not a substitute for a rate limit: six digits is a million guesses.

import { generateOTPSecret, otpauthURI, totpVerify } from "unsecure/otp";

const secret = generateOTPSecret();
const uri = otpauthURI({ type: "totp", secret, account: "user@example.com", issuer: "My App" });

const { valid, step } = await totpVerify(secret, submittedCode);

#The functions

FunctionWhat it does
generateOTPSecret()A random secret as an unpadded base32 string.
otpauthURI()The otpauth:// URI you render as a QR code.
totp()The code for a time step. Mostly for tests and for showing your own.
totpVerify()Checks a submitted code against a window of steps.
hotp()The code for a counter value.
hotpVerify()Checks a submitted code against a window of counters.

Secrets are raw bytes (any BytesSource), a base32 string, or an HMAC CryptoKey from importHmacKey(). Whatever you pass must resolve to at least one byte.

#generateOTPSecret()

generateOTPSecret(); // 20 bytes -> a 32-character base32 string
generateOTPSecret(32); // 32 bytes, for SHA-256
generateOTPSecret(64); // 64 bytes, for SHA-512

length is a byte count and an integer of at least 1. The result is unpadded base32, which is what authenticator apps expect and what serializes cleanly to JSON.

#TOTP

import { totp, totpVerify } from "unsecure/otp";

await totp(secret); // the code for right now
await totp(secret, { time: 59 }); // "287082" for the RFC 6238 test secret
await totp(secret, { period: 60, algorithm: "SHA-256" });

const result = await totpVerify(secret, submitted);
// { valid: true, delta: 0, step: 59623316 }  or  { valid: false, delta: 0 }

#Options

OptionTypeDefaultNotes
algorithmDigestAlgorithm"SHA-1"SHA-1 is what authenticator apps assume. Matched case-insensitively.
digitsnumber6An integer from 6 to 8.
periodnumber30Seconds per step. An integer of at least 1.
timenumbernowUnix seconds. Omit it for the current time; a fractional value is floored.
windownumber1Verify only. Steps to check in each direction.
lastAcceptednumbernoneVerify only. The step of the last accepted code. See replay, below.

#Result

type TOTPVerifyResult =
  { valid: true; delta: number; step: number } | { valid: false; delta: 0; step?: undefined };

delta is how many steps away the match was: 0 is the current step, -1 the previous one, +1 the next. step is the absolute step that matched, and it is the value you persist.

#Refusing a replay

RFC 6238 §5.2 says a code is single-use. The library holds no state, so the memory of what was accepted travels with the user record: pass the last accepted step back as lastAccepted, and totpVerify refuses every candidate at or before it.

// A captured code stays valid for the rest of its window
const { valid } = await totpVerify(secret, submitted);

// The refusal happens inside verify; you store one integer
const result = await totpVerify(secret, submitted, { lastAccepted: user.lastOtpStep });
if (result.valid) {
  await store.setLastOtpStep(user.id, result.step);
}

A rejected replay is indistinguishable from a wrong code, on purpose. user.lastOtpStep starts as undefined for a user who has never verified, which is also what "nothing to refuse" means.

#HOTP

HOTP counts events instead of seconds. The server owns the counter.

import { hotp, hotpVerify } from "unsecure/otp";

await hotp(secret, 0); // "755224"
await hotp(secret, 1); // "287082"
await hotp(secret, 0, { digits: 8 }); // "84755224"

const result = await hotpVerify(secret, "287082", 0, { window: 5 });
// { valid: true, delta: 1, counter: 1 }

counter is required and is an integer of at least 0. Candidates before it are never checked.

type HOTPVerifyResult =
  { valid: true; delta: number; counter: number } | { valid: false; delta: 0; counter?: undefined };

Advance past the counter that matched, or the same code works forever:

const result = await hotpVerify(secret, code, user.counter, { window: 5 });
if (result.valid) {
  await store.setCounter(user.id, result.counter + 1);
}

#otpauthURI()

Builds the URI an authenticator app scans.

otpauthURI({ type: "totp", secret, account: "user@example.com", issuer: "My App" });
// "otpauth://totp/My%20App:user%40example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=My%20App&algorithm=SHA1&digits=6&period=30"

otpauthURI({ type: "hotp", secret, account: "user@example.com", issuer: "My App", counter: 0 });
// "otpauth://hotp/My%20App:user%40example.com?secret=…&issuer=My%20App&algorithm=SHA1&digits=6&counter=0"
OptionRequiredNotes
typeyes"hotp" or "totp". Anything else is UNSUPPORTED.
secretyesBytes or a base32 string. A CryptoKey is INVALID_TYPE — a key cannot be rendered into a URI.
accountyesA non-empty string, usually the user's email.
issuernoA non-empty string when given. Omit the key for no issuer; "" throws.
algorithmnoDefault "SHA-1".
digitsnoDefault 6.
counterfor HOTPAn integer of at least 0.
periodfor TOTPDefault 30.

Values are percent-encoded per the Key URI format, so a space is %20 and never +. A string secret is canonicalized to unpadded uppercase base32, which means "jbsw y3dp" and the equivalent bytes produce the same URI.

#Reusing an imported key

The secret is imported once per call, and every candidate in the window is signed with that one key: a window: 5 verify costs one importKey, not six. hotp, hotpVerify, totp and totpVerify also take an HMAC CryptoKey, which removes even that import.

import { importHmacKey } from "unsecure/hmac";
import { totpVerify } from "unsecure/otp";

// The key's hash must be the algorithm the OTP call uses — SHA-1 by default
const key = await importHmacKey(secretBytes, { algorithm: "SHA-1" });

const { valid, delta } = await totpVerify(key, submitted, { window: 1 });

A mismatch is refused rather than silently signed with the wrong digest:

await totp(key, { algorithm: "SHA-256" });
// UnsecureError OUT_OF_RANGE: totp: algorithm must match the key's hash SHA-1, got "SHA-256".

#Timing

Verification walks the whole window on every call: window + 1 HMACs for hotpVerify, 2 * window + 1 for totpVerify. The duration of a call says nothing about which step matched, and the comparison itself runs through secureCompare. delta reports the nearest matching step, with the past winning a tie, not the first one scanned.

#Errors

Causecode
A secret that is not bytes, a string or a key; a non-string account, issuer or typeINVALID_TYPE
An empty secret, account or issuer; a missing HOTP counter; any numeric option outside its rangeOUT_OF_RANGE
An unknown algorithm, or a type string other than "hotp" / "totp"UNSUPPORTED
Web Crypto refused the underlying HMACPLATFORM

Only an omitted option takes its default. null is a value the caller passed, and it fails the range check like any other bad number.

#Recipe: enrol and verify

import { generateOTPSecret, otpauthURI, totpVerify } from "unsecure/otp";

// Enrolment: show the QR code, then confirm the user can read it
export async function begin2FA(user: User) {
  const secret = generateOTPSecret();
  const uri = otpauthURI({
    type: "totp",
    secret,
    account: user.email,
    issuer: "My App",
  });
  await store.setPendingOtpSecret(user.id, secret); // not yet enabled
  return uri; // render as a QR code
}

export async function confirm2FA(user: User, code: string) {
  const secret = await store.getPendingOtpSecret(user.id);
  const result = await totpVerify(secret, code);
  if (!result.valid) return false;
  await store.enableOtp(user.id, secret, result.step);
  return true;
}

Never enable the second factor before the user has proved they can produce a code from it. Otherwise a mis-scanned QR locks them out.

The whole login flow, replay protection included, is in Recipes.

#Pitfall: mismatched secret size

The RFC recommends a secret at least as long as the hash output.

// 20 bytes with SHA-512
await totp(generateOTPSecret(), { algorithm: "SHA-512" });

// Match the size to the algorithm
await totp(generateOTPSecret(20)); // SHA-1, the default
await totp(generateOTPSecret(32), { algorithm: "SHA-256" });
await totp(generateOTPSecret(64), { algorithm: "SHA-512" });

Most authenticator apps only implement SHA-1 with six digits and a 30-second period. Changing any of those three limits which apps can enrol.

#Pitfall: storing the secret as raw bytes in JSON

// A Uint8Array does not survive JSON
JSON.stringify({ secret: secretBytes });

// generateOTPSecret() already returns a base32 string
JSON.stringify({ secret: generateOTPSecret() });

The OTP functions accept either, so keeping the string form costs nothing.

#Pitfall: no rate limit

Six digits with a window: 1 is a small space and an online guess costs an attacker one request. Rate-limit verification attempts per account, and lock the factor after a handful of failures. The library cannot do this for you; it has no state.