TOTPの仕組み徹底解説:認証コードを生成するアルゴリズム
週に何度か、認証アプリの6桁のコードを入力する。同じコードがスマートフォンとサーバーの両方に出るのに、その2つは一度も通信していない。それでも値は一致する。同じ共有シークレットが30秒ごとに新しい数字を作り出すからで、その仕組みは、双方が別々に実行する小さな決定論的アルゴリズムだ。コードがネットワークを流れることはないし、中央のサーバーが番号を配るわけでもない。
TOTP(Time-based One-Time Password)は RFC 6238 で定義されている。共有シークレットと現在時刻をもとに時刻の HMAC を計算し、その結果を切り詰めて短い数値コードにする。二要素認証(2FA)は、双方が値を交換せずに同じ値を計算できることで成り立つので、このアルゴリズムが信頼モデルの中身そのものになっている。
この記事では具体的な数値を追いながらアルゴリズムを最初から最後までたどり、多くの解説が飛ばす後半、つまりサーバーが実際にどうコードを検証するのか、2FAが何を防いで何を防がないのかも扱う。読みながら TOTPジェネレーター で実際のコードを計算してみてもいい。
TOTPとは何か
TOTP(Time-based One-Time Password)は RFC 6238 で定義されたアルゴリズムで、共有シークレットと現在時刻を組み合わせ、一定の間隔で更新される短いコードを作る。認証アプリとサーバーは同じシークレットを持ち、同じ時計を読み、同じ計算をする。だからコードを一度も送らずに、両者とも同じコードへたどり着く。
押さえておきたいのはこの最後の点だ。セットアップのときに渡るのはシークレットだけで、コードそのものは渡らない。あとは各側が自分でコードを導く。ネットワーク上で傍受できるのは、登録時のシークレットと、ログイン時にユーザーが入力する6桁の数字だけだ。3つの入力が1つの出力にまとまる、と考えればいい。
| 入力 | 役割 | 典型的な値 |
|---|---|---|
| 共有シークレット | 登録時に一度だけ合意する、長期間使う鍵 | JBSWY3DPEHPK3PXP(Base32) |
| 時刻ステップ | 前へ刻んでいくカウンター | 30秒のウィンドウ |
| 出力 | 上記2つから導かれる短いコード | 324550 |
シークレットはたいてい Base32(A–Z の文字と 2–7 の数字)で書かれる。大文字小文字を区別せず、印刷しても入力してもQRコードに入れても壊れないからだ。登録のやり方は2つで、認証用QR として描ける otpauth:// URI をスキャンするか、Base32 文字列を手で打ち込む。
TOTP vs HOTP vs SMS vs パスキー:2FAの全体像
TOTPはいくつかある選択肢の1つで、選ぶときは他の方式と並べて見ると判断しやすい。一行で覚えるなら、TOTPはHOTPのカウンターを「Unixエポックからの時刻ステップ数」に置き換えたものだ。残りの違いは、フィッシング耐性と使い勝手、必要なインフラのトレードオフに尽きる。
| 方式 | 駆動要素 | コードの有効期間 | フィッシング耐性? | ネットワーク要? | 典型的な用途 |
|---|---|---|---|---|---|
| HOTP(RFC 4226) | 増加するカウンター | 使用するまで | なし | 不要 | ハードウェアトークン、レガシー |
| TOTP(RFC 6238) | 現在時刻 | 約30秒 | なし | 不要(登録後) | 認証アプリ |
| SMS OTP | サーバーがコードを送信 | 数分 | なし | 必要(携帯網) | 一般向けのフォールバック |
| プッシュ承認 | デバイスへのサーバープロンプト | リクエストごと | 部分的 | 必要 | アプリベースの2FA |
| パスキー / FIDO2 | 公開鍵チャレンジ | リクエストごと | あり(オリジン束縛) | 必要 | 現代的なアカウント |
表からいくつか傾向が読み取れる。TOTPとHOTPは登録後はオフラインで動くので、丈夫でプライバシーも保ちやすい。ただし単体ではフィッシングに弱く、よくできた偽サイトがコードを尋ねて中継できてしまう。SMSはネットワークのチャネルが増える分、そこが新しい攻撃面になる。パスキーは認証情報をサイトのオリジンに縛りつけるので、フィッシングの隙そのものを消す。新しいアカウントがこの方式へ移っているのはこのためだ。それでもTOTPは、そこそこ強くてどこでも使えて無料という条件がそろっているので、いまも広く使われている。
TOTPアルゴリズムの仕組み、ステップごとに
アルゴリズムは全部で4ステップだ。数値を手元で再現できるよう、各ステップを RFC のテスト用シークレット JBSWY3DPEHPK3PXP と、固定した Unix時刻 1700000000 で動かしていく。
- Base32シークレットをデコードして、生の鍵バイト列にする。
- 現在の Unix時刻から時刻ステップカウンターを計算する。
- シークレット鍵でカウンターを HMAC する。
- ダイジェストを切り詰めて6桁のコードにする。
ステップ1 — Base32シークレットをバイト列にデコードする
Base32は各文字に5ビットを詰め込む形式で、デコーダーはその文字を8ビットのバイトへ組み直す。シークレット JBSWY3DPEHPK3PXP をデコードすると、10個の生バイト 48 65 6c 6c 6f 21 de ad be ef になる。HMAC の鍵になるのは、表示用の文字列ではなくこのバイト配列のほうだ。
ステップ2 — 時刻ステップカウンターを計算する
カウンターは、ある起点から経過した時刻ステップの数(端数は切り捨て)だ。式にすると T = floor((unixTime − T0) / period) になる。RFC のデフォルトは T0 = 0(Unixエポック)と period = 30。unixTime = 1700000000 なら T = floor(1700000000 / 30) = 56666666 だ。この整数を8バイトのビッグエンディアン値 00 00 00 00 03 60 aa 2a として符号化する。カウンターが変わるのは新しい30秒ウィンドウに入ったときだけなので、どのコードも1ウィンドウのあいだは同じままで、そのあと切り替わる。
ステップ3 — シークレットでカウンターを HMAC する
アルゴリズムは、シークレットのバイト列を鍵にして、8バイトのカウンターに HMAC-SHA1 を計算する。HMAC は鍵付きの一方向関数なので、シークレットがなければダイジェストを逆算することも、通る値を作り出すこともできない。コードが偽造できないのはここに理由がある。今回の入力だと、ダイジェストは20バイト 1d 70 6e 94 1a c7 6b 6d 4a 46 dd 6f af a4 5f e3 35 11 bf 86 になる。
ステップ4 — 6桁コードへの動的切り詰め(RFC 4226)
20バイトのダイジェストはそのまま入力するには長すぎるので、RFC 4226 の動的切り詰めがここから数値を抜き出す。まず最終バイトの下位ニブルをオフセットにする。最終バイトは 0x86、その下位ニブルは 6 なので、オフセットは 6 だ。そのオフセットから4バイト(6b 6d 4a 46)を読み、先頭バイトの最上位ビットをマスクして値を正に保つと、整数 1802324550 になる。これを 10^6 で割った余りを取ってゼロ埋めする。1802324550 % 1000000 = 324550。これが、このシークレットでこの瞬間にアプリが見せるコードだ。
依存ライブラリなしで、ブラウザの Web Crypto API だけを使った JavaScript 実装が以下だ。各コメントが、ブロックを上の4ステップのどれかに対応づけている。
// TOTP per RFC 6238 — SHA-1, 6 digits, 30s period (the defaults).
async function generateTotp(base32Secret, unixTime = Date.now() / 1000) {
// Step 1: decode the Base32 secret (A-Z, 2-7) to raw key bytes.
const alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = '';
for (const ch of base32Secret.replace(/=+$/, '').toUpperCase()) {
bits += alpha.indexOf(ch).toString(2).padStart(5, '0');
}
const keyBytes = new Uint8Array(
bits.match(/.{8}/g).map((b) => parseInt(b, 2)));
// Step 2: counter = number of 30s steps since the epoch (8-byte big-endian).
let counter = Math.floor(unixTime / 30);
const msg = new Uint8Array(8);
for (let i = 7; i >= 0; i--) {
msg[i] = counter & 0xff;
counter = Math.floor(counter / 256);
}
// Step 3: HMAC-SHA1 the counter with the secret key.
const key = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
const hmac = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
// Step 4: dynamic truncation (RFC 4226) -> 6-digit code.
const offset = hmac[hmac.length - 1] & 0x0f;
const binary = ((hmac[offset] & 0x7f) << 24) | (hmac[offset + 1] << 16) |
(hmac[offset + 2] << 8) | hmac[offset + 3];
return (binary % 1_000_000).toString().padStart(6, '0');
}
const code = await generateTotp('JBSWY3DPEHPK3PXP', 1700000000);
console.log(code); // -> "324550"
同じアルゴリズムを、標準ライブラリ(hmac と struct)だけで書くと Python ではこうなる。
import base64, hmac, hashlib, struct, time
def totp(secret, for_time=None, period=30, digits=6, digest='sha1'):
if for_time is None:
for_time = time.time()
# Step 1: Base32-decode the secret to raw key bytes.
key = base64.b32decode(secret.upper())
# Step 2: counter = number of time steps since the epoch (8-byte big-endian).
counter = int(for_time // period)
msg = struct.pack(">Q", counter)
# Step 3: HMAC the counter with the secret.
h = hmac.new(key, msg, digest).digest()
# Step 4: dynamic truncation (RFC 4226) -> N-digit code.
offset = h[-1] & 0x0f
binary = ((h[offset] & 0x7f) << 24 |
(h[offset + 1] & 0xff) << 16 |
(h[offset + 2] & 0xff) << 8 |
(h[offset + 3] & 0xff))
return str(binary % (10 ** digits)).zfill(digits)
print(totp('JBSWY3DPEHPK3PXP', 1700000000)) # -> 324550
どちらの実装も、固定した時刻に対して 324550 を出すし、公式の RFC 6238 テストベクターも再現する(たとえば T = 59 の SHA-1 ベクターは 94287082 を返す)。SHA-1 を SHA-256 や SHA-512 に替えたり桁数を変えたりした場合、検証する側がまったく同じ設定に合わせていないと、コードは一致しない。
TOTPコードをサーバー側で検証する
コードを生成するのはシステムの半分でしかない。もう半分は、ユーザーがいま入力した6桁を通すかどうかをサーバーが決める処理で、セキュリティ上のトレードオフはここに集まっている。
サーバーはコードを保存しない。保存するのはシークレットだけで、ログインのたびにそのシークレットと現在時刻から期待されるコードを計算し直して比べる。やっかいなのは時計のずれだ。ユーザーのデバイスとサーバーが秒単位までそろうことはまずないので、厳密な等値チェックだとウィンドウの境界近くのコードまで弾いてしまう。そこで使うのが、小さな検証ウィンドウだ。現在のステップと前後1ステップずつ、つまりカウンター T−1、T、T+1 のコードを確かめる。ウィンドウを広げればずれに寛容になるが、推測されうるコードも増える。ウィンドウ 1(±30秒)がよく使われるバランスなのはそのためだ。ツールの「検証」タブでも同じ±1ステップの許容を見られる。
import { createHmac, timingSafeEqual } from 'crypto';
function verifyTotp(secret, code, { window = 1, period = 30, digits = 6 } = {}) {
const counter = Math.floor(Date.now() / 1000 / period);
const submitted = Buffer.from(code);
// Check the current step and ±window steps for clock drift.
for (let i = -window; i <= window; i++) {
const expected = Buffer.from(totpAt(secret, counter + i, digits));
// Constant-time compare so timing can't leak a partial match.
if (expected.length === submitted.length &&
timingSafeEqual(expected, submitted)) {
return counter + i; // matched step — store it to block replay
}
}
return false;
}
これを「動く」から「安全」に引き上げる仕掛けがあと2つある。1つはリプレイ防止だ。ユーザーごとに最後に受け入れたカウンターを保存し、それ以下のステップから来たコードは全部拒否する。こうすれば、一度盗み見られたコードを同じウィンドウ内で使い回せない。verifyTotp が true ではなく一致したステップを返すのは、これに使うためだ。もう1つはレート制限だ。6桁のコードは100万通りのうちの1つで、±1ウィンドウだとそのうち3つが常に通る。何も絞らなければ、攻撃者はこの空間を総当たりできてしまう。数回失敗したらアカウントをロックするか、待ち時間を入れるとよい。それからシークレットは長く使う鍵なので、保存時に暗号化し、ソース管理には置かず、パスワードと同じように扱う。デバイスをなくした日に備えて、強力なリカバリーコード も一緒に作っておこう。
TOTPが防ぐもの、防がないもの
TOTPはパスワードだけのときよりはっきり安全になるが、すべてを守るわけではない。製品ページはこの穴をぼかしがちなので、何を守って何を守らないかをはっきり分けておく。
| TOTPが防ぐもの | TOTPが防がないもの |
|---|---|
| 漏洩・使い回しされたパスワード | リアルタイムフィッシング / 中間者攻撃 |
| クレデンシャルスタッフィング | デバイスからシークレットを読み取るマルウェア |
| リモートでのパスワード総当たり | 2FAを飛ばす脆弱なアカウント復旧フロー |
| パスワードハッシュのみを露出するDB侵害 | (これらには別の防御が必要) |
守れる範囲は大きい。ログインにはシークレットからしか作れないコードが要るので、パスワードが漏れただけでは通らなくなる。これでクレデンシャルスタッフィングとリモート総当たりはまず効かなくなる。データベースが漏れても、TOTP シークレットが保存時に暗号化されていれば、攻撃者はコードを作れない。
ただし穴も実在する。リアルタイムフィッシングのプロキシ(中間者ページ)は、本物そっくりの画面をユーザーに見せ、その場のコードを受け取って、同じウィンドウ内で本物のサイトへ中継できる。TOTPには、そのコードが間違った場所に入力されたかどうかを見分ける手段がない。シークレットを抜き取るデバイス上のマルウェアにかかれば、TOTPは丸ごと破られる。雑な「2FAを忘れた」復旧フローも、TOTPをまるごと回避してしまう。よくある勘違いを1つ正しておくと、SIMスワップ攻撃が破るのは SMS のワンタイムコードで、TOTP ではない。TOTPには電話番号のチャネルがなく、攻撃者がリダイレクトできるものがそもそも存在しないからだ。
この先の選択肢も挙げておく。パスキーと FIDO2/WebAuthn はオリジンに縛られるので、仕組みのうえでフィッシングに強い。認証情報が、間違ったドメインへの認証を最初から受け付けないからだ。TOTPは、パスワードだけの状態から確実に一段上がる手段として使い、これで完成とは考えないのがいい。TOTPは認証スタックの他の部分ともきれいに噛み合う。検証済みのログインに乗るセッショントークンの層はJWTセキュリティのベストプラクティスを、2FAが補う保存時パスワードの層はパスワードハッシュ化(bcrypt vs Argon2)を見てほしい。
TOTPを実装するときによくある落とし穴
TOTPのバグはたいてい、RFCで固められたアルゴリズム本体ではなく、その周りの配線で起きる。実装でつまずきやすいのは次のあたりだ。
- サーバーの時計ずれ。 サーバーで NTP が動いていないと、サーバーの「今」がユーザーのデバイスから少しずつずれて、誰のコードも一致しなくなる。すべてのホストでネットワーク時刻同期を有効にしておく。
- 平文の、あるいはコミットしてしまったシークレット。 git にチェックインされた設定ファイルの中のシークレットは、ずっと開いたままのバックドアになる。シークレットマネージャーに暗号化して置き、ソース管理には絶対に入れない。
- リプレイ防止がない。 一致したステップを記録せずにコードを通すと、同じコードがウィンドウ内でもう一度通ってしまう。ユーザーごとに最後に使ったステップを保存し、再利用は拒否する。
- ウィンドウが広すぎる、または狭すぎる。 広すぎると当てられるコードが増えて弱くなり、狭すぎると少しのずれで正規のユーザーまで弾く。ウィンドウ 1 がふつうの落とし所だ。
- パラメータの食い違い。 登録時の
otpauth://URI が SHA-256 と8桁を入れているのに、検証側が SHA-1 と6桁のつもりだと、どのコードも通らない。アルゴリズム・桁数・周期は URI から読み取り、両側でそろえる。 - バックアップやリカバリーコードがない。 スマートフォンをなくすと、戻る道は復旧経路しかない。セットアップ時にリカバリーコードを出し、アカウントの重みに見合う強さにしておく。パスワードエントロピー の裏にあるのと同じ理屈が、復旧用シークレットにもそのまま効く。
FAQ
TOTPはフィッシングに完全耐性があるのか?
ない。TOTPは漏洩パスワードとリモート総当たりは防ぐが、リアルタイムフィッシングのプロキシは偽のログイン画面を見せ、その場のコードを受け取って、同じ30秒ウィンドウ内で本物のサイトへ中継できる。フィッシングに強くしたいならパスキーと FIDO2 で、これらは認証情報をサイトのオリジンに縛りつける。
TOTPは SMS の 2FA より安全か?
安全だ。SMSのコードは携帯網を通る分、SIMスワップや SS7 攻撃で傍受されうるし、キャリアのセキュリティ任せになる。TOTPには電話番号のチャネルがなく、コードを一切送らないので、途中で傍受できるものがない。やり取りされるのはセットアップ時のシークレット1回きりだ。
スマートフォンや認証アプリを失ったらどうなるのか?
前もって用意したバックアップが要る。手としては、2FAを設定したときに保存したリカバリーコード、同じシークレットで登録しておいた2台目のデバイス、安全な場所にしまった元の Base32 シークレットのどれかだ。どれもなければ、デバイスをなくした時点でアカウントから締め出される。
サーバーはどうやって TOTP コードを検証するのか?
共有シークレットと現在時刻から期待されるコードを計算し直し、送られてきたコードを現在の時刻ステップと前後1ステップ(時計のずれに備える分)に照らし合わせる。あわせて、同じコードのリプレイを防ぐために一致したステップを記録し、推測を防ぐために試行回数を制限する。
TOTPコードはなぜ30秒ごとに更新されるのか?
30秒は RFC 6238 のデフォルト周期だ。コードを落ち着いて読んで入力できるくらいには長く、攻撃者がコードを手に入れてもほぼすぐ無効になるくらいには短い。60秒の周期を使うシステムもあり、検証側が合わせられるよう otpauth:// URI にその値が入る。
2台のデバイスで1つの TOTP シークレットを共有できるか?
できる。同じ Base32 シークレットと同期した時計を持つデバイスは、アルゴリズムが決定論的なのでどれも同じコードを出す。複数デバイス対応の認証アプリのバックアップが成り立つのはこの性質のおかげで、同時に、シークレットを秘密にしておかないといけない理由でもある。シークレットをコピーした人は、これから先のコードを全部作れてしまうからだ。
TOTPは Google Authenticator と同じものか?
違う。TOTPは RFC 6238 で定義されたオープンなアルゴリズムで、Google Authenticator、Authy、1Password はそれを実装したアプリだ。標準が共通なので、準拠したアプリなら TOTP を使うどのサービスとも組み合わせて動く。特定ベンダーへのロックインはない。
まとめ
要点は、覚えておける程度に短くまとまる。
- TOTPは、共有シークレットと現在時刻を HMAC と切り詰めでコードに変える。
- 双方が別々にコードを計算する。コードがネットワークを流れることはない。
- 検証は ±1ステップのウィンドウに、リプレイ防止とレート制限を足して行う。
- パスワードへの攻撃は防ぐが、リアルタイムフィッシングは防げない。そこを塞ぐのがパスキーだ。
- サーバーの時計を NTP で合わせ、シークレットは暗号化して秘密にしておく。
アルゴリズムが実際の数字を出すところや、自分の検証ウィンドウを確かめてみたいなら、TOTP / 2FAジェネレーター を開いてほしい。コードの計算もセットアップも検証もすべてブラウザ内で完結し、シークレットがデバイスの外に出ることはない。