コンテンツにスキップ

Claude Code でパスキー+リカバリコードのアカウント認証を作る

⚠️ このハンズオンは制作途中です。実際に動かして検証しながら手順を確定させている段階で、説明やプロンプトが今後書き換わる可能性があります。気になる点があればフィードバックいただけると助かります。

この記事では、一般のユーザーが各自アカウントを作る仕組みを、パスキー(WebAuthn)で作る。題材は「みんなの寄せ書き」。各ユーザーがパスキーでアカウントを作り、名前と一言を残すと、みんなの一言が並ぶ。

最近のサービスのログインが「パスワードなし」「メールも不要」で済むのは、このパスキー(WebAuthn)の仕組みによる。本記事ではそれを、仕様に沿って安全に、しかも Cloudflare の無料枠(Workers + D1)だけで自前実装する。パスキーの仕組みを理解しながら作り、最後に自分でテストして確かめられるところまでやる。

パスキーは Hanko や Clerk などのホスト型サービスを使えばボタン1つで載せられる。それでも本記事で自前実装するのは、本サイトのスタンス(裏側を理解する・外部サービスに依存しない・Cloudflare 内で完結)に沿うため。WebAuthn の難所(チャレンジ生成・署名検証)は SimpleWebAuthn ライブラリ(@simplewebauthn/server / @simplewebauthn/browser)が引き受けてくれるので、公開鍵やチャレンジは D1 に置くだけでよい。

1-1. 作るもの:みんなの寄せ書き

Section titled “1-1. 作るもの:みんなの寄せ書き”

寄せ書きが並んだ完成画面

完成イメージ。みんなの寄せ書きが並び、ログインすると下の欄から自分も投稿できる

▶ 完成版を触ってみる: yosegaki.tatsuo-8d3.workers.dev:この章で作る寄せ書きの完成版。閲覧はそのまま、パスキーでアカウントを作れば投稿・編集も試せる(デモのため、投稿は予告なくリセット・削除することがある)。

  • 各ユーザーがパスキーでアカウントを作る(メールもパスワードも不要)。
  • 名前と一言を入力すると、全員の寄せ書きに並ぶ(1人1投稿、あとから編集可)。
  • 閲覧は公開、投稿・編集はログインした本人だけ。

このアプリのポイントは、いくらでも増える一般ユーザーが各自アカウントを持つ点。そのために必要になるのが、ユーザーを区別する仕組みと、パスキーをなくしたときの復旧手段(リカバリコード)だ。

パスキーは、端末の生体認証(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)の「正体」を押さえておく。ここを分かっていると、後のコードがなぜそう書くのか腑に落ちる。

このアプリのデータ構造は、変わらない主キーと、足したり消したりできる鍵束に分けるのが肝。

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 に、複数のパスキーリカバリコードがぶら下がる。パスキーは端末ごとに足せる(マルチデバイス)。
  • challenge(チャレンジ):サーバーが毎回出す使い捨ての乱数。これに端末が署名することで「いま・本人が」操作した証拠になる。1回使ったら捨てる・短時間で失効(このアプリは5分)。リプレイ攻撃を防ぐ。
  • RP ID / origin:パスキーを束ねるドメイン。サーバーは検証時に「自分のドメインか」を必ず照合する(フィッシング耐性の正体)。
  • 署名カウンタ(counter):認証のたびに増える数。逆行していたらコピーされた認証器の疑い、として検知に使う。
  • user handle:アカウントの不変ID。discoverable なログインで、パスキーからこの handle が返ってきてユーザーを特定できる。

iCloud キーチェーンや Google パスワードマネージャーに入るパスキーは同じエコシステム内で同期される(iPhone で作れば Mac でも使える)。一方、Windows Hello のような端末固定のパスキーは同期しない。エコシステムをまたぐ(iPhone ⇄ Windows)と同期されないので、そのために「1アカウントに複数パスキー」と「リカバリコード」が効いてくる。

このハンズオンは新規プロジェクトとして作る。

  1. 作業フォルダを作る(例:~/claude/yosegaki)。
  2. そのフォルダで Claude Code を使える状態にする。
  3. Cloudflare に wrangler でログイン済みであることを確認する(未ログインなら案内に従ってログイン)。
Terminal window
npx wrangler d1 create yosegaki-db

実行すると database_id が表示される。あとで wrangler.jsonc に使うので控えておく(Claude Code に頼んでいれば覚えていてくれる)。

5つのテーブルを作る。リカバリコードは code_hash(ハッシュ)と used(単回消費フラグ)を持つのがポイント。プロンプトでそれを明示するのが大事だ(曖昧だと平文保存されかねない)。

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

作ったらローカルに適用する。

Terminal window
npx wrangler d1 migrations apply yosegaki-db --local

このアプリは Cloudflare Workers + Static Assets で作る。/api/* は Worker が処理し、それ以外は public/ の静的ファイルを返す。フレームワーク(Hono など)は使わず、素の fetch で分岐する。道具は少ないほどよい。

Claude
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情報・チャレンジ・セッションの共通ヘルパーを作る。安全要件を箇条書きの“契約”としてプロンプトに書くのがコツ。

Claude
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”
Claude
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 でセッションを発行する。消費できないコードは 401

リカバリコードは生成・ハッシュ保存・単回消費・再生成で旧無効の4点を厳密に。プロンプトでルールを固定する。

発行されるコードはこんな形。ユーザーはこの10個を安全な場所に控える(下はサンプルで、すべて無効なコード)。

3E1F0-22F71-9C0FF-7A6B6
A3E45-07D41-A62B0-EE001
D6C99-C2902-68409-CD6D2
1F979-2D7F2-F52B3-30DFF
7A347-0E3CD-CA178-E1B4F
D1357-21779-97BDD-A23B9
9F4CB-50CE5-8967C-78BD8
A6477-5B825-8B0F4-EC905
15047-A5A5C-C3AA3-F92A3
20ADA-4A722-5A8E2-33D44
Claude
src/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 と パスキー追加”
Claude
寄せ書きとアカウント管理の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: セッション削除

public/index.htmlapp.jsstyle.css を作る。ブラウザ側は @simplewebauthn/browser を ESM CDN から読み込み、startRegistration / startAuthentication を呼ぶだけ。

Claude
public/index.html, app.js, style.css を作って。寄せ書きアプリの画面。
- @simplewebauthn/browser は ESM CDN(https://esm.sh/@simplewebauthn/browser@13)から import
- 上部に寄せ書き一覧(GET /api/board)。各カードは名前と一言。ユーザー入力にはXSS防止の処置を入れる
- 未ログイン: 「パスキーでアカウントを作る」「パスキーでログイン」、折りたたみで「リカバリコードでログイン」
- サインアップ成功時: リカバリコード10個をモーダルで表示し、「保存しました」にチェックするまで閉じられないようにする(再表示できない旨を明記)
- ログイン済み: 名前と一言の入力+保存、パスキー追加、リカバリコード再生成、ログアウト
- リカバリコードでログインした直後は「この端末にパスキーを追加しますか?」を促す

本番の D1 にマイグレーションを適用し、デプロイする。

Terminal window
npx wrangler d1 migrations apply yosegaki-db --remote
npx wrangler deploy

完了すると https://プロジェクト名.アカウント.workers.dev の形で公開URLが出る。

ドメイン束縛に注意。パスキーは登録したドメインに束縛される。workers.dev で登録したパスキーは、あとで独自ドメインに移すと使えなくなる。本番のドメインを決めてから登録するのが安全。

10. 動かす:最初のアカウントを作る

Section titled “10. 動かす:最初のアカウントを作る”

公開URLを開いて、自分でアカウントを作ってみる。

  1. パスキーでアカウントを作る」 → 生体認証でパスキーを作成。
  2. リカバリコード10個が表示される → 安全な場所に保存(パスワードマネージャー等)。
  3. 名前と一言を入力して保存 → 寄せ書きに自分のカードが出る。

投稿後の画面

投稿すると寄せ書きに並ぶ。下にアカウント管理(パスキー追加・リカバリ再生成・ログアウト)

11. 動作確認(自分でテストする)

Section titled “11. 動作確認(自分でテストする)”

ここが大事。仕様どおり安全に動いているかを自分で確かめる。ブラウザ操作と wrangler d1 execute での目視で完結する。

確認することやり方期待
名前レスログインログアウト → 「パスキーでログイン」ユーザー名を入れずに自分のアカウントに入れる
1人1投稿もう一度投稿カードが増えず上書きされる
公開閲覧シークレットウィンドウでトップを開く寄せ書きは見えるが投稿欄は出ない
未ログインで保護APIシークレットで GET /api/me を直接開く401
リカバリで復旧リカバリコード1個でログイン入れる+「パスキー追加」を促される
単回消費同じリカバリコードでもう一度ログイン2回目は弾かれる(401)
ハッシュ保存の証拠下記コマンドcode_hash64桁のhex(平文コードは無い)
Terminal window
npx wrangler d1 execute yosegaki-db --remote --command "SELECT code_hash, used FROM recovery_codes LIMIT 3"

リカバリコードを1つ手元に控えて、その文字列(ハイフン除去・大文字化)の SHA-256 を自分で計算してみると、ここに出る code_hash と一致する。「平文ではなくハッシュが保存されている」ことを自分の目で確認できる。

仕様準拠のチェックリスト。実装が次を満たしているか確認する。

  • challenge は単回・5分:発行時に保存、検証時に削除。使い回し不可。
  • RP ID / origin 束縛:ホスト名から導出し検証に渡す(フィッシング耐性)。
  • 署名カウンタを更新:ログインのたびに保存(クローン検知)。
  • セッション cookieHttpOnly; 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)にある。ユーザーには「◯◯でログイン」のほうが身近なことも多い。