Claude Codeでパスキーを実装する — WebAuthnのチャレンジ検証をテストで固める
SimpleWebAuthnでパスキーの登録と認証を実装させ、challengeの使い捨て・origin・rpID・signCountの拒否をテストに落とす手順。ブラウザなしで回る疑似オーセンティケーターのコードつきです。
Claude Codeでパスキー(WebAuthn)のログインを実装するときは、正常系より先に「拒否すべき応答」をテストにして渡すと、実装のずれを早く拾えます。パスキーの登録と認証は、ライブラリを呼べば一度は動きます。壊れやすいのは、使い回されたchallenge、別サイトのorigin、別のrpID、巻き戻ったsignCountを通してしまう側です。この記事では、SimpleWebAuthn(@simplewebauthn/server)で登録と認証のサーバー側を書かせ、ブラウザも実機の認証器も使わずに、拒否の条件を7本のテストで固める流れを説明します。テストとコードは @simplewebauthn/server 14.0.3で実行し、7本とも通る状態のものです。
パスキー実装でテストに落とす4つの拒否条件
パスキーの検証で、サーバー側が守る点は4つに分けられます。ライブラリの verifyRegistrationResponse() と verifyAuthenticationResponse() は、このうち3つを引数で受け取って照合します。残る1つは呼び出す側の設計です。
サーバーが拒否すべき4点
challenge の再利用
発行した1回の認証にだけ有効な値です。ライブラリは「渡された
expectedChallengeと応答が一致するか」を見るだけで、使用済みかどうかは知りません。保存した値を検証後に消すのは呼び出し側の仕事です。origin の不一致
応答の
clientDataJSONに入るoriginが、expectedOriginと違う場合です。別サイトで集めた応答の持ち込みを止めます。rpID の不一致
認証器が署名したデータに含まれるRP IDのハッシュが、
expectedRPIDと合わない場合です。signCount の巻き戻り
保存済みの値より小さい、または同じ値のカウンターです。複製された認証器の兆候として扱われます。
SimpleWebAuthnのドキュメントは、signCountについて次のように説明しています。保存した値が0より大きく、次の認証の値が同じか小さいなら、認証器が侵害された状態かもしれません。一方でmacOSのTouch IDのように常に0を返す認証器もあり、その場合はRP側で複製を検知する手段がほぼありません。「カウンターが増えること」を絶対の条件にしないのは、この性質があるためです。
テストの形を決める — ブラウザなしで認証器を作る
パスキーのテストで最初にぶつかるのは、応答を作る側がブラウザと認証器だという点です。実機で毎回タッチして確かめる方法では、Claude Codeが自分で回せるチェックになりません。
そこで、テスト用に疑似オーセンティケーターを用意します。署名鍵をNodeの crypto で生成し、attestationType: 'none' の登録応答と、ES256で署名した認証応答をJSONの形で返すだけの小さな関数です。サーバー側の検証コードは本物のライブラリをそのまま使うので、暗号の検証までテストの中で実際に走ります。
Claude Codeのベストプラクティスは、合否が返ってくる確認手段を持たせると、Claudeが変更、実行、失敗の読み取り、再実行を自分で回せると説明しています。さらに、完了の報告には「実行したコマンドとその出力」という証拠を求めるよう勧めています。パスキーの場合、その確認手段が上の疑似オーセンティケーターと node --test です。
疑似オーセンティケーターの核になる認証応答の部分は、次のとおりです。登録応答の組み立て(CBORで包んだ公開鍵)は長いので省き、ここでは認証側だけを示します。
// fake-authenticator.mjs(抜粋)
authenticate(options, { origin: o = origin, rp = rpID } = {}) {
counter += 1;
const cnt = Buffer.alloc(4);
cnt.writeUInt32BE(counter);
// flags: UP(0x01) | UV(0x04)
const authData = Buffer.concat([sha256(rp), Buffer.from([0x05]), cnt]);
const cd = clientData('webauthn.get', options.challenge, o);
const signature = sign('sha256',
Buffer.concat([authData, sha256(cd)]), privateKey);
return {
id: b64u(credentialId), rawId: b64u(credentialId),
type: 'public-key', clientExtensionResults: {},
response: {
clientDataJSON: b64u(cd),
authenticatorData: b64u(authData),
signature: b64u(signature),
},
};
},origin と rp を引数で差し替えられるようにしてあるのがポイントです。「別のoriginから来た応答」「別のrpIDで署名された応答」を、テストが1行で作れます。signCountも setCounter() で任意の値に戻せます。
サーバー側を書かせる — challengeを使い捨てにする
実装の指示では、ライブラリの呼び出し方を細かく指定するより、ライブラリが担当しない部分を明示するほうが効きます。SimpleWebAuthnのドキュメントの例は、challengeの保存を setCurrentRegistrationOptions のような疑似コードで済ませており、保存方法も消すタイミングも書かれていません。Claudeに任せると、保存はしても消さない実装になりがちな箇所です。
CLAUDE.mdには、次のような規約を置いておきます。
# パスキー実装の規約
- WebAuthn の検証は @simplewebauthn/server を使い、自前で署名検証を書かない
- challenge は検証に使った時点で必ず削除する(成功・失敗を問わない)
- テストは `node --test` で実行し、ライブラリ関数をモックしない
- rpID と origin は設定から渡し、リクエストのヘッダーから組み立てない最後の2行は、テストが空洞になる典型を先に塞ぐためのものです。verifyAuthenticationResponse をモックしたテストは、検証ロジックを一行も通らずに緑になります。originをリクエストのHostヘッダーから作る実装は、攻撃者の指定したoriginを「期待値」にしてしまいます。
サーバー側の全体は、次の程度の長さに収まります。インメモリで動く最小構成で、DBを使う場合は Map を差し替えます。
// passkey-service.mjs
import {
generateRegistrationOptions, verifyRegistrationResponse,
generateAuthenticationOptions, verifyAuthenticationResponse,
} from '@simplewebauthn/server';
export function createPasskeyService({ rpID, rpName, origin }) {
const challenges = new Map(); // userId -> { challenge, kind }
const passkeys = new Map(); // credentialId -> 公開鍵・counter など
// 取り出すと同時に削除する(使い捨て)
const take = (userId, kind) => {
const entry = challenges.get(userId);
challenges.delete(userId);
if (!entry || entry.kind !== kind) {
throw new Error('challenge が見つからないか、種別が違います');
}
return entry.challenge;
};
return {
passkeys,
async registrationOptions(userId, userName) {
const options = await generateRegistrationOptions({
rpName, rpID, userName, attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred', userVerification: 'preferred',
},
});
challenges.set(userId, { challenge: options.challenge, kind: 'register' });
return options;
},
async verifyRegistration(userId, response) {
const v = await verifyRegistrationResponse({
response, expectedChallenge: take(userId, 'register'),
expectedOrigin: origin, expectedRPID: rpID,
});
if (v.verified) {
const { credential } = v.registrationInfo;
passkeys.set(credential.id, { userId,
publicKey: credential.publicKey, counter: credential.counter,
transports: credential.transports });
}
return v.verified;
},
async authenticationOptions(userId) {
const options = await generateAuthenticationOptions({
rpID, userVerification: 'preferred',
});
challenges.set(userId, { challenge: options.challenge, kind: 'authenticate' });
return options;
},
async verifyAuthentication(userId, response) {
const passkey = passkeys.get(response.id);
if (!passkey || passkey.userId !== userId) {
throw new Error('未登録のパスキーです');
}
const v = await verifyAuthenticationResponse({
response, expectedChallenge: take(userId, 'authenticate'),
expectedOrigin: origin, expectedRPID: rpID,
credential: { id: response.id, publicKey: passkey.publicKey,
counter: passkey.counter, transports: passkey.transports },
});
if (v.verified) passkey.counter = v.authenticationInfo.newCounter;
return v.verified;
},
};
}take() が使い捨ての実体です。challenges.delete() を検証の前に置いているので、検証が例外で落ちた場合もchallengeは残りません。kind を持たせているのは、登録用に発行した値で認証が通る取り違えを防ぐためです。認証側では、応答の id から引いたパスキーが、リクエストしたユーザーのものかも確かめています。
ライブラリ側の引数名は、ドキュメントの例と同じです。expectedChallenge、expectedOrigin、expectedRPID を渡し、認証では保存済みの公開鍵とカウンターを credential にまとめます。登録後に保存するのは、credential の id、publicKey、counter、transports です。認証が成功したら authenticationInfo の newCounter で保存値を更新します。
拒否条件を7本のテストにする
テストは、上の4条件に正常系と所有者の確認を足した7本です。先に指示を出してから実装させる順序にします。
passkey.test.mjs に次の7本のテストを先に書いて。実装はまだ書かないで。
1. 登録→認証の正常系(認証後に保存した counter が 1 になる)
2. 同じ認証応答の再送は拒否
3. 古い challenge に対する応答は、新しい challenge の発行後に拒否
4. origin が https://evil.test の応答は拒否
5. rpID が evil.test の応答は拒否
6. counter が保存値より小さい応答は拒否
7. 別ユーザーの userId では、他人のパスキーで認証できない
fake-authenticator.mjs を使い、ライブラリ関数はモックしない。
書いたら node --test を実行して、失敗の出力を見せて。テストの中身は短くなります。拒否の確認は assert.rejects() で、例外のメッセージの一部を正規表現で指定します。
// passkey.test.mjs(抜粋)
const cfg = { rpID: 'example.test', rpName: 'Example',
origin: 'https://example.test' };
const setup = async () => {
const svc = createPasskeyService(cfg);
const auth = createAuthenticator(cfg);
const opts = await svc.registrationOptions('u1', 'alice');
assert.equal(await svc.verifyRegistration('u1', auth.register(opts)), true);
return { svc, auth };
};
test('challenge は使い捨て: 同じ応答の再送は拒否', async () => {
const { svc, auth } = await setup();
const opts = await svc.authenticationOptions('u1');
const res = auth.authenticate(opts);
await svc.verifyAuthentication('u1', res);
await assert.rejects(svc.verifyAuthentication('u1', res), /challenge/);
});
test('signCount が戻った応答は拒否', async () => {
const { svc, auth } = await setup();
auth.setCounter(10);
await svc.verifyAuthentication('u1',
auth.authenticate(await svc.authenticationOptions('u1'))); // 11
auth.setCounter(4);
await assert.rejects(svc.verifyAuthentication('u1',
auth.authenticate(await svc.authenticationOptions('u1'))), /counter/i);
});実行すると、7本が通ります。
node --test passkey.test.mjs出力の末尾は # tests 7 / # pass 7 / # fail 0 でした。ドキュメントのインストール節はNode LTS 22.x以上を挙げていますが、手元のNode v20.18.0でもこの7本は通りました。本番に載せるときは、ドキュメントの記載どおり22.x以上を使います。
拒否の理由を読み分ける
各ケースでライブラリが投げたメッセージは、次のとおりです。テストの正規表現は、この文言の一部に合わせています。
| 変えた値 | 返ったメッセージ(14.0.3) |
|---|---|
originを https://evil.test に | 返ったメッセージ(14.0.3)Unexpected authentication response origin "https://evil.test", expected "https://example.test" |
rpIDを evil.test に | 返ったメッセージ(14.0.3)Unexpected RP ID hash |
| 古いchallengeの応答 | 返ったメッセージ(14.0.3)Unexpected authentication response challenge "…", expected "…"(値は毎回変わる) |
| signCountを戻す | 返ったメッセージ(14.0.3)Response counter value 5 was lower than expected 11 |
signCountの行は、保存値が11の状態で、認証器のカウンターを4に戻してから1回認証した結果です。認証器が1つ進めて返すため、メッセージ中の「5」は戻した4に1を足した値です。
rpIDの不一致は、メッセージに期待値も実際の値も出ません。ログから原因を追うときは、リクエストに使ったrpIDを自分で記録しておく必要があります。originの不一致は、登録の検証でも同じ形式で返ります(Unexpected registration response origin …)。
テストが本当に効いているかを壊して確かめる
緑のテストは、実装が正しいことの証拠にはなりません。テストが赤くなることを一度見ておくと、見張りとして働くと分かります。
take() から challenges.delete(userId); の1行を消した実装で同じ7本を走らせると、「challengeは使い捨て」の1本だけが失敗しました(# pass 6 / # fail 1)。残る6本は通ります。ライブラリの検証は保存した値と応答を比べるだけなので、削除を忘れても、他の拒否条件のテストは何も言いません。使い捨てを守るテストは、専用に1本必要です。
同じ確認を、Claude Codeに頼めます。
passkey-service.mjs の take() から challenges.delete の行を一時的に消して、
node --test の結果を見せて。どのテストが落ちたかを報告して、
確認が済んだら元に戻して。完了の報告には、実行したコマンドと出力を添えさせます。「テストが通りました」の一文だけでは、どのテストが何を見ているかが分かりません。
疑似オーセンティケーターが保証しない範囲
このテストは、サーバー側の検証ロジックだけを固めます。次の点は別の確認が必要です。
- ブラウザ側の呼び出し:
@simplewebauthn/browserのstartRegistration()とstartAuthentication()には、サーバーが返したオプションをそのまま渡します。ここの接続ミスはサーバーのテストでは見つかりません。ブラウザのE2Eテストか手動の確認が要ります。 - 実際の認証器の挙動: 疑似オーセンティケーターはES256と
attestationType: 'none'だけを再現します。Touch IDのようにカウンターを常に0で返す認証器や、同期されるパスキーの扱いは、ここでは確かめていません。signCountの拒否を本番の全ユーザーに厳格に適用するかは、別に設計する必要があります。 - 永続化: インメモリの
Mapはプロセスを再起動すると消えます。ドキュメントは、パスキーを専用のテーブルに持ち、公開鍵をバイト列で保存するよう勧めています。MongoDBなどでBinary型に保存すると、検証時にError: No dataになる事例もドキュメントのトラブルシューティングに載っています。 - challengeの有効期限: この実装は使い捨てだけを守り、期限は持っていません。発行から一定時間で失効させる処理は別に足します。
Claude Code自身のパスキーや信頼済みデバイスの扱いは、実装とは別の話です。Claude Code Trusted Devicesとパスキーの扱い方を参照してください。認証をまるごと外部サービスに任せる方法なら、descopeプラグインで認証実装とAuth0移行を進める記事があります。
このテストをClaudeに繰り返し回させる
パスキーの実装は、一度で終わらず、ライブラリの更新や仕様の追加に合わせて直す場面が続きます。拒否条件のテストが揃っていると、Claudeへの指示は「node --test が全部通るまで直して」の一言で足ります。
OAuthのクライアントでも同じ型で拒否条件を固めています。認証まわりで同じ型を使ったOAuthのPKCEとstateをテストで固める手順も、あわせて読むと比べやすくなります。
Claude Codeのドキュメントは、テストを書かせるときは検証したい挙動を具体的に指定するよう勧めています。そのうえで、Claudeに既存テストの書き方に合わせさせ、見落としやすいエッジケースの洗い出しも頼めます。パスキーなら「どの入力で拒否されるべきか」を先に列挙し、足りない条件を探させるのが、その具体例になります。
まとめ
パスキーの実装をClaude Codeに任せるときの要点は、拒否条件を先にテストにすること、challengeの使い捨てを呼び出し側の責任として明示すること、テストを壊して赤くなるのを一度見ることの3つです。ライブラリが検証してくれるのはoriginとrpIDとsignCountの照合までで、使い捨てと期限は実装する側に残ります。
実際の案件では、パスキーを2要素目として使うのか、パスワードの代わりにするのかで、userVerification の設定と、signCountをどこまで厳しく扱うかが変わります。まずは7本のテストをそのまま置き、自分のサービスの方針に合わせて条件を足すところから始めるのが確実です。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。