Use Cases (更新: 2026/6/1)

Claude Codeで2要素認証を実装する実務ガイド: TOTP、バックアップコード、復旧設計まで

Claude CodeでNext.jsに2FA/MFAを安全に実装する手順。TOTP、復旧、監査ログ、落とし穴まで解説。

Claude Codeで2要素認証を実装する実務ガイド: TOTP、バックアップコード、復旧設計まで

パスワードだけのログインは、漏えいした1つの情報でアカウントを奪われる弱さがあります。2FA、つまり2要素認証は「知っているもの」であるパスワードに加えて、「持っているもの」である認証アプリ、バックアップコード、パスキーなどを確認し、攻撃者がパスワードを知っていてもログインを止めるための仕組みです。

ただし、2FAは「QRコードを出せば終わり」ではありません。TOTP、つまり時間で変わる6桁コードを導入しても、バックアップコードを平文保存したり、復旧フローをサポート担当者の裁量にしたり、試行回数制限を忘れたりすると、むしろ攻撃面が増えます。Claude Codeに任せる場合も、作業粒度とレビュー観点を明確にしないと、動くけれど危ない実装になりがちです。

この記事では、Next.js App Router、TypeScript、Prismaを前提に、TOTP、backup codes、step-up auth、recovery flow、rate limit、remember device、SMSの注意点、WebAuthn/passkeysへの発展、監査ログまでを1つの設計としてまとめます。ログイン基盤はClaude Code JWT認証ガイド、パスワード再設定はClaude Codeパスワードリセット実装、権限境界はClaude Code RBAC実装ガイド、AIエージェントに触らせる範囲はClaude Codeセキュリティベストプラクティスと合わせて確認してください。

外部基準としては、OWASP Multifactor Authentication Cheat SheetOWASP WSTGのMFAテスト項目MDN Web Authentication APIW3C WebAuthn Level 3を参照しています。OWASPはMFAの導入タイミング、OTPの扱い、失敗試行、復旧、要素変更時の確認を強調しています。

2FA/MFAで守るべき境界

MFAはmulti-factor authenticationの略で、日本語では多要素認証です。2FAはそのうち2つの要素を使う形です。要素は大きく、知っているもの、持っているもの、本人そのもの、生体や端末の性質などに分かれます。今回の主役であるTOTPは、認証アプリとサーバーが共有する秘密鍵から30秒ごとにコードを作る方式です。

実務での境界は、初回ログインだけでは足りません。メールアドレス変更、パスワード変更、支払い方法変更、管理者招待、APIキー発行、個人情報エクスポートなど、被害が大きい操作ではstep-up auth、つまりログイン済みでも追加認証を求める設計が必要です。

flowchart TD
  A["Password or SSO login"] --> B{"2FA enabled?"}
  B -->|No| C["Session issued with lower assurance"]
  B -->|Yes| D["TOTP or backup code challenge"]
  D --> E{"Valid and rate limit OK?"}
  E -->|No| F["Deny, audit log, cooldown"]
  E -->|Yes| G["Session issued with mfaAt"]
  G --> H{"Sensitive action?"}
  H -->|No| I["Continue"]
  H -->|Yes| J{"mfaAt fresh?"}
  J -->|Yes| K["Allow action"]
  J -->|No| D
  G --> L["Optional remembered device"]

Claude Codeには、この図を最初に渡してから実装を分けて依頼します。私の経験では「2FAを実装して」と一括依頼するより、「DBスキーマ」「暗号化ヘルパー」「セットアップAPI」「有効化API」「ログインチャレンジ」「復旧フロー」「監査ログ」「テスト」の単位に切ったほうが、レビューしやすく事故も減ります。

Claude Codeに任せる作業粒度

Claude Codeに任せてよい作業は、型定義、Route Handler、UIコンポーネント、テスト、レビュー観点の列挙です。一方で、暗号化キー、HMAC用のpepper、管理画面の本番権限、本人確認の運用判断は人間が握るべきです。秘密情報をプロンプトに貼らず、.env.exampleだけを渡します。

依頼文は次のようにします。

Next.js App Router、TypeScript、Prismaで2FAを実装します。
対象はTOTP、バックアップコード、remember device、step-up auth、監査ログです。
制約:
- TOTP secretはAES-256-GCMで暗号化して保存
- backup codeはHMAC-SHA256でハッシュ化して保存
- 2FA検証はユーザー単位とIP単位でrate limit
- SMSは第一候補にしない。将来passkeysへ移行できる設計にする
- 復旧と要素変更は監査ログを必ず残す
まずPrisma schemaとセキュリティレビュー観点だけ出してください。

レビューでは、次の観点を必ず見ます。

観点見るポイント
秘密鍵TOTP secretが平文保存されていないか
バックアップコード一度しか表示せず、DBにはハッシュだけが残るか
試行回数TOTP、backup code、recoveryの全てにrate limitがあるか
セッション2FA通過時刻をmfaAtとして持ち、重要操作で鮮度を確認するか
remember deviceCookie値そのものではなくハッシュを保存し、有効期限と失効があるか
復旧サポート担当者の主観で解除できないか
ログ成功、失敗、無効化、復旧、要素変更が監査ログに残るか

データモデルを先に固める

最初にDB設計を固めます。TOTP secretは暗号化して保存し、バックアップコードとremember device tokenはハッシュだけを保存します。バックアップコードは「ユーザーが最後に見る平文」を返しますが、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])
}

本番では、TOTP secret暗号化キーをKMSやSecrets Managerから渡すのが理想です。小規模なNext.jsでは、まず32バイトのbase64キーを環境変数に置き、ローテーション手順を運用メモに残します。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"

暗号化、ハッシュ、rate limitの共通部品

次のヘルパーは、TOTP secretをAES-256-GCMで暗号化し、バックアップコードとremember device tokenをHMAC-SHA256でハッシュ化します。バックアップコードは8文字を2組に分け、人間が読み上げやすくしています。

// 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;
};

const rateLimitBuckets = new Map<string, { count: number; resetAt: number }>();

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;
}

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 });
}

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");
}

export function hashRememberedDeviceToken(userId: string, token: string): string {
  return createHmac("sha256", hashSecret())
    .update(`remember-device:${userId}:${token}`)
    .digest("hex");
}

export function createRememberedDeviceToken(): string {
  return randomBytes(32).toString("base64url");
}

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 function consumeRateLimit(key: string, limit: number, windowMs: number) {
  const now = Date.now();
  const bucket = rateLimitBuckets.get(key);
  if (!bucket || bucket.resetAt <= now) {
    rateLimitBuckets.set(key, { count: 1, resetAt: now + windowMs });
    return { ok: true, remaining: limit - 1 };
  }
  bucket.count += 1;
  return { ok: bucket.count <= limit, remaining: Math.max(0, limit - bucket.count) };
}

export async function writeAuditLog(
  userId: string | null,
  action: string,
  metadata: Record<string, unknown> = {},
) {
  await prisma.auditLog.create({
    data: {
      userId,
      action,
      metadata,
    },
  });
}

このrate limitは単一プロセス向けの最小実装です。Vercel、Kubernetes、複数台構成ではRedisやUpstashに置き換えてください。Claude Codeには「このMap実装をRedis実装に差し替え、同じテストを維持して」と依頼すると、境界を保ったまま拡張できます。

セットアップと有効化API

セットアップAPIはQRコードを生成しますが、この段階ではtwoFactorEnabledを有効にしません。ユーザーが認証アプリで読み取り、最初のTOTPを入力して検証できた時点で初めて有効化します。

// src/app/api/account/2fa/setup/route.ts
import { NextResponse } from "next/server";
import { authenticator } from "otplib";
import QRCode from "qrcode";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/require-user";
import { encryptTotpSecret, writeAuditLog } from "@/lib/two-factor";

const ISSUER = "ClaudeCodeLab";

export async function POST() {
  const user = await requireUser();

  if (user.twoFactorEnabled) {
    return NextResponse.json({ error: "2FA is already enabled." }, { status: 409 });
  }

  const secret = authenticator.generateSecret();
  const encrypted = encryptTotpSecret(secret);
  const otpauthUrl = authenticator.keyuri(user.email, ISSUER, secret);
  const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl);

  await prisma.user.update({
    where: { id: user.id },
    data: {
      twoFactorSecretCiphertext: encrypted.ciphertext,
      twoFactorSecretIv: encrypted.iv,
      twoFactorSecretTag: encrypted.tag,
    },
  });

  await writeAuditLog(user.id, "2fa.setup.started", { issuer: ISSUER });

  return NextResponse.json({
    qrCodeDataUrl,
    manualKey: secret,
  });
}

有効化APIでは、TOTP検証、rate limit、バックアップコード生成、ハッシュ保存、監査ログを同じトランザクション境界で扱います。バックアップコードはこのレスポンスで一度だけ返します。

// 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 {
  consumeRateLimit,
  decryptTotpSecret,
  generateBackupCodes,
  hashBackupCode,
  verifyTotpToken,
  writeAuditLog,
} 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 limit = consumeRateLimit(`2fa-enable:${user.id}`, 5, 10 * 60 * 1000);
  if (!limit.ok) {
    await writeAuditLog(user.id, "2fa.enable.rate_limited");
    return NextResponse.json({ error: "Too many attempts." }, { status: 429 });
  }

  const freshUser = await prisma.user.findUniqueOrThrow({
    where: { id: user.id },
    select: {
      id: true,
      twoFactorSecretCiphertext: true,
      twoFactorSecretIv: true,
      twoFactorSecretTag: true,
    },
  });

  if (
    !freshUser.twoFactorSecretCiphertext ||
    !freshUser.twoFactorSecretIv ||
    !freshUser.twoFactorSecretTag
  ) {
    return NextResponse.json({ error: "2FA setup has not started." }, { status: 400 });
  }

  const secret = decryptTotpSecret({
    ciphertext: freshUser.twoFactorSecretCiphertext,
    iv: freshUser.twoFactorSecretIv,
    tag: freshUser.twoFactorSecretTag,
  });

  if (!verifyTotpToken(body.token, secret)) {
    await writeAuditLog(user.id, "2fa.enable.failed");
    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 });
}

ログイン時の検証、backup codes、remember device

ログイン時は、パスワード検証後に2FAチャレンジを挟みます。TOTPが使えない場合に備えてbackup codesを許可しますが、使ったコードは即座にusedAtを埋めます。remember deviceは利便性のために有効ですが、端末紛失時のリスクがあるため、30日程度の有効期限、失効画面、監査ログが必要です。

// src/lib/verify-second-factor.ts
import { prisma } from "@/lib/prisma";
import {
  constantTimeEqualHex,
  consumeRateLimit,
  createRememberedDeviceToken,
  decryptTotpSecret,
  hashBackupCode,
  hashRememberedDeviceToken,
  verifyTotpToken,
  writeAuditLog,
} from "@/lib/two-factor";

type VerifySecondFactorInput = {
  userId: string;
  code: string;
  rememberDevice: boolean;
  userAgent?: string;
  ipAddress?: string;
};

export async function verifySecondFactor(input: VerifySecondFactorInput) {
  const limit = consumeRateLimit(`2fa-login:${input.userId}:${input.ipAddress ?? "unknown"}`, 6, 5 * 60 * 1000);
  if (!limit.ok) {
    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 } } },
  });

  if (!user.twoFactorEnabled) {
    return { ok: true as const, rememberedDeviceToken: null };
  }

  const canVerifyTotp =
    user.twoFactorSecretCiphertext &&
    user.twoFactorSecretIv &&
    user.twoFactorSecretTag;

  if (canVerifyTotp) {
    const secret = decryptTotpSecret({
      ciphertext: user.twoFactorSecretCiphertext!,
      iv: user.twoFactorSecretIv!,
      tag: user.twoFactorSecretTag!,
    });

    if (verifyTotpToken(input.code, secret)) {
      const rememberedDeviceToken = await rememberDeviceIfRequested(input);
      await writeAuditLog(input.userId, "2fa.login.totp_success");
      return { ok: true as const, rememberedDeviceToken };
    }
  }

  const submittedHash = hashBackupCode(input.userId, input.code);
  const matchedBackupCode = user.backupCodes.find((backupCode) =>
    constantTimeEqualHex(backupCode.codeHash, submittedHash),
  );

  if (matchedBackupCode) {
    await prisma.backupCode.update({
      where: { id: matchedBackupCode.id },
      data: { usedAt: new Date() },
    });
    await writeAuditLog(input.userId, "2fa.login.backup_code_success");
    return { ok: true as const, rememberedDeviceToken: null };
  }

  await writeAuditLog(input.userId, "2fa.login.failed", { ipAddress: input.ipAddress });
  return { ok: false as const, reason: "invalid_code" };
}

async function rememberDeviceIfRequested(input: VerifySecondFactorInput) {
  if (!input.rememberDevice) return null;

  const token = createRememberedDeviceToken();
  const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);

  await prisma.rememberedDevice.create({
    data: {
      userId: input.userId,
      deviceHash: hashRememberedDeviceToken(input.userId, token),
      userAgent: input.userAgent,
      ipPrefix: input.ipAddress?.split(".").slice(0, 3).join("."),
      expiresAt,
    },
  });

  await writeAuditLog(input.userId, "2fa.remember_device.created", { expiresAt });
  return token;
}

remember deviceのCookieはhttpOnlysecuresameSite: "lax"で発行します。Cookie値そのものはDBに保存せず、HMACの結果だけを保存するのが重要です。攻撃者がDBだけを読んでも、有効なCookieを作れないようにするためです。

UIは「成功」より「保存」を強く促す

UIではQRコード、手入力キー、6桁コード入力、バックアップコード表示を分けます。バックアップコードはダウンロード、印刷、パスワードマネージャー保存の行動を促し、画面を閉じた後は再表示しません。

// src/components/TwoFactorSetupPanel.tsx
"use client";

import { useState } from "react";

type Step = "idle" | "scan" | "backup";

export function TwoFactorSetupPanel() {
  const [step, setStep] = useState<Step>("idle");
  const [qrCodeDataUrl, setQrCodeDataUrl] = useState("");
  const [manualKey, setManualKey] = useState("");
  const [token, setToken] = useState("");
  const [backupCodes, setBackupCodes] = useState<string[]>([]);
  const [error, setError] = useState("");

  async function startSetup() {
    setError("");
    const response = await fetch("/api/account/2fa/setup", { method: "POST" });
    const data = await response.json();
    if (!response.ok) {
      setError(data.error ?? "Failed to start 2FA setup.");
      return;
    }
    setQrCodeDataUrl(data.qrCodeDataUrl);
    setManualKey(data.manualKey);
    setStep("scan");
  }

  async function enableTwoFactor() {
    setError("");
    const response = await fetch("/api/account/2fa/enable", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ token }),
    });
    const data = await response.json();
    if (!response.ok) {
      setError(data.error ?? "Invalid verification code.");
      return;
    }
    setBackupCodes(data.backupCodes);
    setStep("backup");
  }

  if (step === "idle") {
    return <button onClick={startSetup}>Set up two-factor authentication</button>;
  }

  if (step === "scan") {
    return (
      <section aria-label="Two-factor authentication setup">
        <img src={qrCodeDataUrl} alt="Authenticator app QR code" width={220} height={220} />
        <p>Manual key: <code>{manualKey}</code></p>
        <input
          inputMode="numeric"
          autoComplete="one-time-code"
          value={token}
          onChange={(event) => setToken(event.target.value)}
          maxLength={6}
          placeholder="123456"
        />
        <button onClick={enableTwoFactor}>Verify and enable</button>
        {error && <p role="alert">{error}</p>}
      </section>
    );
  }

  return (
    <section aria-label="Backup codes">
      <h2>Save these backup codes now</h2>
      <p>Each code can be used once. Store them outside this application.</p>
      <ul>
        {backupCodes.map((code) => (
          <li key={code}><code>{code}</code></li>
        ))}
      </ul>
    </section>
  );
}

ここでClaude Codeに追加で依頼するなら、「スクリーンリーダーで読めるラベル」「エラー時にフォーカスを戻す」「バックアップコードをクリップボードへコピーするボタン」「再表示できない注意書き」を別タスクにします。UI改善とセキュリティ境界を混ぜるとレビューが散ります。

復旧フローとstep-up auth

復旧フローは2FA実装の弱点になりやすい部分です。よくある危険な実装は、問い合わせメールだけで2FAを解除する、サポート担当者が管理画面で即時解除できる、復旧リンクにrate limitがない、復旧時にメール通知を出さない、というものです。

安全寄りの設計では、まずバックアップコードを使わせます。バックアップコードを失った場合は、メール確認、既存セッションでのstep-up、支払い情報や組織オーナー承認など、サービスに合った複数の確認を組み合わせます。解除後は全セッションを失効し、24時間などのクールダウンを置いてから高リスク操作を許可します。

// src/lib/step-up.ts
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.");
  }
}

// Example usage before a sensitive action:
// assertFreshMfa(session, 15);
// await rotateApiKey(userId);

SMSは便利ですが、SIMスワップ、番号再利用、通信事業者側のソーシャルエンジニアリングの影響を受けます。ゼロよりは良い場面もありますが、第一候補はTOTPやpasskeysにし、SMSは移行期や限定的な復旧手段として扱うのが現実的です。

WebAuthn/passkeysへの発展

TOTPは導入しやすい一方、フィッシング耐性は限定的です。攻撃者が偽サイトでTOTPを入力させ、リアルタイムで本物のサイトに転送する可能性があります。より強い選択肢がWebAuthnとpasskeysです。WebAuthnは公開鍵暗号を使い、秘密鍵をサーバーに送らず、ドメインに紐づく認証を行います。

最初からpasskeysだけに寄せると、業務端末、古いブラウザ、共有端末、サポート運用で詰まることがあります。そのため、現実的にはTOTPとバックアップコードをまず固め、次にWebAuthn credential用のテーブルを追加し、段階的に「推奨要素」をpasskeysへ移します。Claude Codeには、MDNとW3C仕様を前提に「TOTPと同じ監査ログ、失効、step-upポリシーを守ってWebAuthn登録APIを追加して」と依頼すると、既存境界を壊しにくくなります。

4つの実例

1つ目はSaaSの管理者招待です。管理者を追加する操作では、ログイン済みでも15分以内のmfaAtを要求します。攻撃者がセッションCookieを盗んでも、管理者追加の直前で止められます。

2つ目は請求情報の変更です。カード変更、請求先メール変更、領収書エクスポートではstep-up authを入れ、監査ログに旧値のハッシュ、変更者、IP、user agentを残します。個人情報そのものをログに残さない点が重要です。

3つ目は開発者向けAPIキー発行です。APIキーは外部システムから継続的に使われるため、発行時とscope変更時に2FAを要求します。RBACと組み合わせ、api_key:create権限を持つユーザーだけがstep-upを通過できるようにします。

4つ目はサポートによる2FA復旧です。サポート担当者は解除ボタンを押すのではなく、復旧リクエストを作成し、別担当者の承認、本人への通知、一定時間の待機を経て解除します。復旧操作自体も監査ログと管理者のstep-up authが必要です。

具体的な落とし穴

最も危ない落とし穴は、TOTP secretの平文保存です。DB漏えい時に攻撃者がTOTPを生成できるため、2FAの意味が薄れます。暗号化しても、暗号化キーを同じDBに置けば効果はありません。

次に多いのは、バックアップコードの再利用です。ハッシュ保存していてもusedAtを更新しなければ、漏れたコードが何度も使えます。認証成功時は必ずトランザクションで使用済みにします。

3つ目は、2FA設定変更にstep-upを求めないことです。攻撃者がログイン済みセッションを奪った場合、自分の認証アプリに差し替えて被害者を締め出せます。2FAの無効化、要素追加、backup code再生成は必ず直近のMFAを要求します。

4つ目は、rate limitをログインパスワードにしか入れないことです。TOTPは6桁なので、試行回数を許すと突破確率が上がります。ユーザー単位、IP単位、デバイス単位の制限を組み合わせます。

5つ目は、監査ログに秘密を残すことです。TOTP、backup code、remember device token、復旧リンクはログに出してはいけません。ログにはイベント名、結果、時刻、リスク判断に必要なメタデータだけを残します。

まとめ

2FA/MFAは機能追加ではなく、認証、セッション、復旧、監査、サポート運用をまたぐ設計です。Claude Codeを使うなら、コード生成の速さよりも、作業を小さく切ってレビュー可能にすることが重要です。TOTP secretは暗号化、backup codesはハッシュ化、検証にはrate limit、重要操作にはstep-up auth、復旧には承認と監査ログ、将来はWebAuthn/passkeysへ移行できる余地を残す。この形なら、AdSense審査で嫌われる薄い一般論ではなく、実装支援や問い合わせにつながる具体性を持てます。

ClaudeCodeLabでは、Claude Codeを使った認証設計レビュー、Next.jsの2FA実装支援、チーム向けセキュリティ研修、既存コードのMFA監査を相談できます。自社プロダクトに合わせて、TOTPからpasskeys移行までのロードマップを一緒に整理できます。

この記事で紹介した内容を実際に試すときの確認ポイントは、TWO_FACTOR_ENCRYPTION_KEYを本番と開発で分けること、backup codeが一度しか表示されないこと、TOTP失敗がrate limitされること、remember deviceのCookieを削除すると再チャレンジになること、2FA無効化にstep-up authが必要なこと、監査ログに秘密値が出ていないことです。最後に、Claude Codeへ「この実装をOWASP MFA Cheat SheetとWSTGの観点で批判的にレビューして」と依頼し、人間が差分と本番権限を確認してから公開してください。

#Claude Code #2FA #MFA #TOTP #セキュリティ
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。