OTP実装のアンチパターン7選 — NIST SP 800-63B準拠
公開日: 2026-07-18
SMS認証や電話認証によるワンタイムパスワード(OTP)は、多くのWebサービスで本人確認の要となっています。しかし「6桁の数字を送って照合するだけ」という単純さゆえに、実装の落とし穴を見落としがちです。
本記事ではOTP認証の実装でよく見られるアンチパターンを7つ取り上げ、それぞれの「悪い実装例」と「修正例」をPHPコードで対比します。SMS認証そのものを全否定するのが目的ではありません——実装の品質を高めることで、SMS/電話認証も堅牢に運用できます。
対象読者: OTP認証をゼロから実装する、または既存実装のセキュリティを見直したいエンジニア
アンチパターン①: 検証コードの有効期限が長すぎる
なぜ問題か
OTPの有効期限が長いほど、盗まれたコードが悪用される窓口が広がります。NIST SP 800-63B §5.1.3 では、SMS/電話経由のOOB(Out-of-Band)認証コードについて、有効期限を10分以内に設定することを求めています。
// ❌ 悪い例: 有効期限60分
$expires_at = now()->addMinutes(60);
$otp = rand(100000, 999999);
DB::table('otps')->insert([
'phone' => $phone,
'code' => $otp, // 平文保存
'expires_at' => $expires_at,
]);
// ✅ 修正例: 有効期限5分 + ハッシュ保存
$otp = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
DB::table('otps')->insert([
'phone' => $phone,
'code' => hash('sha256', $otp), // 平文を保存しない
'expires_at' => now()->addMinutes(5),
]);
ポイント: 有効期限は5〜10分が適切。期限切れOTPはバッチで定期削除する。
アンチパターン②: 再送エンドポイントにレート制限がない (SMS pumping)
なぜ問題か
「コードを再送する」エンドポイントにレート制限がないと、攻撃者が大量の再送リクエストを自動発行してSMS配信コストを爆増させる SMS pumping 攻撃の標的になります。FCC(米連邦通信委員会)とFTC(米連邦取引委員会)もこの手口への注意喚起を公表しています。
// ❌ 悪い例: 再送リクエストを無制限に受け付ける
Route::post('/resend-otp', function (Request $request) {
sendSms($request->phone, generateOtp());
return response()->json(['sent' => true]);
});
// ✅ 修正例: IPアドレスと電話番号の両軸でレート制限
Route::post('/resend-otp', function (Request $request) {
$keyIp = 'otp_resend_ip:' . $request->ip();
$keyPhone = 'otp_resend_phone:' . $request->phone;
if (RateLimiter::tooManyAttempts($keyIp, 5) ||
RateLimiter::tooManyAttempts($keyPhone, 3)) {
return response()->json(['error' => 'Too many requests'], 429);
}
RateLimiter::hit($keyIp, decaySeconds: 3600);
RateLimiter::hit($keyPhone, decaySeconds: 3600);
sendSms($request->phone, generateOtp());
return response()->json(['sent' => true]);
});
ポイント: 電話番号あたり1時間3〜5回を上限に。IPアドレス単位の制限も並用する。
アンチパターン③: OTPを固定値・連番で生成している
なぜ問題か
PHPの rand() や単純な連番でOTPを生成すると、値が予測可能になります。OWASP ASVS V2 §2.7 は、OTPの生成に暗号学的に安全な乱数生成器(CSPRNG)を使用することを要求しています。
// ❌ 悪い例: 予測可能な生成方法
$otp = rand(100000, 999999); // rand() は暗号学的に安全でない
// あるいは
$otp = $lastOtp + 1; // 連番は論外
// ✅ 修正例: CSPRNGを使用
$otp = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
Node.jsの場合:
const crypto = require('crypto');
const otp = crypto.randomInt(0, 1000000).toString().padStart(6, '0');
Pythonの場合:
import secrets
otp = str(secrets.randbelow(1000000)).zfill(6)
ポイント: PHP は random_int()、Node.js は crypto.randomInt()、Python は secrets.randbelow() を使う。いずれも標準ライブラリに含まれており、追加依存なしで利用可能。
アンチパターン④: 検証失敗回数を制限していない (ブルートフォース)
なぜ問題か
6桁のOTPは最大100万通りの組み合わせです。1秒あたり10回の試行を許せば、理論上は最悪27時間余りで突破されます。CWE-307(認証試行回数制限の不備) は代表的な認証脆弱性の一つです。NIST SP 800-63B §5.2.2 は、100回以内の連続失敗でロックアウトを要求しています。
// ❌ 悪い例: 試行回数無制限
$otp = DB::table('otps')->where('phone', $phone)->first();
if ($otp && $otp->code === $request->code && $otp->expires_at > now()) {
return authSuccess();
}
return response()->json(['error' => 'Invalid code'], 422);
// ✅ 修正例: 5回失敗で15分ロック + タイミング攻撃対策
$key = 'otp_attempts:' . $phone;
if (RateLimiter::tooManyAttempts($key, maxAttempts: 5)) {
return response()->json(['error' => 'Too many attempts'], 429);
}
$otp = DB::table('otps')->where('phone', $phone)->first();
$valid = $otp
&& hash_equals(hash('sha256', $request->code), $otp->code) // タイミング攻撃対策
&& $otp->expires_at > now();
if (!$valid) {
RateLimiter::hit($key, decaySeconds: 900); // 失敗→15分でリセット
return response()->json(['error' => 'Invalid'], 422);
}
RateLimiter::clear($key);
return authSuccess();
ポイント: hash_equals() でタイミング攻撃も同時に防ぐ。ロック解除は一定時間経過(デクリメントロック)が安全。
アンチパターン⑤: 成功後もOTPを無効化しない (replay攻撃)
なぜ問題か
認証成功後にOTPをDBから削除しないと、有効期限内であれば同じコードで何度でも認証できてしまいます(replay攻撃)。特にOTPが通信経路上で傍受・ログ記録されているケースでは深刻なリスクです。
// ❌ 悪い例: 検証後もOTPをDBに残す
if (verifyOtp($phone, $code)) {
return authSuccess(); // OTPは削除しない → 再利用可能
}
// ✅ 修正例: 検証成功と削除をトランザクション内でアトミックに処理
DB::transaction(function () use ($phone, $code, &$result) {
$otp = DB::table('otps')
->where('phone', $phone)
->lockForUpdate() // 二重認証防止
->first();
if ($otp && hash_equals(hash('sha256', $code), $otp->code) && $otp->expires_at > now()) {
DB::table('otps')->where('id', $otp->id)->delete(); // 使用済みを即削除
$result = true;
}
});
return $result ? authSuccess() : response()->json(['error' => 'Invalid'], 422);
ポイント: lockForUpdate() で競合リクエストによる二重認証も防止する。
アンチパターン⑥: エラーメッセージでOTP情報を漏洩している
なぜ問題か
エラーメッセージが詳細すぎると、攻撃者に余計な情報を与えます。OWASP Authentication Cheat Sheet は、認証エラーには「何が間違っているか」を具体的に示さない汎用メッセージを使うよう推奨しています。
❌ 悪い例のメッセージ:
「コードが間違っています。残り3回試行できます。」
「このコードは有効期限が切れています。再送してください。」
「この電話番号は登録されていません。」
✅ 修正例のメッセージ:
「認証に失敗しました。もう一度お試しください。」
ポイント: 「コード不一致」「有効期限切れ」「電話番号未登録」をすべて同一メッセージで返す。残り試行回数の表示はオプションとして検討(利便性とのトレードオフ)。
アンチパターン⑦: セッションに紐づけていない
なぜ問題か
OTPを電話番号だけで検証し、セッション(またはリクエストトークン)と紐づけていないと、攻撃者が正規ユーザーの電話番号を使って別のブラウザセッションから認証を通せるケースが生まれます。
// ❌ 悪い例: 電話番号だけで検証(セッション確認なし)
Route::post('/verify', function (Request $request) {
$otp = DB::table('otps')
->where('phone', $request->phone)
->where('code', hash('sha256', $request->code))
->first();
if ($otp) return authSuccess($otp->phone);
});
// ✅ 修正例: セッションIDも照合
Route::post('/verify', function (Request $request) {
$otp = DB::table('otps')
->where('phone', $request->phone)
->where('session_id', session()->getId()) // セッションIDも照合
->first();
if ($otp
&& hash_equals(hash('sha256', $request->code), $otp->code)
&& $otp->expires_at > now()
) {
DB::table('otps')->where('id', $otp->id)->delete();
return authSuccess($otp->phone);
}
return response()->json(['error' => 'Invalid'], 422);
});
ポイント: OTP発行時のセッションIDを保存しておき、照合時に一致確認する。SPA/モバイルアプリの場合はランダム生成した request_token を代替として使用できる。
OTP検証フロー — アンチパターン発生箇所
OTP検証フロー — アンチパターン発生箇所
- ユーザーが電話番号を入力して送信
- アプリが再送レート制限チェック(←アンチパターン②の対策箇所)
- CSPRNGでOTPを生成してSMS/電話配信(←③の対策箇所)
- OTPをDB/Cacheに保存(有効期限5分・ハッシュ化・session_id付)(←①⑦の対策箇所)
- ユーザーがOTPを入力して送信
- アプリが試行回数チェック(←④の対策箇所)
- OTP照合(
hash_equals)+セッション確認(←④⑦の対策箇所) - 検証後OTPを即削除(←⑤の対策箇所)
- 認証完了通知(汎用エラーメッセージ)(←⑥の対策箇所)
NISTチェックリスト (SP 800-63B 準拠)
| チェック項目 | NIST根拠 | 推奨値 / 要件 |
|---|---|---|
| OTP有効期限 | §5.1.3.2 | 10分以内 |
| 試行回数上限 | §5.2.2 | 100回以内(実装上は5〜10回推奨) |
| OTPビット長 | §5.1.3.2 | 20 bits以上(6桁数字≒20bits) |
| 乱数品質 | §5.1.3.2 | approved RBG(SP 800-90A準拠)を使用 |
| 再送レート制限 | 実装推奨 | 同一電話番号への配信は時間あたり上限設定 |
| OTP使い捨て | §5.1.3.2 | 使用後は即時無効化 |
| セッション紐づけ | §5.1.3 | OTPは発行セッションと関連付ける |
| エラー情報 | OWASP Auth Cheat Sheet | 認証失敗理由を具体的に示さない |
着信認証APIの場合: 照合をAPI側に委譲する設計
SMS OTPの場合は上記の対策をすべて自前で実装する必要がありますが、着信認証APIを使う設計では、有効期限管理とレート制限をAPI側に委譲できます。
pauth.meの /api/v1/apply エンドポイントでは、試行回数制限・有効期限管理がAPIサーバー側で一元管理されます。アンチパターン①④の実装負担を軽減できるため、自前実装の落とし穴を踏みにくい設計になっています。
# pauth.me での認証照合リクエスト例
curl -X GET "https://pauth.me/api/v1/apply?callerd_number=09011112222&pin=123456" \
-H "Authorization: Bearer YOUR_API_TOKEN"
# → HTTP 200 で認証成功(不一致時は 204 No Content)
# 有効期限管理とレート制限はAPIが担当
ブルートフォース耐性を自前でテストしたい場合は、test_プレフィックスのAPIキーを使えば本番通話なしでシミュレーションが可能です(pauth.me サンドボックス 参照)。
まとめ
今回紹介した7つのアンチパターンと対策をまとめます。
| # | アンチパターン | リスク | 主な対策 |
|---|---|---|---|
| ① | 有効期限が長い | 盗用OTPの悪用 | 5〜10分以内に設定 |
| ② | 再送制限なし | SMS pumping / コスト爆増 | IP・電話番号でレート制限 |
| ③ | 予測可能な乱数 | OTP推測 | CSPRNGを使用 |
| ④ | 試行回数無制限 | ブルートフォース | 5〜10回でロック |
| ⑤ | 成功後も有効 | Replay攻撃 | 使用後即削除(アトミック) |
| ⑥ | 詳細エラーメッセージ | 情報漏洩 | 汎用メッセージに統一 |
| ⑦ | セッション非紐づけ | 横断認証 | セッションIDも照合 |
SMS/電話認証は「OTP送信→入力→照合」の3ステップに見えても、実装すべきセキュリティ要件は多岐にわたります。NIST SP 800-63BとOWASP ASVSをチェックリストとして活用しながら、実装を見直してみてください。
▶ 関連記事: SMS認証の落とし穴3選 | SMS認証 vs 着信認証 比較ガイド | Node.jsで着信認証を実装する方法
参考文献: NIST SP 800-63B: Digital Identity Guidelines — Authentication and Lifecycle Management(NIST, 2017/2020改訂) / OWASP Application Security Verification Standard (ASVS) V2 / OWASP Authentication Cheat Sheet / CWE-307: Improper Restriction of Excessive Authentication Attempts / FCCのsmishing・SMS pumping詐欺に関する消費者向け警告(FCC, 2023)