なぜパスワードをやめたのか
個人ブログの管理画面にパスワード認証を用意すると、パスワードの保管・ハッシュ化・漏洩対策という付随コストが全部ついてきます。利用者が自分一人なら、「登録済みメールアドレスにワンタイムパスワード(OTP)を送る」だけで十分です。
ただし素朴に作ると「OTP送信ボタンをbotに連打されてメール爆撃」という別の問題が生まれます。この記事はその対策まで含めた設計の全体像です。
認証フローの全体像
txt
1. メールアドレス入力2. Turnstile(bot判定・ほぼ自動)3. OTP送信ボタン → サーバーでTurnstileトークン検証 → メール送信4. 届いた6桁コードを入力 → 検証 → セッションCookie発行
重要な設計判断: Turnstileは「OTP送信前」に1回だけ検証します。OTP検証時にもう一度要求するとUXが悪化する割にセキュリティは上がりません(送信されたOTP自体が所有証明になるため)。代わりにOTP検証には試行回数制限をつけます。
Turnstileのサーバー検証
クライアントのウィジェットが返すトークンは、必ずサーバーでsiteverifyにかけます:
ts
export async function verifyTurnstileToken(token: unknown, remoteIp?: string | null) { const secret = await getRuntimeValue("TURNSTILE_SECRET_KEY"); if (!secret) return { ok: false, error: "turnstile_not_configured" as const }; if (typeof token !== "string" || token.length < 8) return { ok: false, error: "turnstile_required" as const }; const body = new FormData(); body.set("secret", secret); body.set("response", token); if (remoteIp) body.set("remoteip", remoteIp); const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body }).catch(() => null); if (!res?.ok) return { ok: false, error: "turnstile_failed" as const }; const json = await res.json().catch(() => null); return json?.success ? { ok: true as const } : { ok: false, error: "turnstile_failed" as const };}
細かいですが大事な点: remoteipには cf-connecting-ip ヘッダーだけを使います。x-forwarded-for は呼び出し側が偽造できるので信用しません。
メール爆撃対策: D1でのレート制限
Workersはリクエストごとに別インスタンスになり得るので、メモリ上のレート制限はすり抜けられます。OTP送信のクールダウンはD1に永続化しました。ポイントは「読んでから書く」ではなく1文で予約すること:
sql
UPDATE otp_request_limitsSET count = CASE WHEN ? - window_start >= ? THEN 1 ELSE count + 1 END, window_start = CASE WHEN ? - window_start >= ? THEN ? ELSE window_start END, last_sent_at = ?WHERE key = ? AND (? - window_start >= ? OR (? - last_sent_at >= ? AND count < ?))RETURNING count;
RETURNINGが空ならクールダウン中。UPDATEがアトミックなので、同時リクエストが2通メールを送る競合が起きません。
さらに、メール送信が失敗したら予約を解放します(でないと「送れていないのにクールダウンだけ消費」という最悪のUXになる)。
OTPの保存と検証
OTPは平文保存せず、HMAC-SHA256で署名したCookieに入れます。検証時の比較はタイミング攻撃対策で crypto.subtle.timingSafeEqual 相当の定時間比較を使います。そして試行回数制限:
ts
// 検証失敗が上限を超えたらロックif (count > maxOtpAttempts) return { ok: false, error: "otp_locked" };
これがあるので、TurnstileをOTP検証側にも付ける必要はありません。6桁の総当たりは100万通り、試行上限が数回なら突破確率は実質ゼロです。
メールアドレスの存在を漏らさない
管理者以外のアドレスが入力された場合も、レスポンスは成功と同じ形にします:
ts
if (!allowed || email !== allowed) { // アドレスが管理者かどうかを推測させない。OTPは送らず成功風に返す return NextResponse.json({ ok: true });}
エラーを返すと「このアドレスは管理者ではない」という情報が漏れます。
まとめ
• 利用者が自分だけならパスワードよりメールOTPがシンプルで安全
• Turnstileは送信前に1回。検証側は試行回数制限で守る
• レート制限はメモリでなくD1に永続化し、UPDATE 1文でアトミックに予約
• 送信失敗時はクールダウンを返却、アドレス存在は漏らさない
Turnstile site key が未設定のため、このフォームは送信できません。管理者は NEXT_PUBLIC_TURNSTILE_SITE_KEY を設定してください。