2FAは「QRを出して終わり」じゃない。リカバリーコードと復旧で詰む話
TOTPの2要素認証で本当に難しいのは復旧。リカバリーコードの保存・ハッシュ化・復旧フローを、Claude Codeと作った実装コード付きで解説します。
「2要素認証、入れときました」とSlackに書いた3日後。ユーザーから「スマホを機種変したらログインできなくなった」という問い合わせが来ました。
認証アプリごと消えていたんです。リカバリーコードは「どこかに保存した気がする」。僕は管理画面に2FAを強制解除するボタンを置いていなかったので、その人のアカウントは事実上ロックされました。
このとき思い知りました。2FAでいちばん難しいのは、コードを照合する部分じゃない。「持っているもの」をなくした人を、どう安全に戻すかなんです。
この記事の要点
- 2FA(TOTP)の本番運用で詰むのは検証ロジックより復旧(リカバリー)。スマホ紛失・機種変・認証アプリ消失をどう救うかを最初に設計する。
- リカバリーコードは平文でDBに残さない。HMAC-SHA256でハッシュ化し、使ったら即座に使用済みにする。再表示はしない。
- 復旧フローを「サポート担当の解除ボタン」にすると、そこが最大の穴になる。承認・通知・待機を挟み、解除後は全セッション失効+クールダウン。
- Claude Codeには「2FA作って」と丸投げせず、
スキーマ→暗号化ヘルパー→有効化API→復旧フロー→監査ログと粒度を切って渡すと事故が減る。 - TOTPはフィッシングに弱い。将来はパスキー(WebAuthn)へ移れる余地を残しておく。
なぜ「検証」より「復旧」が本番で効くのか
2FAの記事はたくさんありますが、その多くが「QRコードを出して、6桁を照合して、有効化しました」で終わっています。チュートリアルとしては正しい。でも、それは2FAの一番カンタンなところだけです。
TOTP(Time-based One-Time Password、30秒ごとに変わる6桁コード)の検証自体は、ライブラリに任せれば数行で書けます。難しいのはその後。ユーザーは必ず認証アプリをなくします。機種変、初期化、アプリの誤削除、QRを撮ったまま設定し忘れ。母数が増えれば、毎週のように「ログインできません」が来ます。
ここで復旧フローが雑だと、二択に追い込まれます。一つは、サポートが本人確認もそこそこに2FAを解除する運用。これは攻撃者が「スマホなくしました」と言えば突破できる、もっとも古典的なソーシャルエンジニアリングの穴です。もう一つは、復旧手段がなくてアカウントを諦めてもらう運用。どちらも事故です。
だからこの記事は、リカバリーコードと復旧フローに軸足を置きます。ログイン全体の設計やJWTの話は別記事に譲ります。認証の土台はClaude Code認証実装ガイド、トークン設計はClaude Code JWT認証ガイド、権限境界はClaude Code RBAC実装ガイドを見てください。外部の拠り所はOWASP Multifactor Authentication Cheat Sheetです。OWASPも、要素を失ったときの復旧設計をMFAの必須項目として挙げています。
リカバリーコードで守るべき3つの境界
リカバリーコード(バックアップコードとも言う)は、認証アプリが使えないときの非常口です。だからこそ、扱いを間違えると非常口が泥棒の入り口になります。守る境界は3つだけ覚えてください。
| 境界 | やること | やってはいけないこと |
|---|---|---|
| 保存 | HMAC-SHA256でハッシュ化して保存 | 平文・可逆暗号でDBに残す |
| 表示 | 生成直後に一度だけ表示し、保存を強く促す | 設定画面でいつでも再表示できるようにする |
| 使用 | 使ったコードは即usedAtを埋めて二度使えなくする | ハッシュ照合だけで使用済みフラグを更新しない |
特に3つ目。ハッシュ保存していても、使用済みにし忘れると、漏れた1枚のコードが何度でも通ってしまいます。「ハッシュにしたから安心」は半分しか正しくないんです。
データモデル:何をハッシュで持つか
復旧を安全にやるための土台がスキーマです。TOTPの秘密鍵は暗号化、リカバリーコードと「この端末を覚える」トークンはハッシュだけを保存します。秘密の生の値をDBに残さない、が一貫した方針です。
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
twoFactorEnabled Boolean @default(false)
twoFactorEnabledAt DateTime?
twoFactorSecretCiphertext String?
twoFactorSecretIv String?
twoFactorSecretTag String?
backupCodes BackupCode[]
rememberedDevices RememberedDevice[]
auditLogs AuditLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model BackupCode {
id String @id @default(cuid())
userId String
codeHash String
usedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, usedAt])
@@unique([userId, codeHash])
}
model RememberedDevice {
id String @id @default(cuid())
userId String
deviceHash String
userAgent String?
ipPrefix String?
expiresAt DateTime
lastUsedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, expiresAt])
@@unique([userId, deviceHash])
}
model AuditLog {
id String @id @default(cuid())
userId String?
action String
metadata Json?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([userId, createdAt])
@@index([action, createdAt])
}
BackupCodeに@@unique([userId, codeHash])を付けているのは、同じコードを二重登録しないためのDB側の保険です。usedAtがあるおかげで、使ったコードを物理削除せず履歴として残せます。誰がいつ非常口を使ったかは、監査で効いてきます。
暗号化キーは本番ではKMSやSecrets Managerから渡すのが理想です。小さなNext.jsなら、まず32バイトのキーを環境変数に置き、ローテーション手順を運用メモに残すところから始めれば十分です。HMAC用の秘密値はキーとは別に分けます。
# .env.example
DATABASE_URL="postgresql://user:password@localhost:5432/app"
TWO_FACTOR_ENCRYPTION_KEY="base64-encoded-32-byte-key"
TWO_FACTOR_HASH_SECRET="long-random-hmac-secret"
リカバリーコードを生成・ハッシュ化する共通部品
ここが今回の心臓部です。リカバリーコードを作り、HMAC-SHA256でハッシュ化し、照合は必ず定数時間比較(timingSafeEqual)で行います。比較に普通の===を使うと、何文字目で外れたかが応答時間に滲み出て、理論上は総当たりの手がかりになるためです。
コードは8文字を2組に分け、A1B2C-D3E4Fのように人間が読み上げやすい形にしています。
// src/lib/two-factor.ts
import {
createCipheriv,
createDecipheriv,
createHmac,
randomBytes,
timingSafeEqual,
} from "node:crypto";
import { authenticator } from "otplib";
import { prisma } from "@/lib/prisma";
authenticator.options = { step: 30, window: 1 };
type EncryptedSecret = {
ciphertext: string;
iv: string;
tag: string;
};
function encryptionKey(): Buffer {
const key = Buffer.from(process.env.TWO_FACTOR_ENCRYPTION_KEY ?? "", "base64");
if (key.length !== 32) {
throw new Error("TWO_FACTOR_ENCRYPTION_KEY must be a base64 encoded 32-byte key.");
}
return key;
}
function hashSecret(): string {
const secret = process.env.TWO_FACTOR_HASH_SECRET;
if (!secret || secret.length < 32) {
throw new Error("TWO_FACTOR_HASH_SECRET must be at least 32 characters.");
}
return secret;
}
// TOTP秘密鍵はAES-256-GCMで暗号化して保存する
export function encryptTotpSecret(secret: string): EncryptedSecret {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", encryptionKey(), iv);
const ciphertext = Buffer.concat([cipher.update(secret, "utf8"), cipher.final()]);
return {
ciphertext: ciphertext.toString("base64"),
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
};
}
export function decryptTotpSecret(encrypted: EncryptedSecret): string {
const decipher = createDecipheriv(
"aes-256-gcm",
encryptionKey(),
Buffer.from(encrypted.iv, "base64"),
);
decipher.setAuthTag(Buffer.from(encrypted.tag, "base64"));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(encrypted.ciphertext, "base64")),
decipher.final(),
]);
return plaintext.toString("utf8");
}
export function verifyTotpToken(token: string, secret: string): boolean {
if (!/^\d{6}$/.test(token)) return false;
return authenticator.verify({ token, secret });
}
// リカバリーコードを10枚生成(ユーザーが最後に見る平文を返す)
export function generateBackupCodes(count = 10): string[] {
return Array.from({ length: count }, () => {
const raw = randomBytes(5).toString("hex").toUpperCase();
return `${raw.slice(0, 5)}-${raw.slice(5, 10)}`;
});
}
// 入力のゆらぎ(小文字・ハイフン)を吸収してから照合する
export function normalizeBackupCode(code: string): string {
return code.replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
}
export function hashBackupCode(userId: string, code: string): string {
return createHmac("sha256", hashSecret())
.update(`backup:${userId}:${normalizeBackupCode(code)}`)
.digest("hex");
}
// 比較は必ず定数時間で(早期returnで時間差を作らない)
export function constantTimeEqualHex(left: string, right: string): boolean {
const a = Buffer.from(left, "hex");
const b = Buffer.from(right, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
export async function writeAuditLog(
userId: string | null,
action: string,
metadata: Record<string, unknown> = {},
) {
await prisma.auditLog.create({ data: { userId, action, metadata } });
}
リカバリーコードをuserId込みでハッシュしているのがポイントです。万一あるユーザーのハッシュが漏れても、別ユーザーのコードとして使い回せません。normalizeBackupCodeで大文字小文字とハイフンを吸収しているのは、ユーザーが「a1b2cd3e4f」と打っても通すためです。非常口は、慌てている人でも開けられないと意味がありません。
有効化と同時にリカバリーコードを一度だけ渡す
TOTPの最初の1回が通った瞬間に、リカバリーコードを生成してハッシュ保存し、生の値をこのレスポンスでだけ返します。ここで返し損ねたら、ユーザーは二度と非常口を手にできません。だから生成・保存・監査ログを同じトランザクション境界に入れます。
// src/app/api/account/2fa/enable/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/require-user";
import {
decryptTotpSecret,
generateBackupCodes,
hashBackupCode,
verifyTotpToken,
} from "@/lib/two-factor";
const bodySchema = z.object({ token: z.string().regex(/^\d{6}$/) });
export async function POST(request: NextRequest) {
const user = await requireUser();
const body = bodySchema.parse(await request.json());
const fresh = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: {
id: true,
twoFactorSecretCiphertext: true,
twoFactorSecretIv: true,
twoFactorSecretTag: true,
},
});
if (!fresh.twoFactorSecretCiphertext || !fresh.twoFactorSecretIv || !fresh.twoFactorSecretTag) {
return NextResponse.json({ error: "2FA setup has not started." }, { status: 400 });
}
const secret = decryptTotpSecret({
ciphertext: fresh.twoFactorSecretCiphertext,
iv: fresh.twoFactorSecretIv,
tag: fresh.twoFactorSecretTag,
});
if (!verifyTotpToken(body.token, secret)) {
return NextResponse.json({ error: "Invalid code." }, { status: 400 });
}
// ここで生成した平文は、このレスポンスでしかユーザーに渡せない
const backupCodes = generateBackupCodes();
await prisma.$transaction([
prisma.backupCode.deleteMany({ where: { userId: user.id } }),
prisma.user.update({
where: { id: user.id },
data: { twoFactorEnabled: true, twoFactorEnabledAt: new Date() },
}),
prisma.backupCode.createMany({
data: backupCodes.map((code) => ({
userId: user.id,
codeHash: hashBackupCode(user.id, code),
})),
}),
prisma.auditLog.create({
data: {
userId: user.id,
action: "2fa.enabled",
metadata: { backupCodeCount: backupCodes.length },
},
}),
]);
return NextResponse.json({ backupCodes });
}
UI側では、このレスポンスのコードを「ダウンロード」「印刷」「パスワードマネージャーに保存」へ誘導します。成功しましたの表示より、保存しましたか?の確認を強く出すのがコツです。冒頭の僕の事故は、ここで保存を促すUIが弱かったのも原因でした。
ログイン時、TOTPの代わりにリカバリーコードを受け付ける
ログインのチャレンジでは、TOTPが通らないユーザーのためにリカバリーコードも受け取ります。一致したら、その場でusedAtを埋めて二度と使えなくします。総当たり対策として、ユーザー単位とIP単位のrate limit(試行回数制限)を必ず噛ませます。TOTPは6桁しかないので、回数を許すほど突破確率が上がるからです。
// src/lib/verify-second-factor.ts
import { prisma } from "@/lib/prisma";
import {
constantTimeEqualHex,
decryptTotpSecret,
hashBackupCode,
verifyTotpToken,
writeAuditLog,
} from "@/lib/two-factor";
// 本番の試行回数制限はRedis等に置く。ここは1プロセス向けの最小例
const buckets = new Map<string, { count: number; resetAt: number }>();
function consumeRateLimit(key: string, limit: number, windowMs: number) {
const now = Date.now();
const bucket = buckets.get(key);
if (!bucket || bucket.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
return limit > 0;
}
bucket.count += 1;
return bucket.count <= limit;
}
type Input = { userId: string; code: string; ipAddress?: string };
export async function verifySecondFactor(input: Input) {
const key = `2fa-login:${input.userId}:${input.ipAddress ?? "unknown"}`;
if (!consumeRateLimit(key, 6, 5 * 60 * 1000)) {
await writeAuditLog(input.userId, "2fa.login.rate_limited", { ipAddress: input.ipAddress });
return { ok: false as const, reason: "rate_limited" };
}
const user = await prisma.user.findUniqueOrThrow({
where: { id: input.userId },
include: { backupCodes: { where: { usedAt: null } } },
});
// まずTOTPを試す
if (user.twoFactorSecretCiphertext && user.twoFactorSecretIv && user.twoFactorSecretTag) {
const secret = decryptTotpSecret({
ciphertext: user.twoFactorSecretCiphertext,
iv: user.twoFactorSecretIv,
tag: user.twoFactorSecretTag,
});
if (verifyTotpToken(input.code, secret)) {
await writeAuditLog(input.userId, "2fa.login.totp_success");
return { ok: true as const, via: "totp" as const };
}
}
// TOTPがダメなら未使用のリカバリーコードと定数時間で照合する
const submitted = hashBackupCode(input.userId, input.code);
const matched = user.backupCodes.find((bc) =>
constantTimeEqualHex(bc.codeHash, submitted),
);
if (matched) {
// 一致した瞬間に使用済みにする。これを忘れると同じコードが何度も通る
await prisma.backupCode.update({
where: { id: matched.id },
data: { usedAt: new Date() },
});
const remaining = user.backupCodes.length - 1;
await writeAuditLog(input.userId, "2fa.login.backup_code_success", { remaining });
return { ok: true as const, via: "backup_code" as const, remaining };
}
await writeAuditLog(input.userId, "2fa.login.failed", { ipAddress: input.ipAddress });
return { ok: false as const, reason: "invalid_code" };
}
返しているremainingは地味だけど大事です。リカバリーコードが残り少なくなったら「再生成しませんか」と促せます。最後の1枚を使ってログインした人を放置すると、次の機種変でまた詰みます。
全部なくした人を、安全に戻す復旧フロー
ここが2FAの弱点になりやすい本丸です。やりがちな危険な実装を先に挙げます。
- 問い合わせメールの本文だけで2FAを解除する
- サポート担当が管理画面の「解除」ボタンを一人で押せる
- 復旧リンクにrate limitがない
- 復旧したのに本人へ通知メールを出さない
僕の最初の運用は、実はこの「サポートが解除ボタンを押す」型でした。悪意ある第三者が「スマホなくした」と問い合わせれば、本人確認の甘さ次第で乗っ取れる構造です。安全寄りに直すと、優先順位はこうなります。
- まずリカバリーコードを使わせる。これが正規の非常口。
- リカバリーコードも失った場合は、メール確認・既存セッションでの追加認証・支払い情報や組織オーナー承認など、サービスに合った確認を複数組み合わせる。
- 解除は「申請→別担当者の承認→本人へ通知→一定時間の待機」を経る。一人では完結させない。
- 解除後は全セッションを失効し、24時間ほどのクールダウンを置いてから高リスク操作を許可する。
そして、2FAの設定変更そのものには直近の認証(step-up auth)を要求します。ログイン済みセッションを奪われたとき、攻撃者が自分の認証アプリに差し替えて本人を締め出すのを防ぐためです。
// src/lib/step-up.ts
// 重要操作の直前に「最近MFAを通ったか」を確認する
export function assertFreshMfa(
session: { userId: string; mfaAt?: Date | null },
maxAgeMinutes = 15,
) {
if (!session.mfaAt) {
throw new Error("MFA is required for this action.");
}
const ageMs = Date.now() - session.mfaAt.getTime();
if (ageMs > maxAgeMinutes * 60 * 1000) {
throw new Error("MFA challenge is too old. Please verify again.");
}
}
// 例: 2FA無効化・リカバリーコード再生成・復旧承認の直前に呼ぶ
// assertFreshMfa(session, 15);
// await regenerateBackupCodes(userId);
復旧フローを設計するとき、SMSを「最後の手段」に置きたくなりますが、SIMスワップや番号の再利用に弱いことは頭に入れておいてください。ゼロよりマシな場面はあるものの、第一候補はTOTP、その先はパスキー(WebAuthn)です。WebAuthnは秘密鍵をサーバーに送らずドメインに紐づくので、TOTPの弱点であるフィッシング(偽サイトに6桁を入力させてリアルタイムで本物へ転送する攻撃)に強くなります。AIエージェントにこの周辺を触らせる範囲はClaude Codeセキュリティベストプラクティスも参考にしてください。
Claude Codeに任せるなら、粒度を切る
僕がこの実装をClaude Codeと進めて学んだのは、「2FA作って」の一括依頼が一番事故るということです。出てくるコードはそれっぽく動くのに、復旧やrate limitがごっそり抜けたり、リカバリーコードを平文で返したりします。
なので、こう切って渡します。
- Prismaスキーマとセキュリティレビュー観点だけ出して
- 暗号化・ハッシュ・定数時間比較のヘルパーを書いて(生の秘密はログに出さない)
- 有効化APIを書いて(リカバリーコードは一度だけ返す)
- ログイン検証を書いて(TOTPとリカバリーコードの両対応、rate limit必須)
- 復旧フローを書いて(承認・通知・待機・全セッション失効)
- 各APIのテストとOWASP観点のレビューを書いて
秘密情報はプロンプトに貼らず、.env.exampleだけ渡します。レビュー時は「リカバリーコードが平文で保存・返却されていないか」「使用済みフラグの更新がトランザクションに入っているか」「2FA設定変更にstep-upがあるか」を最優先で見ます。実装支援やレビューが必要なら研修・相談で個別に対応しています。
よくある質問
Q. リカバリーコードは何枚生成すればいい? A. 10枚が定番です。使うたびに減り、残りが少なくなったら再生成を促します。多すぎると保存が雑になり、少なすぎると機種変のたびに枯れます。
Q. リカバリーコードを忘れた・なくした人はどうする? A. これが復旧フローの出番です。リカバリーコードが使えない場合に備えて、メール確認+既存セッションでの追加認証+承認制の解除を用意します。サポートが単独で即解除できる導線は作らないでください。
Q. リカバリーコードを再表示する画面を作ってもいい? A. 作らないのが安全です。再生成(古いものを全て無効化して新しい10枚を発行)はOKですが、過去のコードをそのまま再表示する機能は、画面の盗み見やセッション奪取で漏れます。
Q. リカバリーコードとSMSバックアップ、どっちがいい? A. リカバリーコードを基本にしてください。SMSはSIMスワップに弱く、復旧手段としても狙われやすいです。より強くするならパスキー(WebAuthn)を追加します。
Q. TOTPのwindowはどこまで広げていい?
A. window: 1(前後30秒の許容)が無難です。広げるほど端末の時計ズレに優しくなりますが、攻撃者に渡す試行猶予も増えます。広げるより、サーバー時刻をNTPで合わせる方を優先します。
実際に試した結果
冒頭の「機種変で詰んだ」事故のあと、僕がまず足したのは賢い検証ロジックではなく、復旧の3点セットでした。リカバリーコードのハッシュ保存、使ったら即使用済み、そして承認制の復旧フロー。これだけで「ログインできません」の問い合わせは、解除ボタンを一人で押すサポート対応から、本人がリカバリーコードで自己解決する形に変わりました。
確認の決め手はいつも同じです。TWO_FACTOR_ENCRYPTION_KEYを本番と開発で分けているか。リカバリーコードが一度しか表示されないか。使ったコードが二度通らないか。2FA無効化にstep-upが要るか。監査ログに秘密値が出ていないか。最後にClaude Codeへ「この実装をOWASP MFA Cheat Sheetの観点で批判的にレビューして」と投げ、僕が差分と本番権限を確認してから公開します。
2FAは機能追加ではなく、検証・セッション・復旧・サポート運用をまたぐ設計です。検証はライブラリが速くしてくれる。でも、なくした人を安全に戻す設計だけは、最初に自分で決めておく価値があります。同じテーマで自社の実装を見てほしいときは、教材一覧か研修・相談からどうぞ。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。