
# 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.
::

```ts
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()`](/crypto/hashing/hmac#reusing-an-imported-key). Whatever you pass must resolve to at least one byte.

## generateOTPSecret()

```ts
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

```ts
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

```ts
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.

```ts
// 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.

```ts
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.

```ts
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:

```ts
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.

```ts
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.

```ts
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:

```ts
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`](/generate/secrets/compare). `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

```ts
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](/examples/two-factor-auth).

## Pitfall: mismatched secret size

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

```ts
// 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

```ts
// 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.
