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
| Function | What 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-512length 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
| Option | Type | Default | Notes |
|---|---|---|---|
algorithm | DigestAlgorithm | "SHA-1" | SHA-1 is what authenticator apps assume. Matched case-insensitively. |
digits | number | 6 | An integer from 6 to 8. |
period | number | 30 | Seconds per step. An integer of at least 1. |
time | number | now | Unix seconds. Omit it for the current time; a fractional value is floored. |
window | number | 1 | Verify only. Steps to check in each direction. |
lastAccepted | number | none | Verify 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"| Option | Required | Notes |
|---|---|---|
type | yes | "hotp" or "totp". Anything else is UNSUPPORTED. |
secret | yes | Bytes or a base32 string. A CryptoKey is INVALID_TYPE — a key cannot be rendered into a URI. |
account | yes | A non-empty string, usually the user's email. |
issuer | no | A non-empty string when given. Omit the key for no issuer; "" throws. |
algorithm | no | Default "SHA-1". |
digits | no | Default 6. |
counter | for HOTP | An integer of at least 0. |
period | for TOTP | Default 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
| Cause | code |
|---|---|
A secret that is not bytes, a string or a key; a non-string account, issuer or type | INVALID_TYPE |
An empty secret, account or issuer; a missing HOTP counter; any numeric option outside its range | OUT_OF_RANGE |
An unknown algorithm, or a type string other than "hotp" / "totp" | UNSUPPORTED |
| Web Crypto refused the underlying HMAC | PLATFORM |
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.