# TOTP 原理和实践

> 从 OTP、HOTP 到 TOTP，梳理一次性密码的生成原理、绑定流程、服务端验证方式、前端职责边界，以及 WebOTP 与 TOTP 的区别。

- 发布于：2026-01-07
- 更新于：2026-01-07
- 分类：学习
- 标签：后端
- 原文：https://chanx.tech/blog/totp

## 一、背景

TOTP 经常出现在两步验证、敏感操作确认和企业后台登录中。用户在 Google Authenticator、Microsoft Authenticator、1Password、Authy 等工具里看到的 6 位动态验证码，大多数就是 TOTP。

它的核心目标很简单：即使用户的静态密码泄露，攻击者也不能只凭密码完成登录。

从认证因素看，TOTP 通常作为第二因素使用：

```txt
用户名 + 密码
  -> 验证用户知道什么

TOTP 动态验证码
  -> 验证用户持有什么
```

这里的“持有什么”，本质上是用户设备上的认证器保存了一份和服务端一致的密钥。

## 二、从 OTP 到 TOTP

OTP 是 One-Time Password，一次性密码。它不是某一种具体算法，而是一类动态验证码方案。

常见 OTP 可以分成两类：

- HOTP：基于计数器，每验证一次计数器递增
- TOTP：基于时间，把当前时间切成固定窗口

HOTP 的标准来自 RFC 4226，TOTP 的标准来自 RFC 6238。TOTP 可以理解为 HOTP 的时间版本：它把 HOTP 里的计数器换成了时间步长。

一个简化关系是：

```txt
HOTP = HMAC(secret, counter)
TOTP = HMAC(secret, time_step)
```

HOTP 需要客户端和服务端维护一致的计数器，适合硬件令牌等场景。TOTP 只依赖时间同步，使用成本更低，因此在互联网产品里更常见。

## 三、TOTP 的核心原理

TOTP 的输入主要有三个：

- `secret`：服务端和认证器共享的密钥
- `time step`：时间步长，常见值是 30 秒
- `digits`：验证码位数，常见值是 6 位

生成过程可以概括为：

```txt
当前 Unix 时间
  -> 除以时间步长并向下取整
  -> 得到 time_step
  -> HMAC(secret, time_step)
  -> 动态截断
  -> 对 10^digits 取模
  -> 得到 6 位验证码
```

对应的公式可以简化成：

```txt
TOTP = Truncate(HMAC-SHA-1(secret, T)) mod 10^digits
T = floor(current_unix_time / period)
```

其中 `period` 通常是 30 秒。也就是说，在同一个 30 秒窗口内，只要客户端和服务端持有相同的 `secret`，并且时间基本同步，就会算出相同的验证码。

## 四、绑定流程

TOTP 的使用通常分成两步：绑定和验证。

绑定阶段，服务端会为用户生成一个随机密钥，并把它编码成认证器能识别的格式。

典型流程如下：

```txt
用户开启两步验证
  -> 服务端生成 secret
  -> 服务端生成 otpauth:// URI
  -> 前端展示二维码
  -> 用户用认证器扫码
  -> 用户输入认证器生成的 6 位验证码
  -> 服务端验证通过
  -> 绑定完成
```

二维码里通常不是验证码本身，而是一个 `otpauth://` URI。它大致长这样：

```txt
otpauth://totp/Example:user@example.com?secret=BASE32_SECRET&issuer=Example&period=30&digits=6
```

几个关键字段：

- `secret`：共享密钥，通常使用 Base32 编码
- `issuer`：服务名称，认证器里显示用
- `period`：时间步长
- `digits`：验证码位数

绑定完成后，服务端需要保存用户的 TOTP 密钥。认证器也会保存同一份密钥。后续验证时，两边各自根据当前时间计算验证码。

## 五、验证流程

验证阶段，前端只负责收集用户输入的验证码，真正的校验应该在服务端完成。

```txt
用户输入 6 位验证码
  -> 前端提交给服务端
  -> 服务端读取用户 secret
  -> 服务端按当前时间计算 TOTP
  -> 对比用户输入
  -> 返回验证结果
```

服务端一般不会只验证当前时间窗口，而是允许一定容错。例如当前窗口前后各放宽一个时间步：

```txt
T - 1
T
T + 1
```

如果时间步长是 30 秒，前后各一个窗口意味着大约允许 90 秒范围内的验证码匹配。这样可以容忍用户设备和服务端之间的小幅时间偏差。

但容错范围不宜过大。范围越大，可接受的验证码越多，暴力猜测成功率也会随之上升。

## 六、前端应该做什么

在标准的 TOTP 认证流程里，前端不应该长期保存或计算用户的 TOTP 密钥。

前端更合理的职责是：

- 展示绑定二维码
- 引导用户保存恢复码
- 收集用户输入的验证码
- 做基础格式校验
- 展示倒计时和错误提示
- 在敏感操作时触发二次验证

例如输入校验可以保持很轻：

```ts
function normalizeTotp(input: string) {
  return input.replace(/\s/g, '')
}

function isValidTotpFormat(input: string) {
  return /^\d{6}$/.test(normalizeTotp(input))
}
```

前端可以提升体验，但不要把前端当成安全边界。验证码是否正确、是否过期、是否已被重放，都应该由服务端判断。

## 七、服务端实践要点

服务端实现 TOTP 时，重点不是公式本身，而是密钥、状态和风控。

### 密钥生成与存储

`secret` 必须由安全随机数生成，不能由用户信息、时间戳或可预测字符串拼出来。

保存时需要注意：

- 不要明文出现在日志里
- 不要返回给前端多次展示
- 数据库中尽量加密存储
- 绑定完成前可以暂存，绑定成功后再正式启用
- 用户重置 TOTP 时需要让旧密钥失效

### 验证与重放

TOTP 在同一个时间窗口内会生成相同验证码。如果攻击者在验证码有效期内截获它，理论上仍有被重放的风险。

因此服务端可以记录用户最近成功使用过的时间窗口，避免同一个窗口内重复使用同一个验证码。

一个简化逻辑是：

```txt
校验验证码
  -> 找到匹配的 time_step
  -> 判断该 time_step 是否已经使用过
  -> 未使用则通过并记录
  -> 已使用则拒绝
```

### 失败次数限制

6 位数字验证码只有 100 万种可能。单次猜中概率不高，但如果没有限制，攻击者可以持续尝试。

实践中通常需要：

- 限制单用户失败次数
- 对连续失败增加冷却时间
- 对高风险登录触发额外校验
- 对异常 IP、设备、地区做风控判断

## 八、一个最小实现示例

生产系统建议使用成熟库实现 TOTP，不建议自己手写密码学细节。下面示例只展示服务端大致流程。

```ts
import { authenticator } from 'otplib'

// 绑定阶段：生成密钥和二维码内容
function createTotpBinding(userEmail: string) {
  const secret = authenticator.generateSecret()
  const uri = authenticator.keyuri(userEmail, 'Example', secret)

  return {
    secret,
    uri
  }
}

// 验证阶段：校验用户输入
function verifyTotp(token: string, secret: string) {
  return authenticator.verify({
    token,
    secret
  })
}
```

实际落地时，不要直接把 `secret` 暴露给普通业务日志，也不要在绑定完成后继续让前端反复获取这份密钥。

## 九、WebOTP 和 TOTP 不是一回事

很多前端同学会把 WebOTP 和 TOTP 混在一起，但它们解决的问题不同。

WebOTP API 是浏览器从短信中读取一次性验证码并自动填充输入框，主要提升短信验证码体验。

TOTP 是基于共享密钥和时间窗口生成动态验证码，不依赖短信。

对比一下：

| 项目 | WebOTP | TOTP |
| --- | --- | --- |
| 来源 | 短信 | 认证器本地计算 |
| 是否依赖运营商 | 依赖 | 不依赖 |
| 是否需要共享密钥 | 不需要前端保存 | 服务端和认证器持有 |
| 典型用途 | 手机号登录、短信验证 | 两步验证、敏感操作确认 |
| 前端角色 | 自动读取短信并填充 | 展示二维码、收集验证码 |

WebOTP 可以优化短信 OTP 的输入体验，但不能替代 TOTP。

## 十、安全边界

TOTP 能提高账户安全性，但它不是万能的。

需要特别注意几类风险：

- 钓鱼页面可以诱导用户输入当前 TOTP
- XSS 可以窃取用户输入的验证码
- 设备丢失会导致认证器不可用
- 服务端密钥泄露会让 TOTP 失去意义
- 缺少失败次数限制会放大暴力尝试风险

因此 TOTP 通常还需要配合：

- HTTPS
- CSP 和 XSS 防护
- 登录风险识别
- 恢复码机制
- 设备管理
- 操作审计

## 总结

TOTP 的核心并不复杂：服务端和认证器共享同一个密钥，再基于当前时间窗口各自计算一次性验证码。

真正需要关注的是工程边界：

- 密钥由服务端生成并安全保存
- 认证器保存密钥并生成验证码
- 前端只负责绑定展示和验证码输入
- 服务端负责验证、容错、限流和防重放

理解这条链路后，再看两步验证、敏感操作二次确认或企业后台登录，就能更清楚地判断哪些逻辑属于前端体验，哪些逻辑必须落在服务端安全边界内。
