Claude Code でパスキー+リカバリコードのアカウント認証を作る
この記事では、一般のユーザーが各自アカウントを作る仕組みを、パスキー(WebAuthn)で作る。題材は「みんなの寄せ書き」。各ユーザーがパスキーでアカウントを作り、名前と一言を残すと、みんなの一言が並ぶ。
最近のサービスのログインが「パスワードなし」「メールも不要」で済むのは、このパスキー(WebAuthn)の仕組みによる。本記事ではそれを、仕様に沿って安全に、しかも Cloudflare の無料枠(Workers + D1)だけで自前実装する。パスキーの仕組みを理解しながら作り、最後に自分でテストして確かめられるところまでやる。
パスキーは Hanko や Clerk などのホスト型サービスを使えばボタン1つで載せられる。それでも本記事で自前実装するのは、本サイトのスタンス(裏側を理解する・外部サービスに依存しない・Cloudflare 内で完結)に沿うため。WebAuthn の難所(チャレンジ生成・署名検証)は SimpleWebAuthn ライブラリ(@simplewebauthn/server / @simplewebauthn/browser)が引き受けてくれるので、公開鍵やチャレンジは D1 に置くだけでよい。
1. このハンズオンで作るもの
Section titled “1. このハンズオンで作るもの”1-1. 作るもの:みんなの寄せ書き
Section titled “1-1. 作るもの:みんなの寄せ書き”完成イメージ。みんなの寄せ書きが並び、ログインすると下の欄から自分も投稿できる
▶ 完成版を触ってみる: yosegaki.tatsuo-8d3.workers.dev:この章で作る寄せ書きの完成版。閲覧はそのまま、パスキーでアカウントを作れば投稿・編集も試せる(デモのため、投稿は予告なくリセット・削除することがある)。
- 各ユーザーがパスキーでアカウントを作る(メールもパスワードも不要)。
- 名前と一言を入力すると、全員の寄せ書きに並ぶ(1人1投稿、あとから編集可)。
- 閲覧は公開、投稿・編集はログインした本人だけ。
このアプリのポイントは、いくらでも増える一般ユーザーが各自アカウントを持つ点。そのために必要になるのが、ユーザーを区別する仕組みと、パスキーをなくしたときの復旧手段(リカバリコード)だ。
1-2. パスキーとは
Section titled “1-2. パスキーとは”パスキーは、端末の生体認証(Touch ID / Face ID / Windows Hello など)と公開鍵暗号を組み合わせたログイン方式。
- 秘密の鍵は端末から出ない。サーバーには公開鍵だけが保存される。だから DB が漏れても、それだけではログインできない(合言葉・パスワード方式との決定的な違い)。
- ドメインに束縛される。
example.comで作ったパスキーは、見た目がそっくりな偽サイトでは使えない(フィッシング耐性)。 - メールもパスワードも要らない。これがメール送信の仕組みを持てない無料枠と相性がよい。
パスキー作成時のダイアログ(Chrome + Google パスワードマネージャー)。生体認証で端末にパスキーが作られ、Google や iCloud に同期される
1-3. なぜ「ユーザー名レス」で入れるのか
Section titled “1-3. なぜ「ユーザー名レス」で入れるのか”このアプリにはユーザー名もメールアドレスの入力欄もない。ログインボタンを押すと、OS が「どのパスキーで入る?」と登録済みパスキーの一覧を出してくれる。これは discoverable credential(発見可能な資格情報)という仕様で、パスキー自身が「どのアカウントか」の情報を持っているために実現する。
サーバーはログイン要求のとき「許可するパスキー一覧(allowCredentials)」を空で返す。すると OS は端末にある全パスキーから選ばせてくれる。「メールアドレスを入れてください」が要らないのはこのため。
1-4. なぜリカバリコードが要るか/なぜ「ハッシュ保存」か
Section titled “1-4. なぜリカバリコードが要るか/なぜ「ハッシュ保存」か”パスキーは便利だが、端末を全部なくすとアカウントに二度と入れない。そこで業界標準のサービス(GitHub・Google など)は、登録時にリカバリコード(使い捨ての復旧コード)を発行する。
サインアップ直後にリカバリコードを表示。保存を確認するまで閉じられない
そして決定的に大事なのが、サーバー側(サービスを作る側)がリカバリコードを平文で保存しないこと。ユーザーが受け取ったコードを自分の手元(パスワードマネージャーやメモ)に控えるのは問題ない。危険なのは、サービスの DB に平文のまま持っておくこと。漏れたら全アカウントが即乗っ取られる。
認証ライブラリの better-auth は、調べたところリカバリコードをデフォルトで平文保存する。これは DB が漏れたら即アカウント乗っ取りにつながる弱点。本ハンズオンでは、OWASP の認証ガイドライン(ASVS 5.0 V6)に従い、コードのハッシュ(SHA-256)だけを保存し、一度使ったら無効化する(単回消費)。
| 認証手段 | 役割 | このアプリでの位置づけ |
|---|---|---|
| パスキー | 日常のログイン | 主役。1アカウントに複数登録できる |
| リカバリコード | パスキー喪失時の復旧 | 最後の砦。ハッシュ保存・単回消費 |
2. パスキーの仕組み・仕様のポイント
Section titled “2. パスキーの仕組み・仕様のポイント”実装に入る前に、パスキー(WebAuthn)の「正体」を押さえておく。ここを分かっていると、後のコードがなぜそう書くのか腑に落ちる。
2-1. 登場人物とデータモデル
Section titled “2-1. 登場人物とデータモデル”このアプリのデータ構造は、変わらない主キーと、足したり消したりできる鍵束に分けるのが肝。
flowchart TD U["users(アカウント)<br/>id = 不変のランダムUUID<br/>=WebAuthn user handle<br/>※外部に出さない"] U --> C1["パスキー1(iPhone)"] U --> C2["パスキー2(Windows)"] U --> R1["リカバリコード ×10<br/>(ハッシュ保存・使い捨て)"] U --> P["寄せ書き<br/>name / message"]
- users.id はアカウントの同一性そのもの。ランダムな UUID で、外部に出さない(URL や API 応答に含めない)。これを WebAuthn の user handle として使う。
- その不変の id に、複数のパスキーとリカバリコードがぶら下がる。パスキーは端末ごとに足せる(マルチデバイス)。
2-2. 4つの仕様キーワード
Section titled “2-2. 4つの仕様キーワード”- challenge(チャレンジ):サーバーが毎回出す使い捨ての乱数。これに端末が署名することで「いま・本人が」操作した証拠になる。1回使ったら捨てる・短時間で失効(このアプリは5分)。リプレイ攻撃を防ぐ。
- RP ID / origin:パスキーを束ねるドメイン。サーバーは検証時に「自分のドメインか」を必ず照合する(フィッシング耐性の正体)。
- 署名カウンタ(counter):認証のたびに増える数。逆行していたらコピーされた認証器の疑い、として検知に使う。
- user handle:アカウントの不変ID。discoverable なログインで、パスキーからこの handle が返ってきてユーザーを特定できる。
2-3. 同期パスキー vs 端末固定
Section titled “2-3. 同期パスキー vs 端末固定”iCloud キーチェーンや Google パスワードマネージャーに入るパスキーは同じエコシステム内で同期される(iPhone で作れば Mac でも使える)。一方、Windows Hello のような端末固定のパスキーは同期しない。エコシステムをまたぐ(iPhone ⇄ Windows)と同期されないので、そのために「1アカウントに複数パスキー」と「リカバリコード」が効いてくる。
3. 事前準備
Section titled “3. 事前準備”このハンズオンは新規プロジェクトとして作る。
- 作業フォルダを作る(例:
~/claude/yosegaki)。 - そのフォルダで Claude Code を使える状態にする。
- Cloudflare に wrangler でログイン済みであることを確認する(未ログインなら案内に従ってログイン)。
npx wrangler d1 create yosegaki-db実行すると database_id が表示される。あとで wrangler.jsonc に使うので控えておく(Claude Code に頼んでいれば覚えていてくれる)。
4. 【データベース】スキーマ
Section titled “4. 【データベース】スキーマ”5つのテーブルを作る。リカバリコードは code_hash(ハッシュ)と used(単回消費フラグ)を持つのがポイント。プロンプトでそれを明示するのが大事だ(曖昧だと平文保存されかねない)。
D1 マイグレーション migrations/0001_init.sql を作って。テーブルは5つ。- users(id TEXT PK=ランダムUUID/WebAuthn user handle, display_name, message, created_at, updated_at)- credentials(id PK, user_id FK→users ON DELETE CASCADE, credential_id UNIQUE, public_key, counter, transports, created_at)。user_id にインデックス- recovery_codes(id PK, user_id FK CASCADE, code_hash, used INTEGER DEFAULT 0, created_at)。 ※ code_hash には必ずハッシュ(SHA-256 の hex)だけを入れる。平文のリカバリコードは絶対に保存しない。used で単回消費を表す。code_hash にインデックス- challenges(id PK, challenge, purpose, user_id, created_at)。5分以内のものだけ有効にする- sessions(id PK, user_id FK CASCADE, created_at, expires_at)できあがる recovery_codes はこうなる(平文の列がないことを確認しよう)。
CREATE TABLE recovery_codes ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, code_hash TEXT NOT NULL, -- SHA-256(code) の hex(64桁)。平文は保存しない used INTEGER NOT NULL DEFAULT 0, -- 0=未使用, 1=消費済み(単回消費) created_at TEXT NOT NULL);作ったらローカルに適用する。
npx wrangler d1 migrations apply yosegaki-db --local5. wrangler.jsonc と Worker の骨組み
Section titled “5. wrangler.jsonc と Worker の骨組み”このアプリは Cloudflare Workers + Static Assets で作る。/api/* は Worker が処理し、それ以外は public/ の静的ファイルを返す。フレームワーク(Hono など)は使わず、素の fetch で分岐する。道具は少ないほどよい。
wrangler.jsonc を作って。Workers + Static Assets 構成にする。- main は src/index.js- assets は public/ を binding 名 ASSETS で配信- d1_databases に binding "DB"、database_name "yosegaki-db"、database_id は d1 create で出た値- compatibility_flags に "nodejs_compat" を必ず入れる(SimpleWebAuthn が Buffer を使うため。無いと登録/ログインで 500 になる)
nodejs_compatは必須。これを忘れると、登録やログインのときにBuffer is not definedで 500 エラーになる。実際に踏みやすい落とし穴なので、最初から入れておく。
ルーターの骨組みはこんな形。/api/ 以外は env.ASSETS.fetch(request) に丸投げする。
export default { async fetch(request, env) { const url = new URL(request.url); const p = url.pathname, m = request.method; if (p.startsWith("/api/")) { if (m === "POST" && p === "/api/signup/options") return h.signupOptions(request, env); // ... 各エンドポイント ... if (m === "GET" && p === "/api/me") return withSession(request, env, h.getMe); // 要ログイン return json({ error: "not found" }, 404); } return env.ASSETS.fetch(request); // /api/ 以外は静的ファイル },};withSession は「cookie のセッションが無ければ 401、あれば user_id を渡す」というラッパー。保護APIはこれで包む。
6. 【バックエンド】認証の共通処理
Section titled “6. 【バックエンド】認証の共通処理”RP情報・チャレンジ・セッションの共通ヘルパーを作る。安全要件を箇条書きの“契約”としてプロンプトに書くのがコツ。
src/auth.js に認証の共通ヘルパーを作って。安全要件を必ず守ること。- getRpInfo(request): RP ID と origin をリクエストのホスト名から決める(origin はプロトコル込み。ローカルの http://localhost と本番 https の両対応にする)- saveChallenge / consumeChallenge: challenge は purpose とともに保存し、検証時に「challenge の値で」照合して即削除(単回使用)。created_at が5分以内のものだけ有効- セッション cookie は HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=86400。createSession / getSessionUser / deleteSession を用意なぜ challenge を「値で」照合するのか。ユーザーが1人なら「最新の challenge を1つ取る」で済むが、複数ユーザーが同時にログインすると取り違える。だから challenge の文字列そのものをキーにして照合・消費する。SimpleWebAuthn の
expectedChallengeには関数を渡せるので、その中で「この challenge が DB にあるか」を確かめて消費する。
// ログイン検証の中核(複数ユーザーでも競合しない)verification = await verifyAuthenticationResponse({ response: body, expectedChallenge: async (ch) => !!(await consumeChallenge(env, ch, "login")), expectedOrigin: origin, expectedRPID: rpID, credential: { id: body.id, publicKey, counter: cred.counter },});6-1. サインアップ・ログイン API
Section titled “6-1. サインアップ・ログイン API”src/handlers.js に @simplewebauthn/server を使って認証APIを作って。- POST /api/signup/options: users 行を新規作成(id=ランダムUUID)し、その id を userID(user handle)として generateRegistrationOptions に渡す。residentKey は "required"(ユーザー名レスのため discoverable 必須)。challenge を purpose=register, user_id 付きで保存- POST /api/signup/verify: verifyRegistrationResponse で検証。challenge を値で照合・消費し、消費できた行の user_id を採用する(クライアント申告は信用しない)。成功したら credentials に公開鍵を base64 で保存し、リカバリコード10個を発行して平文を返す(保存はハッシュのみ)。セッションを発行- POST /api/login/options: generateAuthenticationOptions の allowCredentials を空配列にする(discoverable=ユーザー名レス)。challenge を purpose=login で保存- POST /api/login/verify: response.id(credential_id)から user を特定。検証成功で counter を更新し、セッションを発行- POST /api/login/recovery: リカバリコードを受け取り、consumeRecoveryCode(6-2)で単回消費できたら、その user_id でセッションを発行する。消費できないコードは 4016-2. リカバリコード
Section titled “6-2. リカバリコード”リカバリコードは生成・ハッシュ保存・単回消費・再生成で旧無効の4点を厳密に。プロンプトでルールを固定する。
発行されるコードはこんな形。ユーザーはこの10個を安全な場所に控える(下はサンプルで、すべて無効なコード)。
3E1F0-22F71-9C0FF-7A6B6A3E45-07D41-A62B0-EE001D6C99-C2902-68409-CD6D21F979-2D7F2-F52B3-30DFF7A347-0E3CD-CA178-E1B4FD1357-21779-97BDD-A23B99F4CB-50CE5-8967C-78BD8A6477-5B825-8B0F4-EC90515047-A5A5C-C3AA3-F92A320ADA-4A722-5A8E2-33D44src/recovery.js にリカバリコードの仕組みを作って。必ず次を守ること。1. 生成: crypto.getRandomValues で高エントロピー(各80bit以上)なコードを10個。表示用の平文はサインアップ/再生成の応答で「1回だけ」返す2. 保存: 平文は保存しない。コードを正規化(ハイフン・空白を除去して大文字化)してから SHA-256 した hex だけを recovery_codes.code_hash に保存する3. ログイン: 入力コードを同じ正規化をしてから SHA-256 して code_hash かつ used=0 で照合。成功したら used=1 に更新(単回消費。同じコードは二度使えない)。照合と消費は1文の UPDATE ... RETURNING で原子的に行う(SELECT してから UPDATE すると同時実行で二重消費されうる)4. 再生成: 旧コードを全部削除してから新しい10個を発行(旧コードは即無効)ハッシュと単回消費の中身はこれだけ。Web Crypto の crypto.subtle.digest で SHA-256 を取る(コードは高エントロピーなので bcrypt 等のストレッチは不要)。
export async function sha256Hex(str) { const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str)); return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");}
// 入力の揺れを吸収: ハイフン・空白を除去して大文字化(生成時の保存と照合で同じ正規化を使う)function normalize(code) { return code.replace(/[\s-]/g, "").toUpperCase();}
// 単回消費: 未使用の一致コードを1文で used=1 にし、消費できたら user_id を返す// (UPDATE ... RETURNING で照合と消費を原子的に行うので、同時実行でも二重消費されない)export async function consumeRecoveryCode(env, code) { const hash = await sha256Hex(normalize(code)); const row = await env.DB.prepare( `UPDATE recovery_codes SET used = 1 WHERE code_hash = ? AND used = 0 RETURNING user_id` ).bind(hash).first(); return row ? row.user_id : null;}7. 【バックエンド】寄せ書き API と パスキー追加
Section titled “7. 【バックエンド】寄せ書き API と パスキー追加”寄せ書きとアカウント管理のAPIを追加して。- GET /api/board: 公開。display_name と message が両方あるユーザーを新しい順に返す。id など他の列は返さない(user handle は外に出さない)- GET /api/me: 要ログイン。自分の name/message と、パスキー数・未使用リカバリコード数を返す- PUT /api/me/post: 要ログイン。名前と一言を upsert(1人1投稿なので users 行を更新)- POST /api/me/credentials/options + /verify: 要ログイン。別端末用にパスキーを追加。generateRegistrationOptions の excludeCredentials に既存パスキーを渡して二重登録を防ぐ- POST /api/me/recovery/regenerate: 要ログイン。リカバリコードを再生成(旧は全削除)- POST /api/logout: セッション削除8. 【フロントエンド】画面
Section titled “8. 【フロントエンド】画面”public/index.html・app.js・style.css を作る。ブラウザ側は @simplewebauthn/browser を ESM CDN から読み込み、startRegistration / startAuthentication を呼ぶだけ。
public/index.html, app.js, style.css を作って。寄せ書きアプリの画面。- @simplewebauthn/browser は ESM CDN(https://esm.sh/@simplewebauthn/browser@13)から import- 上部に寄せ書き一覧(GET /api/board)。各カードは名前と一言。ユーザー入力にはXSS防止の処置を入れる- 未ログイン: 「パスキーでアカウントを作る」「パスキーでログイン」、折りたたみで「リカバリコードでログイン」- サインアップ成功時: リカバリコード10個をモーダルで表示し、「保存しました」にチェックするまで閉じられないようにする(再表示できない旨を明記)- ログイン済み: 名前と一言の入力+保存、パスキー追加、リカバリコード再生成、ログアウト- リカバリコードでログインした直後は「この端末にパスキーを追加しますか?」を促す9. デプロイ
Section titled “9. デプロイ”本番の D1 にマイグレーションを適用し、デプロイする。
npx wrangler d1 migrations apply yosegaki-db --remotenpx wrangler deploy完了すると https://プロジェクト名.アカウント.workers.dev の形で公開URLが出る。
ドメイン束縛に注意。パスキーは登録したドメインに束縛される。
workers.devで登録したパスキーは、あとで独自ドメインに移すと使えなくなる。本番のドメインを決めてから登録するのが安全。
10. 動かす:最初のアカウントを作る
Section titled “10. 動かす:最初のアカウントを作る”公開URLを開いて、自分でアカウントを作ってみる。
- 「パスキーでアカウントを作る」 → 生体認証でパスキーを作成。
- リカバリコード10個が表示される → 安全な場所に保存(パスワードマネージャー等)。
- 名前と一言を入力して保存 → 寄せ書きに自分のカードが出る。
投稿すると寄せ書きに並ぶ。下にアカウント管理(パスキー追加・リカバリ再生成・ログアウト)
11. 動作確認(自分でテストする)
Section titled “11. 動作確認(自分でテストする)”ここが大事。仕様どおり安全に動いているかを自分で確かめる。ブラウザ操作と wrangler d1 execute での目視で完結する。
| 確認すること | やり方 | 期待 |
|---|---|---|
| 名前レスログイン | ログアウト → 「パスキーでログイン」 | ユーザー名を入れずに自分のアカウントに入れる |
| 1人1投稿 | もう一度投稿 | カードが増えず上書きされる |
| 公開閲覧 | シークレットウィンドウでトップを開く | 寄せ書きは見えるが投稿欄は出ない |
| 未ログインで保護API | シークレットで GET /api/me を直接開く | 401 |
| リカバリで復旧 | リカバリコード1個でログイン | 入れる+「パスキー追加」を促される |
| 単回消費 | 同じリカバリコードでもう一度ログイン | 2回目は弾かれる(401) |
| ハッシュ保存の証拠 | 下記コマンド | code_hash が64桁のhex(平文コードは無い) |
npx wrangler d1 execute yosegaki-db --remote --command "SELECT code_hash, used FROM recovery_codes LIMIT 3"リカバリコードを1つ手元に控えて、その文字列(ハイフン除去・大文字化)の SHA-256 を自分で計算してみると、ここに出る
code_hashと一致する。「平文ではなくハッシュが保存されている」ことを自分の目で確認できる。
12. セキュリティ要点・備忘録
Section titled “12. セキュリティ要点・備忘録”仕様準拠のチェックリスト。実装が次を満たしているか確認する。
- challenge は単回・5分:発行時に保存、検証時に削除。使い回し不可。
- RP ID / origin 束縛:ホスト名から導出し検証に渡す(フィッシング耐性)。
- 署名カウンタを更新:ログインのたびに保存(クローン検知)。
- セッション cookie:
HttpOnly; Secure; SameSite=Strict。 - リカバリコード:ハッシュ保存・単回消費・再生成で旧無効。平文保存しない。
- user handle は非公開:
users.idを API 応答に含めない。寄せ書きは名前と一言だけ返す。パスキー作成時のuserNameにも handle を入れない(「寄せ書きユーザー」のような固定の表示名にする)。さもないと、パスキー作成ダイアログや認証器の一覧に handle のかけらが表示されてしまう。 - 追加登録の重複防止:
excludeCredentialsに既存パスキーを渡す。同じ端末で重ねて追加しようとすると、ブラウザがInvalidStateError(“The authenticator was previously registered”)を返す。これは重複が正しく防がれた証拠なので、UI では「この端末には既にパスキーがあります」と穏やかに扱う(赤エラーにしない)。リカバリコードでログインした端末が、たまたま既にパスキーを持っていた場合に起きやすい。
そして本番運用に向けた宿題:
- レート制限:サインアップ・ログイン・リカバリは総当たりの対象になりうる。本番では Cloudflare Turnstile(フォーム前段の bot 対策)や Workers の Rate Limiting を足す。とくにリカバリコードは総当たり対象なので Rate Limiting を強く推奨。
- 掃除:サインアップ画面を開いて離脱すると、空の
users行や期限切れのchallengesが残る。本番では定期的な掃除(Cron Triggers など)を入れるとよい。
これで、パスキー+リカバリコードによる一般ユーザーのアカウント認証が、メール送信なし・Cloudflare 無料枠だけで完成した。出来合いのライブラリに頼らず仕組みを理解して作ったので、別のアプリにも応用が利く。
別の認証方法:本人確認を Google などの外部サービスに任せる「ソーシャルログイン」で同じ寄せ書きを作る版が ソーシャルログインで寄せ書きを作る(SOL)にある。ユーザーには「◯◯でログイン」のほうが身近なことも多い。



