🔑

Cloudflare Workers + D1でPasskeyログインを実装する

に公開

この記事では、Cloudflare WorkersをAPIレイヤーに、D1をDBとしてPasskey(WebAuthn)を実装する方法を紹介します。SSRなNext.jsアプリ(apps/web)とはHTTP越しに連携し、@simplewebauthn のサーバー/ブラウザー両SDKを活用しています。

構成

  • Cloudflare Workers (apps/worker): Honoを採用したAPIゲートウェイ。Passkey登録・認証・管理のエンドポイントを提供。
  • Cloudflare D1 (apps/worker/schema.sql): ユーザー、Passkey、チャレンジをSQLite互換スキーマで管理。
  • Next.js + App Router (apps/web): プロファイル画面でPasskey登録や削除を行い、@simplewebauthn/browserでクライアント処理を担当。
  • 認証ライブラリ: サーバー側は@simplewebauthn/server、JWTは独自実装 (apps/worker/src/auth.ts)。

D1スキーマのポイント

apps/worker/schema.sqlではPasskey関連で2テーブルを定義:

CREATE TABLE IF NOT EXISTS passkeys (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  credential_id TEXT NOT NULL UNIQUE,
  public_key TEXT NOT NULL,
  counter INTEGER NOT NULL DEFAULT 0,
  device_type TEXT,
  backed_up BOOLEAN,
  transports TEXT,
  attestation_format TEXT,
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS passkey_challenges (
  id TEXT PRIMARY KEY,
  user_id TEXT,
  email TEXT,
  challenge TEXT NOT NULL,
  type TEXT NOT NULL CHECK (type IN ('register','login')),
  expires_at DATETIME NOT NULL,
  created_at DATETIME NOT NULL
);
  • passkeysはFIDOクレデンシャルのベース64URLエンコード済み公開鍵、カウンター、Authenticatorのメタ情報を保持。
  • passkey_challengesは登録/ログインチャレンジを10分TTL (futureISO(10))で保存し、毎回リクエスト前に期限切れを削除しています。

WorkersユーティリティでのRP設定

apps/worker/src/utils/passkey.tsにRP設定やBase64Url変換を集約。

export function getRpID(env: Bindings): string {
  return new URL(env.FRONTEND_URL).hostname;
}

export async function createRegistrationOptions(...) {
  return generateRegistrationOptions({
    rpID: getRpID(env),
    rpName: 'Sample App',
    userID: encodeUserId(user.id),
    userName: user.email,
    userDisplayName: user.name,
    authenticatorSelection: {
      residentKey: 'required',
      userVerification: 'required',
    },
    attestationType: 'none',
    excludeCredentials,
  });
}
  • RP ID/Originは環境変数FRONTEND_URLから算出。Stagingでも環境変数を差し替えるだけでOK。
  • residentKey: 'required'でユーザー名レスな設計を前提化し、紛失端末を避ける。
  • 端末トランスポート情報はJSON文字列でD1に保存し、後述のUIに表示。

登録フロー

1. /auth/passkey/register/start

apps/worker/src/index.tsの該当ハンドラーは以下を実施:

  1. 期限切れチャレンジを削除。
  2. 同一ユーザーの未消化登録チャレンジを掃除(多重登録防止)。
  3. 既存PasskeyをexcludeCredentialsに設定して重複クレデンシャルをブロック。
  4. challengeIdをUUIDで払い出し、D1にtype='register'で保存。
  5. クライアントへ{ challengeId, options }を返却。

2. /auth/passkey/register/finish

  • 受信したchallengeIdでD1を引き当て、ユーザーIDや有効期限を検証。
  • verifyRegistrationでクライアントレスポンスを検証し、registrationInfo.credentialを取り出し。
  • credentialDeviceTypeやcredentialBackedUpを含めてpasskeysテーブルをUPSERT。
  • カウンターは初期値0で保存。PublicKey/Credential IDはBase64Urlへ正規化。
  • 完了後チャレンジレコードを削除。

認証フロー

1. /auth/passkey/login/options

  • チャレンジを新規に生成し、type='login'でD1に保存。
  • AllowCredentialsを指定しない実装のため、ブラウザが端末内の全Passkey候補を提示。

2. /auth/passkey/login/finish

  1. クライアントレスポンスのidでpasskeysを検索。
  2. 対応ユーザーを取得し、verifyAuthenticationで検証。
  3. FIDOカウンターをpasskeysに書き戻し(リプレイ防止)。
  4. Worker内でJWTを再発行し、auth_tokenクッキーを更新。
  5. チャレンジを削除し結果を返却。

クライアント統合 (Next.js)

apps/web/app/(protected)/profile/page.tsx
const handlePasskeySetup = useCallback(async () => {
  if (typeof window !== 'undefined' && !window.PublicKeyCredential) {
    toast.error('このブラウザはPasskeyに対応していません')
    return
  }
  const start = await apiClient.startPasskeyRegistration()
  const attestation = await startRegistration({ optionsJSON: start.options })
  await apiClient.finishPasskeyRegistration(start.challengeId, attestation)
  await fetchPasskeys()
})
  • apiClientは/auth/passkey/*エンドポイントと通信。
  • Passkey一覧は/auth/passkey/credentialsから取得し、Credential IDやバックアップ状態をUI表示。
  • 削除操作は確認ダイアログ付きでDELETE /auth/passkey/credentials/:idを呼び出し。

まとめ

Cloudflare Workers + D1でも、@simplewebauthnを組み合わせることでPasskey実装をシンプルに構築できます。
前回はこちら↓

https://zenn.dev/komiyajapan/articles/b0d7ac79f680b8

Discussion