本文へスキップ
ecdsa.com
← すべての記事

開発者のためのパスキー:推測に頼らない WebAuthn 実装

パスキーは、ユーザー名もパスワードも入力せずにログインできる WebAuthn の認証情報です。ブラウザーの API は小さくても、それを取り巻く判断は小さくありません。ここでは、2 つのセレモニーの最初から最後までの流れ、サーバーが確認しなければならないこと、Chromium が出すエラーそのものを示します。いずれも仮想認証器に対して実行した結果です。

要点:パスキーは、検出可能な WebAuthn の認証情報です。つまり、認証器(スマートフォン、ノートパソコンのセキュアなハードウェア、パスワードマネージャー、セキュリティキー)が 1 つのサイト、すなわちリライングパーティのために作成する鍵ペアです。登録ではサーバーに公開鍵が送られ、ログインではサーバーのチャレンジに対する署名が送られます。サーバーは、すべてのレスポンスでチャレンジ、オリジン、RP ID、ユーザー検証フラグを確認しなければなりません。residentKey: "required" と userVerification: "required" を要求し、アルゴリズムとして -8、-7、-257 を提示し、認証器のモデルを制限する企業でない限りアテステーションは "none" のままにしてください。また、ほとんどのパスキーはデバイス間で同期されるものと想定してください。

基本モデルを 1 つの表で

用語コードにとっての意味
リライングパーティ(RP)と RP ID自分のサイト。RP ID は example.com のようなドメインで、認証情報はそのドメインとサブドメインでだけ使え、それ以外では決して使えない
検出可能な認証情報ユーザーハンドルとともに認証器に保存されるため、ログインにユーザー名が不要。WebAuthn Level 3 では「パスキー」がその別名として挙げられている
ユーザーハンドル(user.id)自分のサイトの不透明なアカウント ID で、最大 64 バイト、ログイン時に返される。メールアドレスは決して使わない
ユーザー存在確認(UP)とユーザー検証(UV)UP:誰かが触れた、または確認した。UV:認証器が PIN または生体認証を確認した。どちらも認証器データ内のフラグ
バックアップ可能(BE)とバックアップ済み(BS)ほかのデバイスに同期できる、または同期済みの認証情報。つまり同期パスキーで、仕様では「マルチデバイス認証情報」と呼ばれる
アテステーションと AAGUIDどの認証器モデルが鍵を作成したかを示す、任意の証明書チェーン。AAGUID はそのモデルを識別する

認証器データのフラグは、ビット 0 が UP、ビット 2 が UV、ビット 3 が BE、ビット 4 が BS、ビット 6 が AT(アテステーション済み認証情報データを含む)、ビット 7 が ED(拡張)です(WebAuthn Level 3 §6.1)。WebAuthn Level 3 は 2026年8月25日に W3C 勧告になりました。

サーバー:両方のセレモニー

@simplewebauthn/server 14.0.3 を使った、完全なリライングパーティの実装です。コードを短くするためにストレージはメモリ上に置いています。本番環境では 3 つのマップはデータベースのテーブルになり、チャレンジはユーザーのサーバーサイドのセッションに保持されます。

server.mjs(@simplewebauthn/server 14.0.3 を使用)
// Relying party: registration and sign-in ceremonies with @simplewebauthn/server 14.0.3.
import {
  generateAuthenticationOptions,
  generateRegistrationOptions,
  verifyAuthenticationResponse,
  verifyRegistrationResponse,
} from "@simplewebauthn/server";

const rpID = "localhost"; // the site's registrable domain in production, e.g. "example.com"
const origin = "http://localhost:3000"; // every origin allowed to use this RP ID
const users = new Map(); // userName -> { id, credentials: [] }
const credentials = new Map(); // credential ID -> { userName, publicKey, counter, transports }
const pending = new Map(); // ceremony ID -> expected challenge, single use

export async function registrationOptions(userName, { attestation = "none" } = {}) {
  const user = users.get(userName) ?? { id: crypto.getRandomValues(new Uint8Array(16)), credentials: [] };
  users.set(userName, user);
  const options = await generateRegistrationOptions({
    rpName: "Example",
    rpID,
    userName,
    userID: user.id,
    attestationType: attestation,
    authenticatorSelection: { residentKey: "required", userVerification: "required" },
    supportedAlgorithmIDs: [-8, -7, -257], // EdDSA, ES256, RS256
    excludeCredentials: user.credentials.map((id) => ({ id, transports: credentials.get(id).transports })),
  });
  pending.set(`reg:${userName}`, options.challenge);
  return options;
}

export async function finishRegistration(userName, response) {
  const expectedChallenge = pending.get(`reg:${userName}`);
  pending.delete(`reg:${userName}`);
  const { verified, registrationInfo: info } = await verifyRegistrationResponse({
    response,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    requireUserVerification: true,
  });
  if (!verified) throw new Error("registration not verified");
  const { credential } = info;
  credentials.set(credential.id, { userName, publicKey: credential.publicKey, counter: credential.counter, transports: credential.transports });
  users.get(userName).credentials.push(credential.id);
  return info;
}

export async function authenticationOptions() {
  // Empty allowCredentials: the authenticator offers its discoverable credentials (passkeys).
  const options = await generateAuthenticationOptions({ rpID, userVerification: "required" });
  pending.set(`auth:${options.challenge}`, options.challenge);
  return options;
}

export async function finishAuthentication(response, challenge) {
  if (!pending.delete(`auth:${challenge}`)) throw new Error("unknown or used challenge");
  const stored = credentials.get(response.id);
  if (!stored) throw new Error("unknown credential");
  const { verified, authenticationInfo } = await verifyAuthenticationResponse({
    response,
    expectedChallenge: challenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: { id: response.id, publicKey: stored.publicKey, counter: stored.counter, transports: stored.transports },
    requireUserVerification: true,
  });
  if (!verified) throw new Error("authentication not verified");
  stored.counter = authenticationInfo.newCounter;
  return { userName: stored.userName, ...authenticationInfo };
}

2 つの検証関数の呼び出しが確認する内容と、それぞれが重要な理由は次のとおりです。

  • チャレンジ:ランダムかつ 1 回限りで、このセレモニーに結び付いています。古いチャレンジを含むレスポンスはリプレイです。
  • オリジン:ブラウザーはページのオリジンを clientDataJSON に書き込み、認証器はそのハッシュに署名します。これを自分のオリジンのリストと比較することこそが、パスキーにフィッシング耐性をもたらします。本物に似せたサイトが生成するレスポンスには、そのサイト自身のオリジンが入るからです。
  • RP ID のハッシュ:認証器データは RP ID の SHA-256 で始まります。そのため、別のサイト用の認証情報を自分のサイトで使うことはできません。
  • フラグ:UP は必ず設定されていなければなりません。パスキー単独で認証する場合(それこそがパスキーを使う意味です)は、UV も設定されていなければなりません。
  • 署名:認証器データとクライアントデータのハッシュに対する署名を、登録時に保存した公開鍵で検証します。
  • カウンター:多くの同期パスキーは常に 0 を報告します。WebAuthn がカウンターの減少をクローンの可能性として扱うのは、0 以外の値が関係する場合だけです(§6.1.1)。0 を理由に拒否してはいけません。

ブラウザー:2 つの呼び出し

browser.js
// Browser side of both ceremonies. JSON options from the server need their base64url
// fields turned into ArrayBuffers; the static helpers on PublicKeyCredential do that.
async function register(optionsJSON) {
  const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJSON);
  const credential = await navigator.credentials.create({ publicKey });
  return credential.toJSON(); // send to the server as-is
}

async function signIn(optionsJSON) {
  const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);
  const credential = await navigator.credentials.get({ publicKey });
  return credential.toJSON();
}

parseCreationOptionsFromJSON、parseRequestOptionsFromJSON、toJSON は WebAuthn Level 3 の一部で、これまでどのプロジェクトも抱えていた base64url の変換コードに取って代わります。ブラウザーの自動入力でパスキーを提示するユーザー名フィールドを作るには、PublicKeyCredential.isConditionalMediationAvailable() を確認したうえで、ページの読み込み時に mediation: "conditional" を指定して navigator.credentials.get() を開始し、入力欄に autocomplete="username webauthn" を指定します。

エンドツーエンドの実行

実験環境では、Playwright 1.64.1(mcr.microsoft.com/playwright:v1.64.1-noble)で Chromium を操作し、Chrome DevTools Protocol を通じて仮想認証器を追加しました。http://localhost:3000 のページで browser.js を実行し、ドライバーが server.mjs のオプションをページに渡し、レスポンスをページから取り出しました。どの行も、実際に行った登録またはログインから得たものです。

出力(Chromium 156.0.8078.4、Playwright イメージ内の Node.js)
chromium 156.0.8078.4, virtual platform authenticator with user verification
registration options: challenge 43 chars, pubKeyCredParams -8, -7, -257, attestation none
registered: fmt none, COSE alg -8, transports internal, credentialDeviceType singleDevice, backedUp false, userVerified true, aaguid 01020304-0506-0708-0102-030405060708
second registration on the same authenticator: InvalidStateError: The user attempted to register an authenticator that contains one of the credentials already registered with the relying party.
signed in as alice: userVerified true, newCounter 2, credentialBackedUp false
replayed assertion: unknown or used challenge
response created on http://localhost:4000: Unexpected registration response origin "http://localhost:4000", expected "http://localhost:3000"
synced passkey, attestation "direct": fmt packed, credentialDeviceType multiDevice, backedUp true
sign-in when the authenticator cannot verify the user: NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.

各行からわかることは次のとおりです。

  • アルゴリズム:オプションでは -8、-7、-257 を提示し、認証器はそのうち自身が対応している最初のアルゴリズムである -8(EdDSA)を選びました。並び順が優先順位を表します。
  • excludeCredentials は機能する:UI で「このデバイスには、このアカウントのパスキーがすでにあります」と伝えるための InvalidStateError が返されます。
  • ユーザー名なしのログイン:allowCredentials は空でした。認証器は自身の検出可能な認証情報を提示し、サーバーは認証情報 ID と、それに対応して保存されている公開鍵からアカウントを特定しました。
  • リプレイはチャレンジで失敗する:使用時にチャレンジを削除しているからこそ、2 回目の試行が失敗します。
  • ブラウザーは誤ったオリジンをすべて阻止するわけではない:http://localhost:4000 のページでも、RP ID localhost の認証情報を作成できてしまいました。RP ID はそのドメインのすべてのポートとサブドメインを対象とするからです。これを拒否したのはサーバーのオリジンチェックだけでした(Unexpected registration response origin)。expectedOrigin には、オリジンを正確に列挙したリストを指定してください。
  • 同期パスキーは同期されていることを自ら示す:バックアップ可能(BE)を有効にすると、SimpleWebAuthn は multiDevice と backedUp true を報告しました。
  • UV の必須指定はブラウザーが強制する:ユーザーを検証できない認証器では、ブラウザーが汎用的な NotAllowedError で拒否しました。サーバーサイドの requireUserVerification は 2 番目の防衛線です。

パスワードと並行したパスキーの導入

すべてのアカウントを一度に切り替えられるサイトはほとんどありません。WebAuthn Level 3 には段階的な導入のための仕組みが追加されており、いずれも PublicKeyCredential.getClientCapabilities() で対応状況を確認できます。

  • パスワードでのログイン直後にパスキーを提案する:conditionalCreate 機能があれば、mediation: "conditional" を指定した navigator.credentials.create() によって、直前にパスワードを自動入力したパスワードマネージャーが、追加のダイアログなしでパスキーを作成できます。
  • ユーザー名フィールドからログインする:conditionalGet 機能は、前述の自動入力によるフローです。パスキーを持つユーザーはそれを選び、それ以外のユーザーはこれまでどおりパスワードを入力します。
  • 認証器側のリストを自分のリストと同期させる:ユーザーがアカウント設定でパスキーを削除しても、認証器にはまだそれが表示されます。signalUnknownCredential() はある認証情報 ID がもう受け付けられないことを認証器に伝え、signalAllAcceptedCredentials() はログイン中のユーザーの完全なリストを送り、signalCurrentUserDetails() はユーザーがアカウント名を変更した後に表示名を更新します(§5.1.10)。
  • 複数のドメインで 1 つの RP ID:example.com と example.co.uk でログインさせるブランドは、RP ID を 1 つのままにして、ほかのオリジンを https://example.com/.well-known/webauthn に {"origins": [...]} として列挙できます。RP ID がページ自身のドメインでない場合、ブラウザーはそのファイルを取得します(関連オリジンリクエスト、§5.11)。取得に失敗すると、結果は後述の SecurityError になります。
  • 認証情報ごとに BE と BS を記録する:唯一のパスキーがデバイス固定でバックアップもされていないユーザーは、そのデバイスを失うとアクセスも失います。パスワードを廃止する前に、2 つ目のパスキーを追加するよう促してください。

アテステーション:必要になる場面

アテステーションが答えるのは「どの認証器モデルがこの鍵を作成したか」という問いです。ユーザーについても、デバイスのパッチレベルやルート化の有無についても、何も示しません。デフォルトの attestation: "none" では、サーバーが受け取る前に、ブラウザーがアテステーションステートメントを空のものに置き換えます。それでも実験環境では認証器の AAGUID を受け取りました。WebAuthn Level 2 はこの場合に AAGUID を 16 バイトのゼロに置き換えるようブラウザーに求めていましたが、Level 3 ではその手順が削除されました。アテステーションがなければ、AAGUID は自己申告にすぎず、証明にはなりません。

一般消費者向けのサイトでは、アテステーションを必須にしないでください。同期パスキーはユーザーとともにどのデバイスにも移ることを前提としており、そのプロバイダーは一般にアテステーションを提供していません。Google は Android でパスキーの提供を開始した際、パスキーには「当初はアテステーションが一切ない」と述べています(fido-dev メーリングリスト、2022年)。実験環境の仮想認証器は、"direct" を要求されると packed 形式のステートメントを返しました。これはアテステーションを行う認証器が何を送るかを示しますが、特定の同期プロバイダーがどうするかは示しません。そのため、ユーザーが実際に使っているプロバイダーでテストしてください。社内で配布したセキュリティキーなど、登録を特定のモデルに制限しなければならない場合はアテステーションを要求し、FIDO メタデータサービスと照合してチェーンを検証してください。ユーザーにパスキープロバイダーのわかりやすい名前(「Google Password Manager」「1Password」)を表示するには、コミュニティが管理するパスキー認証器の AAGUID リストで AAGUID を名前に対応付けられます。ただし、その README 自体に、表示専用でありセキュリティ上の判断の根拠ではないと書かれています。

COSE アルゴリズム

pubKeyCredParams には COSE アルゴリズム識別子を列挙します(IANA COSE レジストリ)。仕様自身の例は EdDSA、ES256、RS256 を受け入れ、EdDSA を優先しています。このリストで、実際に使われているほぼすべての認証器をカバーできます。

値名前備考
-8EdDSAWebAuthn では Ed25519 のみ(§5.8.5)。RFC 9864 により COSE レジストリで非推奨とされ、-19 Ed25519 が推奨されているが、認証器は今も -8 を出力する
-7ES256ECDSA P-256。最も広くサポートされている。COSE では -9 ESP256 を推奨して非推奨とされており、状況は同じ
-257RS256RSA PKCS#1 v1.5。Windows 10 と一部の Windows 11 環境の Windows Hello は、RS256 の認証情報しか作成しない(W3C メーリングリストでの Yubico の投稿)
-48, -49, -50ML-DSA-44, -65, -87RFC 9964(2026年5月)で登録。Chromium 156 の仮想認証器は -49 を受け入れた(後述)。これらに頼る前に、実際の認証器とサーバーのライブラリで確認すること

-8 を優先する場合でも、-7 と -257 はリストに残してください。pubKeyCredParams が空のときの仕様自体のデフォルトは ES256 と RS256 です。アルゴリズムは認証情報とともに保存してください。SimpleWebAuthn は、保存する COSE 公開鍵の中にアルゴリズムを保持しています。

DOMException のエラー

navigator.credentials.create() と get() は、少数の例外で失敗を報告します。これらは WebAuthn Level 3 の §5.1.3.1 と §5.1.4.3 に列挙されています。検出可能な認証情報の保存機能を持たない仮想 USB セキュリティキーに対して、よくある間違いで Chromium 156 が出した例外は次のとおりです。

出力:errors.mjs(Chromium 156.0.8078.4)
only an unknown algorithm (-65000)           NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
only ML-DSA-65 (-49)                         created, COSE alg -49
residentKey required, key cannot store it    NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
rp.id "example.com" on localhost             SecurityError: The relying party ID is not a registrable domain suffix of, nor equal to the current domain. Subsequently, an attempt to fetch the .well-known/webauthn resource of the claimed RP ID failed.
user.id of 65 bytes                          TypeError: User handle exceeds 64 bytes.
aborted with AbortController                 AbortError: signal is aborted without reason
例外仕様上の意味対処
NotAllowedError包括的なエラー:ユーザーによるキャンセル、タイムアウト、一致する認証情報なし、どの認証器も満たせない要件「完了しなかった」として扱い、再試行と別の方法を提示する。生のメッセージは決して表示しない
InvalidStateErrorexcludeCredentials のエントリが認識されたこのデバイスにはこのサイトのパスキーがすでにあることをユーザーに伝える
SecurityErrorRP ID がページのドメインでもその登録可能なサフィックスでもない、または関連オリジンの検証に失敗した設定を修正する。SecurityError のページを参照
NotSupportedErrorpubKeyCredParams に使えるアルゴリズムがない-7 と -257 を提示する
ConstraintErrorresidentKey または userVerification が必須なのに、対応できる認証器がない実際には NotAllowedError と同じ扱い
AbortError自分の AbortController が呼び出しをキャンセルした自動入力からモーダルなプロンプトに切り替えるときには想定どおりの動作
TypeError不正な形式のオプション(例:64 バイトを超えるユーザーハンドル)自分のオプションのバグ

実験環境の結果のうち 2 つは、仕様のリストと異なります。唯一のアルゴリズムが未知のものだったとき、Chromium は NotSupportedError ではなく NotAllowedError を返しました。また、検出可能な認証情報を保存できないセキュリティキーに検出可能な認証情報を要求したときも、ConstraintError ではなく NotAllowedError を返しました。ユーザーに問題を説明するために NotSupportedError や ConstraintError で分岐するコードは、これらのケースでは、Chromium 上でそれらの例外を受け取ることはありません。どちらの API もセキュアコンテキストにしか存在しません。localhost 以外の平文 HTTP のページでは、Chromium 156 は isSecureContext: false を報告し、navigator.credentials、PublicKeyCredential、crypto.subtle はすべて undefined でした。そのため、セレモニーが始まる前にコードが TypeError で失敗します(Web Crypto 版は crypto.subtle is undefined)。パスキーを使うページはすべて HTTPS で配信してください。唯一の例外は開発用の http://localhost です。

チェックリスト

  1. RP ID はユーザーが目にする登録可能ドメインに設定し、expectedOrigin にはログインに使うすべてのサブドメインを含めて HTTPS オリジンを明示的に列挙
  2. residentKey: "required" と userVerification: "required" を指定し、サーバーはすべてのレスポンスで UV を確認
  3. pubKeyCredParams に -8、-7、-257 を指定し、excludeCredentials にはユーザーの既存の認証情報を設定
  4. チャレンジはランダム、1 回限り、短い有効期間で、サーバーサイドに保存
  5. 認証情報は ID、公開鍵、カウンター、トランスポート、BE と BS のフラグ、AAGUID、ユーザーが編集できる名前とともに保存し、ユーザーによる複数の登録と削除に対応
  6. アテステーションは、認証器のモデルを制限しない限り "none"、制限する場合は FIDO MDS と照合して検証
  7. パスキーそのものより弱くないアカウント復旧手段(2 つ目のパスキーや検証済みの復旧経路。すべてを迂回できる SMS コードは不可)

出典