跳至主要內容
Skip to content

SMS OTP 與 Email OTP

SMS OTP 和 Email OTP 是最常見、也最容易被誤用的驗證方式。

很多產品會做:

text
輸入手機或 Email
寄一組 6 位數驗證碼
使用者輸入驗證碼
驗證成功

這個流程看起來很簡單,但真正要做安全並不只是 Math.random() 產生 6 位數。

你要處理:

  • 驗證碼有效期。
  • 重試次數。
  • 重送節流。
  • 驗證碼儲存方式。
  • 是否綁定用途。
  • 是否綁定接收對象。
  • 是否可重放。
  • 是否會洩漏帳號存在。
  • SMS SIM swap。
  • Email 帳號被盜。
  • 即時釣魚代理。
  • 客服重設與帳號恢復。

OTP 的核心不是「寄一組碼」,而是:

text
用短效、一次性、受限制的秘密,證明使用者控制某個通道。

一、 一句話回答

如果面試官問:

SMS OTP 和 Email OTP 怎麼設計?有什麼風險?

可以先回答:

SMS OTP 和 Email OTP 都是 out-of-band 或一次性驗證碼設計,用短效驗證碼證明使用者控制手機門號或信箱。實作時驗證碼要用安全隨機產生,伺服器只存 hash,綁定 user、用途、接收目標與 challenge id,設定短有效期、嘗試次數、重送節流、一次性使用與稽核紀錄。SMS OTP 優點是普及、低門檻,但有 SIM swap、簡訊攔截、電信商社交工程等風險;Email OTP 容易實作,但如果 email 同時是帳號恢復通道,信箱被盜會讓密碼重設和 OTP 都失效。它們比沒有 MFA 好,但不應被視為高強度或釣魚抗性的 MFA,高風險場景應優先考慮 TOTP、Push number matching、WebAuthn/Passkey 或硬體安全金鑰。

短一點可以說:

text
SMS / Email OTP 是低門檻驗證,
但安全性取決於通道是否可信、OTP 是否短效一次性、以及重試與恢復流程是否嚴格。

二、 OTP 是什麼

OTP 是 One-Time Password,一次性密碼或一次性驗證碼。

它通常有幾個特性:

特性說明
短效幾分鐘內有效
一次性成功使用後立即失效
隨機攻擊者不應能猜到
有用途只能用於登入、綁定、重設等特定目的
有對象綁定某個 user、email、phone 或 challenge
有限制嘗試次數和重送次數受控

例如:

text
驗證碼 839204
用途:登入 MFA
對象:user_123
通道:sms +886912345678
有效期:5 分鐘
最多嘗試:5 次

這樣才是一個相對完整的 OTP challenge。

三、 SMS OTP 與 Email OTP 的定位

兩者都常被拿來做:

  • 註冊驗證。
  • 登入第二步。
  • 忘記密碼。
  • 修改手機 / email。
  • 高風險操作加驗。
  • 帳號恢復。

但它們的信任等級不一樣。

方法證明什麼主要問題
SMS OTP使用者目前可接收該門號簡訊SIM swap、簡訊攔截、門號回收、電信商社交工程
Email OTP使用者目前可接收該信箱郵件信箱被盜、email 同時是恢復通道、郵件延遲與釣魚

它們都不是 phishing-resistant。

使用者可以被騙到假網站輸入 OTP,攻擊者再即時轉送到真網站。

所以 SMS / Email OTP 的合理定位是:

text
比單純密碼好,
適合低到中風險場景,
但不應是高風險帳號的最高強度 MFA。

四、 基本流程

典型 OTP 流程如下:

兩個 endpoint 通常夠用:

text
POST /api/otp/challenges
POST /api/otp/challenges/:id/verify

重要的是 challenge id。

不要只靠:

text
user_id + code

因為同一使用者可能同時有多個流程:

  • 登入 MFA。
  • 修改 email。
  • 重設密碼。
  • 綁定手機。

challenge id 可以讓每個流程清楚隔離。

五、 OTP 資料模型

可以設計一張 otp_challenges

sql
create table otp_challenges (
  id uuid primary key,
  user_id uuid,
  purpose text not null,
  channel text not null,
  destination_hash text not null,
  code_hash text not null,
  attempts int not null default 0,
  max_attempts int not null default 5,
  resend_count int not null default 0,
  expires_at timestamp not null,
  used_at timestamp,
  created_at timestamp not null,
  ip inet,
  user_agent text
);

欄位說明:

欄位用途
purposelogin、signup、password_reset、change_email、link_phone
channelsms 或 email
destination_hash手機或 email 的 hash,避免明文散落
code_hashOTP hash,不存明文 code
attempts已嘗試次數
max_attempts最大嘗試次數
expires_at過期時間
used_at是否已使用
ip / user_agent風險分析與稽核

為什麼不存明文 OTP?

因為 OTP 雖然短效,但仍然是可用來完成身份驗證的秘密。資料庫或 log 外洩時,不應讓攻擊者直接拿到還沒過期的 OTP。

可以使用:

text
HMAC(server_secret, challenge_id + code)

或使用密碼雜湊方式,但 OTP 很短,單純 hash 容易被離線暴力猜完。更常見是使用 server-side pepper / HMAC,並搭配短效與嘗試限制。

六、 產生 OTP

不要使用:

ts
Math.random()

要使用 cryptographically secure random。

Node.js 範例:

ts
import crypto from 'crypto'

function generateNumericOtp(length = 6) {
  const max = 10 ** length
  const value = crypto.randomInt(0, max)
  return value.toString().padStart(length, '0')
}

常見長度:

長度組合數
6 位數1,000,000
8 位數100,000,000

6 位數不是靠「很難猜」保護,而是靠:

  • 短有效期。
  • 最大嘗試次數。
  • 速率限制。
  • 一次性使用。
  • 風險偵測。

如果 6 位數 OTP 可以無限猜,那它很快就不安全。

七、 有效期與重試限制

常見設定:

text
有效期:5 到 10 分鐘
最大嘗試:3 到 5 次
重送間隔:30 到 60 秒
每日發送上限:依風險與成本設定

驗證時要檢查:

ts
function verifyOtp(challenge, inputCode) {
  if (challenge.usedAt) {
    throw new Error('OTP_ALREADY_USED')
  }

  if (challenge.expiresAt < new Date()) {
    throw new Error('OTP_EXPIRED')
  }

  if (challenge.attempts >= challenge.maxAttempts) {
    throw new Error('OTP_TOO_MANY_ATTEMPTS')
  }

  const inputHash = hashOtp(challenge.id, inputCode)
  if (!timingSafeEqual(inputHash, challenge.codeHash)) {
    incrementAttempts(challenge.id)
    throw new Error('OTP_INVALID')
  }

  markOtpUsed(challenge.id)
  return true
}

實務上錯誤訊息對外可以模糊一點:

text
驗證碼無效或已過期。

避免讓攻擊者知道是猜錯、過期、還是帳號存在。

八、 重送與節流

「重送驗證碼」如果沒限制,會造成:

  • SMS 成本爆炸。
  • 對使用者簡訊 / 信箱轟炸。
  • 被拿來做騷擾。
  • 暴力嘗試變容易。
  • provider reputation 下降。

節流應該分層:

維度例子
destination同一手機 / email 每分鐘最多一次
user同一 user 每小時最多幾次
IP同一 IP 每分鐘最多幾次
device/session同一裝置短時間限制
purposepassword reset 比一般驗證更嚴格
globalprovider 異常時熔斷

重送時有兩種策略:

1. 產生新 OTP

優點:

  • 舊碼失效,邏輯清楚。

缺點:

  • 使用者可能收到多封簡訊 / email,不知道哪一組有效。

2. 有效期內重送同一 OTP

優點:

  • 使用者體驗較穩定。

缺點:

  • 同一 code 暴露時間較長。

兩種都可以,重點是規則要清楚。

如果採用新 OTP,建議:

text
每次重送都讓舊 challenge 失效。

避免多組 code 同時有效。

九、 綁定用途與目標

OTP 必須綁定用途。

不要讓:

text
登入 OTP

可以拿去:

text
修改 email

也不要讓:

text
發給 alice@example.com 的 OTP

可以拿去驗證:

text
bob@example.com

設計時至少要綁:

  • challenge id。
  • user id 或 anonymous flow id。
  • purpose。
  • destination。
  • channel。
  • expiration。

例如:

ts
type OtpChallenge = {
  id: string
  userId?: string
  anonymousFlowId?: string
  purpose: 'login_mfa' | 'signup_email_verify' | 'password_reset' | 'change_phone'
  channel: 'sms' | 'email'
  destinationHash: string
  codeHash: string
  expiresAt: Date
}

這樣才能避免 OTP 被跨流程重用。

十、 SMS OTP 風險

SMS 最大優點是普及。

幾乎每個人都有手機門號,不需要安裝 app。

但 SMS 的風險也很明確。

1. SIM swap

攻擊者透過社交工程、資料外洩或電信商流程,把受害者門號轉移到攻擊者 SIM。

之後攻擊者就能接收 SMS OTP。

2. 簡訊攔截

風險可能來自:

  • SS7 等電信網路風險。
  • 惡意 App 讀取簡訊。
  • 手機被入侵。
  • 雲端同步通知外洩。

3. 門號回收

手機門號可能被停用後重新分配給別人。

如果你的系統長期信任舊門號,就可能把 OTP 發給新的門號持有人。

4. 海外與到達率

SMS 可能有:

  • 延遲。
  • 被電信商擋。
  • 國際簡訊成本高。
  • 某些地區無法穩定送達。

所以 SMS OTP 的產品定位應該是:

text
低門檻、普及、可作為過渡方案;
但高風險帳號應提供並鼓勵更強 MFA。

NIST 對使用電話網路進行 out-of-band 驗證採取 restricted authenticator 的觀點,也就是不是完全禁止,而是要求系統了解風險、提供替代方案,並隨威脅模型調整。

十一、 Email OTP 風險

Email OTP 更容易實作,但它常被過度信任。

1. Email 也是帳號恢復通道

很多系統的邏輯是:

text
忘記密碼 -> 寄信到 email
登入 MFA -> 也寄 OTP 到 email

如果攻擊者控制 email,就同時控制:

  • 密碼重設。
  • OTP。
  • 安全通知。

這會讓 email 失去第二因素的獨立性。

2. 信箱登入狀態長期存在

很多使用者的 email app 長期登入在手機或瀏覽器。

如果裝置被偷、瀏覽器 session 被盜,email OTP 也可能被拿到。

3. 釣魚與轉寄規則

攻擊者可能:

  • 釣魚取得 email 帳號。
  • 設定自動轉寄。
  • 設定隱藏規則。
  • 搜尋歷史驗證信。

4. 到信延遲與垃圾信

Email OTP 有時會延遲或進垃圾信,導致使用者重送多次,增加流程混亂。

所以 Email OTP 適合:

  • email 驗證。
  • 低風險加驗。
  • 帳號恢復的一部分。

但不適合單獨承擔高風險 MFA。

十二、 訊息內容設計

SMS / Email 內容不要洩漏太多。

可以寫:

text
你的驗證碼是 839204,5 分鐘內有效。請勿提供給他人。

對高風險操作,可以加上用途:

text
你正在修改登入 Email,驗證碼是 839204,5 分鐘內有效。若非本人操作,請立即重設密碼。

不要寫:

text
你的帳號 alice@example.com 正在從台北登入,IP 是 ...

SMS 內容可能出現在鎖定畫面或通知中心,應避免過多敏感資訊。

Email 通知可包含較多安全資訊,但仍要避免把 token 放在容易被第三方掃描或轉寄的地方。

十三、 OTP 與帳號枚舉

常見問題:

text
使用者輸入不存在的 email 要不要寄 OTP?

如果直接回:

text
這個 email 不存在。

攻擊者可以枚舉帳號。

常見做法:

text
如果帳號存在,我們已寄出驗證碼。

但內部行為要小心。

對忘記密碼:

  • 不存在的 email 不寄信。
  • 對外回應相同。
  • 仍可做假延遲,避免 timing side channel。

對註冊:

  • 如果 email 已存在,可提示登入或重設密碼。
  • 但訊息要視產品風險設計。

對登入 MFA:

  • 通常使用者已通過第一步密碼。
  • 這時可進入 MFA challenge,但錯誤訊息仍不應過度洩漏。

十四、 OTP 與釣魚

OTP 最大誤解是:

text
有 OTP 就防釣魚。

但攻擊者可以:

  1. 做一個假的登入頁。
  2. 要使用者輸入密碼。
  3. 真網站觸發 OTP。
  4. 使用者把 OTP 輸入假網站。
  5. 攻擊者立刻把 OTP 輸入真網站。

這叫即時釣魚代理或 adversary-in-the-middle 類型攻擊。

SMS、Email、TOTP 都可能被這樣轉送。

防線:

  • WebAuthn / Passkey。
  • number matching。
  • 顯示登入上下文。
  • 風險偵測。
  • 通知使用者不要提供 OTP。
  • 對高風險操作使用更強驗證。

OTP 是防密碼外洩,不是完整防釣魚。

十五、 實作範例

建立 challenge

ts
app.post('/api/otp/challenges', async (req, res) => {
  const { purpose, channel, destination } = req.body

  await assertRateLimit({
    purpose,
    channel,
    destination,
    ip: req.ip,
  })

  const code = generateNumericOtp(6)
  const challengeId = crypto.randomUUID()

  await db.otpChallenge.create({
    data: {
      id: challengeId,
      userId: req.user?.id ?? null,
      purpose,
      channel,
      destinationHash: hashDestination(destination),
      codeHash: hashOtp(challengeId, code),
      maxAttempts: 5,
      expiresAt: new Date(Date.now() + 5 * 60 * 1000),
      ip: req.ip,
      userAgent: req.headers['user-agent'],
    },
  })

  await sendOtp({ channel, destination, code, purpose })

  res.json({
    challengeId,
    expiresInSeconds: 300,
    resendAfterSeconds: 60,
  })
})

驗證 challenge

ts
app.post('/api/otp/challenges/:id/verify', async (req, res) => {
  const challenge = await db.otpChallenge.findUnique({
    where: { id: req.params.id },
  })

  if (!challenge) {
    return res.status(400).json({ code: 'OTP_INVALID' })
  }

  if (challenge.usedAt || challenge.expiresAt < new Date()) {
    return res.status(400).json({ code: 'OTP_INVALID' })
  }

  if (challenge.attempts >= challenge.maxAttempts) {
    return res.status(429).json({ code: 'OTP_TOO_MANY_ATTEMPTS' })
  }

  const ok = safeCompare(
    hashOtp(challenge.id, req.body.code),
    challenge.codeHash,
  )

  if (!ok) {
    await incrementAttempts(challenge.id)
    return res.status(400).json({ code: 'OTP_INVALID' })
  }

  await markOtpUsed(challenge.id)
  await auditLog('otp_verified', {
    challengeId: challenge.id,
    purpose: challenge.purpose,
    channel: challenge.channel,
  })

  res.json({ verified: true })
})

十六、 常見錯誤

1. OTP 明文存在資料庫

錯誤:

text
code = 839204

正確:

text
code_hash = HMAC(server_secret, challenge_id + code)

2. 沒有限制嘗試次數

錯誤:

text
6 位數 OTP 可以一直猜。

正確:

text
限制 3 到 5 次,超過就失效或鎖定。

3. 沒有重送節流

錯誤:

text
使用者可以每秒重送一次 SMS。

正確:

text
限制同一 destination、IP、user、purpose 的頻率。

4. OTP 沒有綁定用途

錯誤:

text
任何 OTP 都能驗任何流程。

正確:

text
OTP 必須綁定 purpose、challenge、destination。

5. 驗證成功後沒有失效

錯誤:

text
同一組 code 可以多次使用。

正確:

text
成功後立即 used_at,不能重放。

6. 把 SMS 當高強度 MFA

錯誤:

text
金融或管理員帳號只支援 SMS OTP。

正確:

text
SMS 可作過渡或備援,但高風險帳號應支援 TOTP、WebAuthn、Passkey 或硬體安全金鑰。

十七、 面試追問題庫

Q1:SMS OTP 安全嗎?

比沒有 MFA 好,但不算高強度。它有 SIM swap、簡訊攔截、門號回收、電信商社交工程等風險。高風險系統應提供更強方法,例如 TOTP、WebAuthn、Passkey 或硬體安全金鑰。

Q2:Email OTP 可以當 MFA 嗎?

可以作為低成本加驗或 email 驗證,但要看它是否真的獨立。如果 email 同時是密碼重設通道,攻擊者控制 email 後就能同時重設密碼和取得 OTP,安全性會降低。

Q3:OTP 要不要存在資料庫?

可以存 challenge,但不要存明文 OTP。應存 hash 或 HMAC 結果,並設定短有效期、最大嘗試次數、一次性使用和稽核紀錄。

Q4:6 位數 OTP 夠嗎?

6 位數有一百萬種組合,本身不靠組合數單獨保護。它需要搭配短有效期、嘗試次數限制、速率限制、一次性使用和風險偵測。

Q5:重送 OTP 要產生新碼還是重送舊碼?

兩種都可以。產生新碼時應讓舊 challenge 失效,避免多組 code 同時有效。重送舊碼體驗較穩定,但同一 code 暴露時間較長。無論哪種,都要有重送節流。

Q6:OTP 如何防止跨流程使用?

OTP challenge 應綁定 purpose、destination、user 或 anonymous flow、challenge id 和 expires_at。登入 OTP 不能拿去修改 email,修改手機的 OTP 也不能拿去重設密碼。

Q7:OTP 能防釣魚嗎?

不能完整防。即時釣魚代理可以讓使用者把 OTP 輸入假網站,再即時轉送到真網站。若要釣魚抗性,應使用 WebAuthn / Passkey / FIDO2。

十八、 實作題

題目一:設計 OTP challenge 表

要求:

  • 不存明文 code。
  • 支援 SMS 和 Email。
  • 支援不同用途。
  • 有有效期。
  • 有最大嘗試次數。
  • 有重送次數。
  • 可記錄 IP / User-Agent。
  • 成功後一次性失效。

題目二:設計重送節流

請設計以下限制:

text
同一 destination 60 秒內最多 1 次。
同一 IP 1 分鐘最多 10 次。
同一 user 1 小時最多 5 次。
同一 destination 1 天最多 10 次。

要說明超過限制時:

  • 回傳通用錯誤。
  • 不透露帳號是否存在。
  • 記錄風險事件。
  • 高風險時可要求 CAPTCHA 或更強驗證。

題目三:設計修改手機流程

情境:

text
使用者要把手機從 A 改成 B。

好的流程應包含:

  • 使用者已登入。
  • 要求近期重新驗證。
  • 對新手機 B 發 OTP。
  • 必要時對舊手機 A 或 email 發通知。
  • 驗證 B 成功後才更新。
  • 更新後寄通知。
  • 記錄 audit log。
  • 高風險時撤銷其他 session。

十九、 資深視角

1. OTP 是 challenge,不是欄位

很多簡單系統會在 user table 放:

text
otp_code
otp_expires_at

這很快會不夠用。

成熟系統應該把 OTP 視為一次 challenge:

  • 它有用途。
  • 它有接收對象。
  • 它有狀態。
  • 它有風險上下文。
  • 它有嘗試次數。
  • 它有稽核紀錄。

2. SMS / Email OTP 的安全性是通道安全性

SMS OTP 信任電信門號。

Email OTP 信任信箱。

如果通道被接管,OTP 就失效。

所以高風險系統不能只問:

text
使用者有沒有輸入正確 OTP?

還要問:

text
這個通道本身是否足夠可信?
這個操作是否需要更強因素?

3. OTP 最怕「旁路」

OTP 本身設計再好,如果客服可以直接改手機、email 可以直接重設所有東西、或管理員可以無審核關閉 MFA,整體仍然弱。

所以 OTP 要和以下流程一起設計:

  • 忘記密碼。
  • 修改 email。
  • 修改手機。
  • MFA 重設。
  • 帳號恢復。
  • 客服支援。
  • 安全通知。
  • session 撤銷。

4. 引導使用者升級更強 MFA

SMS / Email OTP 很適合當起點。

但成熟產品應該引導高風險使用者升級:

  • TOTP。
  • Passkey。
  • WebAuthn security key。
  • 多個備援 authenticator。

不要讓最弱、最普及的方式成為所有帳號的最高上限。


總結

問題回答重點
OTP短效、一次性、受限制的驗證碼
SMS OTP普及但有 SIM swap 與攔截風險
Email OTP易實作,但信箱被盜會影響恢復與通知
是否存明文不應存明文,應存 hash/HMAC
有效期常見 5 到 10 分鐘
嘗試次數常見 3 到 5 次
重送要節流,避免成本、騷擾與暴力嘗試
綁定用途purpose、destination、challenge id 都要綁
釣魚抗性SMS/Email OTP 不具完整釣魚抗性
高風險替代TOTP、WebAuthn、Passkey、硬體安全金鑰

一句完整的面試回答可以是:

SMS OTP 和 Email OTP 是用短效一次性驗證碼證明使用者控制手機門號或信箱的方式。實作時不能存明文 OTP,應使用安全隨機產生 code,伺服器保存 hash 或 HMAC,並把 challenge 綁定 user、purpose、destination、channel、有效期和嘗試次數。驗證成功後要立即失效,錯誤次數過多要鎖定或讓 challenge 失效,重送要針對 destination、user、IP、purpose 做節流。SMS OTP 普及但有 SIM swap、簡訊攔截、門號回收與電信商社交工程風險;Email OTP 容易實作,但如果 email 同時負責密碼重設,信箱被盜時會變成單點失效。它們比沒有 MFA 好,但不具完整釣魚抗性,高風險帳號應提供 TOTP、WebAuthn、Passkey 或硬體安全金鑰等更強方式。

延伸閱讀