🔑
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の該当ハンドラーは以下を実施:
- 期限切れチャレンジを削除。
- 同一ユーザーの未消化登録チャレンジを掃除(多重登録防止)。
- 既存Passkeyを
excludeCredentialsに設定して重複クレデンシャルをブロック。 -
challengeIdをUUIDで払い出し、D1にtype='register'で保存。 - クライアントへ
{ 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
- クライアントレスポンスの
idでpasskeysを検索。 - 対応ユーザーを取得し、
verifyAuthenticationで検証。 - FIDOカウンターを
passkeysに書き戻し(リプレイ防止)。 - Worker内でJWTを再発行し、
auth_tokenクッキーを更新。 - チャレンジを削除し結果を返却。
クライアント統合 (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実装をシンプルに構築できます。
前回はこちら↓
Discussion