t28.dev

パスキーの登録・認証サンプルアプリで WebAuthn のプロセスを見る

2026/5/6に公開
Tech

パスキーの仕様などを読んで無事頭でっかちになったため、パスキーの登録・認証を行うサンプルアプリを作ってみた。

http://github.com/TatsuyaYamamoto/passkeys-sample

ちなみに WebAuthn では登録・認証のプロセスを以下のように命名している。

  • 公開鍵を作成してそれをユーザーに関連付けるプロセス 👉️ Registration Ceremony
  • 登録済みの公開鍵の秘密鍵をユーザーが管理していることを証明するプロセス 👉️ Authentication Ceremony

コンポーネント

登録・認証を実現するのに必要なコンポーネントは4つ

  • バックエンドのアプリケーションがパスキーの登録に必要なデータを作成したり、公開鍵を保存する。
  • フロントエンドのアプリケーションがブラウザと通信したり、バックエンドへ公開鍵を送信する。
  • ブラウザが WebAuthn API を介してフロントエンドと通信したり、CTAP2 を介してパスキープロバイダとやりとりする。
  • パスキープロバイダ (たとえば Google パスワードマネージャー)がパスキーを作成したり、秘密鍵を保存する。

仮に Chrome のシークレットブラウザでパスキーを登録しようとすると、パスキープロバイダへのアクセスを求められる。

バックエンド・フロントエンドは web.dev でも紹介されている SimpleWebAuthn を使って実装する。

ArrayBuffer な値を WebAPI で使える Base64 に変換 (またはその逆) をしてくれたりと、

https://github.com/MasterKale/SimpleWebAuthn/blob/v13.3.0/packages/browser/src/methods/startRegistration.ts#L48

パスキーを登録する (Registration Ceremony)

フロントエンドが PublicKeyCredentialCreationOptions を要求する

Web アプリ上のボタンなどでパスキーの作成を開始する。

PublicKeyCredentialCreationOptions仕様として定義されている型で、 WebAuthn を使って公開鍵を作成するために使う。

このサンプルではこのタイミングで userId を送信しているが、既にログインセッションがあって user が分かっている状態で開始するのが一般的だと思う。

const handleRegistration = async () => {
  const optionsJson = await postJson("/api/webauthn/registration/options", {
    userId,
  });
  // 省略
};

バックエンドが PublicKeyCredentialCreationOptions を返す

バックエンドは userId を元に generateRegistrationOptions()PublicKeyCredentialCreationOptionsJSON を作る。

~JSON になっているのは、関数内部で作成された ArrayBuffer 型のプロパティを持つ PublicKeyCredentialCreationOptionsWebAPI で使える文字列に変換しているから。

challengeWebAuthn の仕様に従ってバックエンドで保存しておく。

app.post("/api/webauthn/registration/options", async (c) => {
  const { userId } = await c.req.json<{ userId: string }>();

  const passKeys = passKeyTable.get(userId) ?? [];

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    // userHandle を固定することで、同じデバイスへの再登録時に古いパスキーが上書きされる
    // https://www.w3.org/TR/webauthn-3/#sctn-user-handle-privacy
    // It is RECOMMENDED to let the user handle be 64 random bytes
    // https://www.w3.org/TR/webauthn-3/#user-handle
    // a user handle is an identifier for a user account, specified by the Relying Party as user.id during registration
    // userID: crypto.getRandomValues(new Uint8Array(64)),
    userName: userId,
    attestationType: "none",
    excludeCredentials: passKeys.map((passkey) => ({
      id: passkey.id,
      transports: passkey.transports,
    })),
    authenticatorSelection: {
      residentKey: "preferred",
      userVerification: "preferred", // デフォルトは "preferred" です。
      authenticatorAttachment: "platform",
    },
    // W3C spec 推奨
    // https://developers.google.com/identity/passkeys/developer-guides/server-authentication?hl=en#create_the_challenge:~:text=The%20recommended%20default%20value%20is%205%20minutes
    // ブラウザが上書きしうる timeout
    // https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-timeout
    timeout: 300_000, // 5分
  });

  // チャレンジをセッションに保存する
  // 参考: https://developers.google.com/identity/passkeys/developer-guides/server-registration?hl=ja#example_code_create_credential_creation_options
  sessionMap.set(userId, {
    challenge: options.challenge,
  });

  return c.json(options);
});

ブラウザがパスキーを作成する

バックエンドから受け取った PublicKeyCredentialCreationOptionsJSONstartRegistration() に渡してパスキーを作成する。

startRegistration は内部で navigator.credentials.create() を呼んでおり、 この API が実際にモーダルダイアログを表示し、ユーザーの操作を待ち、新しいパスキーを作成する。

const handleRegistration = async () => {
  // (省略)
  let registrationResponse;
  try {
    registrationResponse = await startRegistration({
      optionsJSON: optionsJson, // 👈️ サーバーから受け取った options
    });
  } catch (e) {
    // (省略)
  }

  const verifiedResponse = await postJson("/api/webauthn/registration/verify", {
    userId,
    response: registrationResponse,
  });
  if (verifiedResponse.ok) {
    alert("登録成功。");
  }
};

エラーハンドリング

当然いくつかのエラーケースがあり、仕様としても定義されている。 面白い(?)のは InvalidStateError で、これは「デバイスにパスキーが既にある」ことを表現しており、サイトはこれをエラーとして扱うべきではない

try {
  registrationResponse = await startRegistration({ optionsJSON });
} catch (e) {
  if (e instanceof Error) {
    console.error("[browser] registration options error: ", {
      ...e,
      message: e.message,
    });
  }

  if (e instanceof WebAuthnError) {
    if (e.code === "ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED") {
      alert("この端末は登録済みです。");
      return;
    }
  }

  if (e instanceof WebAuthnError) {
    if (e.code === "ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY") {
      alert("キャンセルされました。");
      return;
    }
  }
  return;
}

バックエンドでパスキーを保存する

パスキーで認証する (Authentication Ceremony)

ブラウザが options を取得する

const handleAuthentication = async () => {
  const optionsJson = await postJson("/api/webauthn/authentication/options", {
    userId,
  });
  // (省略)
};

バックエンドが options を返す

ブラウザが送信する

const handleAuthentication = async () => {
  // 省略
  let authenticationResponse;
  try {
    authenticationResponse = await startAuthentication({
      optionsJSON: optionsJson, // 👈️ サーバーから受け取った options
    });
  } catch (e) {
    // (省略)
  }

  const verifiedResponse = await postJson(
    "/api/webauthn/authentication/verify",
    {
      userId,
      response: authenticationResponse,
    },
  );
};

エラーハンドリング

try {
  authenticationResponse = await startAuthentication({ optionsJSON });
} catch (e) {
  if (e instanceof WebAuthnError) {
    console.error("[browser] authentication verify error: ", {
      ...e,
      message: e.message,
    });
  }
  if (e instanceof WebAuthnError) {
    if (e.code === "ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY") {
      alert("キャンセルされました。");
      return;
    }
  }
  return;
}

バックエンドが検証する