WebAuthn FIDO2実装実践:登録からパスワードレス認証まで6つのプロダクションパターン

安全

パスワードは終わった、Passkeyが未来だ

2026年、パスワード漏洩事件は毎月数億件のペースで増え続けている。フィッシング攻撃は従来のMFAを回避し、SMS OTPはSIMスワップ攻撃で傍受され、TOTPトークンユーザーは「MFA疲労攻撃」で盲目的に承認ボタンを押す。Passkey(パスキー) はFIDO2/WebAuthn標準に基づき、公開鍵暗号+デバイス生体認証でパスワードを完全に排除——Apple、Google、Microsoftの3大エコシステムが完全に相互運用可能、ブラウザカバレッジは96%を超える。

本記事では6つのプロダクションレベルパターンを紹介する:Passkey登録から認証フロー、クレデンシャル管理からマルチデバイスサポート、フォールバック戦略から完全なPython+JavaScriptプロダクションサービスまで、すべてのコードはそのまま実行可能。


WebAuthn/FIDO2コア概念

概念 説明
WebAuthn W3C標準、ブラウザが提供するnavigator.credentials API、登録と認証のJavaScriptインターフェースを定義
FIDO2 FIDO Alliance標準、CTAP2プロトコルを含み、オーセンティケータとサーバーの通信を規定
Passkey クロスデバイス同期可能なFIDO2クレデンシャル、iCloud Keychain/Google Password Managerに保存
Authenticator 認証デバイス、プラットフォーム認証器(TouchID/FaceID/Windows Hello)とローミング認証器(YubiKey)に分類
Relying Party (RP) リライングパーティ、あなたのウェブサイト/サーバー、rpIDで識別
Credential クレデンシャル、公開鍵暗号の鍵ペア、秘密鍵はオーセンティケータのセキュアチップから絶対に出ない
Attestation アテステーション、登録時にオーセンティケータがRPに正当性を証明する方法(noneも選択可能)
Assertion アサーション、認証時にオーセンティケータが秘密鍵でchallengeに署名して返す署名証明

問題分析:WebAuthnプロダクション導入の5つの課題

  1. 登録フローの複雑さ:サーバーは正しいPublicKeyCredentialCreationOptionsを生成する必要がある。COSEアルゴリズム識別子、authenticatorSelectionパラメータの設定ミスが頻発
  2. 認証フローのセキュリティ:challengeは真の乱数で使い捨て必須、署名検証にはCBOR/COSEエンコーディング処理が必要、一つでもチェックを漏らすとセキュリティホールに
  3. クレデンシャルライフサイクル管理:ユーザーは複数のPasskeyを登録する可能性、一覧表示・削除・リネームのサポート、全デバイス紛失時のリカバリ機構が必要
  4. クロスプラットフォームとマルチデバイス:iCloud PasskeyとGoogle Passkeyは相互運用不可、エンタープライズシナリオではセキュリティキー(YubiKey)対応が必要、Conditional UI統合が複雑
  5. フォールバックとダウングレード:レガシーデバイスはWebAuthn非対応、パスワードフォールバックは必須、ハイブリッド認証フローのUX設計が困難

6つのプロダクションパターン実践

Pattern 1:Passkey登録フロー

サーバー(Python + py_webauthn)

from py_webauthn import WebAuthnRP, WebAuthnUser, WebAuthnCredential
from py_webauthn.helpers.structs import (
    AuthenticatorSelectionCriteria,
    UserVerificationRequirement,
    AuthenticatorAttachment,
    ResidentKeyRequirement,
    PublicKeyCredentialDescriptor,
    AttestationConveyancePreference,
)
import secrets
import base64

class PasskeyRegistrationService:
    def __init__(self, rp_id: str, rp_name: str, origin: str):
        self.rp_id = rp_id
        self.rp_name = rp_name
        self.origin = origin

    def generate_registration_options(
        self,
        user_id: str,
        username: str,
        display_name: str,
        exclude_credentials: list[dict] | None = None,
    ) -> dict:
        challenge = secrets.token_bytes(32)
        challenge_b64 = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode()

        exclude_list = []
        if exclude_credentials:
            for cred in exclude_credentials:
                exclude_list.append(
                    PublicKeyCredentialDescriptor(
                        id=base64.urlsafe_b64decode(cred["id"] + "=="),
                        type="public-key",
                    )
                )

        options = {
            "challenge": challenge_b64,
            "rp": {"id": self.rp_id, "name": self.rp_name},
            "user": {
                "id": base64.urlsafe_b64encode(user_id.encode()).rstrip(b"=").decode(),
                "name": username,
                "displayName": display_name,
            },
            "pubKeyCredParams": [
                {"type": "public-key", "alg": -7},
                {"type": "public-key", "alg": -257},
            ],
            "timeout": 60000,
            "excludeCredentials": [
                {
                    "type": ec.type,
                    "id": base64.urlsafe_b64encode(ec.id).rstrip(b"=").decode(),
                }
                for ec in exclude_list
            ],
            "authenticatorSelection": {
                "authenticatorAttachment": "platform",
                "requireResidentKey": True,
                "residentKey": "required",
                "userVerification": "required",
            },
            "attestation": "none",
        }

        return options, challenge_b64

    def verify_registration(
        self,
        challenge_b64: str,
        credential_response: dict,
        expected_origin: str,
    ) -> dict:
        from py_webauthn import verify_registration_response

        verification = verify_registration_response(
            credential=credential_response,
            expected_challenge=base64.urlsafe_b64encode(
                base64.urlsafe_b64decode(challenge_b64 + "==")
            ).rstrip(b"=").decode(),
            expected_origin=expected_origin,
            expected_rp_id=self.rp_id,
        )

        return {
            "credential_id": base64.urlsafe_b64encode(
                verification.credential_id
            ).rstrip(b"=").decode(),
            "public_key": base64.urlsafe_b64encode(
                verification.credential_public_key
            ).rstrip(b"=").decode(),
            "sign_count": verification.sign_count,
        }

クライアント(JavaScript)

async function registerPasskey(userId, username, displayName) {
  const excludeCredentials = await getExistingCredentials(userId);

  const optionsResponse = await fetch("/api/webauthn/registration/options", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      userId,
      username,
      displayName,
      excludeCredentials,
    }),
  });

  const { options, challenge } = await optionsResponse.json();

  const publicKeyCredentialCreationOptions = {
    ...options,
    challenge: base64urlDecode(options.challenge),
    user: {
      ...options.user,
      id: base64urlDecode(options.user.id),
    },
    excludeCredentials: options.excludeCredentials.map((ec) => ({
      ...ec,
      id: base64urlDecode(ec.id),
    })),
  };

  try {
    const credential = await navigator.credentials.create({
      publicKey: publicKeyCredentialCreationOptions,
    });

    const verificationPayload = {
      id: credential.id,
      rawId: base64urlEncode(credential.rawId),
      type: credential.type,
      response: {
        attestationObject: base64urlEncode(
          credential.response.attestationObject
        ),
        clientDataJSON: base64urlEncode(credential.response.clientDataJSON),
      },
    };

    const verifyResponse = await fetch("/api/webauthn/registration/verify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        challenge,
        credential: verificationPayload,
      }),
    });

    const result = await verifyResponse.json();
    if (result.success) {
      showToast("Passkeyの登録に成功しました!");
    }
  } catch (error) {
    if (error.name === "InvalidStateError") {
      showToast("このデバイスには既にPasskeyが登録されています");
    } else if (error.name === "NotAllowedError") {
      showToast("登録操作がキャンセルされました");
    } else {
      showToast(`登録失敗:${error.message}`);
    }
  }
}

function base64urlDecode(str) {
  const padding = "=".repeat((4 - (str.length % 4)) % 4);
  const base64 = str.replace(/-/g, "+").replace(/_/g, "/") + padding;
  const binaryStr = atob(base64);
  return Uint8Array.from(binaryStr, (c) => c.charCodeAt(0));
}

function base64urlEncode(buffer) {
  const bytes = new Uint8Array(buffer);
  let binary = "";
  bytes.forEach((b) => (binary += String.fromCharCode(b)));
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}

Pattern 2:認証フロー(WebAuthn API)

サーバー

class PasskeyAuthService:
    def __init__(self, rp_id: str, origin: str):
        self.rp_id = rp_id
        self.origin = origin

    def generate_authentication_options(
        self,
        allow_credentials: list[dict] | None = None,
    ) -> dict:
        challenge = secrets.token_bytes(32)
        challenge_b64 = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode()

        allow_list = []
        if allow_credentials:
            for cred in allow_credentials:
                allow_list.append(
                    PublicKeyCredentialDescriptor(
                        id=base64.urlsafe_b64decode(cred["id"] + "=="),
                        type="public-key",
                    )
                )

        options = {
            "challenge": challenge_b64,
            "rpId": self.rp_id,
            "timeout": 60000,
            "allowCredentials": [
                {
                    "type": ac.type,
                    "id": base64.urlsafe_b64encode(ac.id).rstrip(b"=").decode(),
                }
                for ac in allow_list
            ],
            "userVerification": "required",
        }

        return options, challenge_b64

    def verify_authentication(
        self,
        challenge_b64: str,
        credential_response: dict,
        stored_credential: dict,
    ) -> bool:
        from py_webauthn import verify_authentication_response

        verification = verify_authentication_response(
            credential=credential_response,
            expected_challenge=base64.urlsafe_b64encode(
                base64.urlsafe_b64decode(challenge_b64 + "==")
            ).rstrip(b"=").decode(),
            expected_origin=self.origin,
            expected_rp_id=self.rp_id,
            credential_public_key=base64.urlsafe_b64decode(
                stored_credential["public_key"] + "=="
            ),
            credential_current_sign_count=stored_credential["sign_count"],
        )

        return verification.new_sign_count

クライアント

async function authenticateWithPasskey() {
  const optionsResponse = await fetch("/api/webauthn/authentication/options", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
  });

  const { options, challenge } = await optionsResponse.json();

  const publicKeyCredentialRequestOptions = {
    ...options,
    challenge: base64urlDecode(options.challenge),
    allowCredentials: options.allowCredentials?.map((ac) => ({
      ...ac,
      id: base64urlDecode(ac.id),
    })),
  };

  try {
    const assertion = await navigator.credentials.get({
      publicKey: publicKeyCredentialRequestOptions,
    });

    const verificationPayload = {
      id: assertion.id,
      rawId: base64urlEncode(assertion.rawId),
      type: assertion.type,
      response: {
        authenticatorData: base64urlEncode(
          assertion.response.authenticatorData
        ),
        clientDataJSON: base64urlEncode(assertion.response.clientDataJSON),
        signature: base64urlEncode(assertion.response.signature),
        userHandle: assertion.response.userHandle
          ? base64urlEncode(assertion.response.userHandle)
          : null,
      },
    };

    const verifyResponse = await fetch(
      "/api/webauthn/authentication/verify",
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          challenge,
          credential: verificationPayload,
        }),
      }
    );

    const result = await verifyResponse.json();
    if (result.success) {
      window.location.href = "/dashboard";
    }
  } catch (error) {
    if (error.name === "NotAllowedError") {
      showToast("認証がキャンセルされました");
    } else {
      showToast(`認証失敗:${error.message}`);
    }
  }
}

Conditional UI(オートフィル)モード

async function setupConditionalUI() {
  if (!window.PublicKeyCredential) return;

  const available = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
  if (!available) return;

  const optionsResponse = await fetch(
    "/api/webauthn/authentication/options",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ conditionalUI: true }),
    }
  );

  const { options, challenge } = await optionsResponse.json();

  const publicKeyCredentialRequestOptions = {
    ...options,
    challenge: base64urlDecode(options.challenge),
    mediation: "conditional",
  };

  try {
    const assertion = await navigator.credentials.get({
      publicKey: publicKeyCredentialRequestOptions,
    });
    // 認証結果の処理、上記と同じ
  } catch (error) {
    // Conditional UIモードでユーザーがPasskeyを選択しないのは正常な動作、サイレントに処理
  }
}

// ページ読み込み時に自動セットアップ
document.addEventListener("DOMContentLoaded", () => {
  const usernameInput = document.getElementById("username");
  if (usernameInput) {
    usernameInput.setAttribute("autocomplete", "username webauthn");
    setupConditionalUI();
  }
});

Pattern 3:クレデンシャル管理(一覧、削除、更新)

サーバー

from dataclasses import dataclass
from datetime import datetime

@dataclass
class PasskeyCredential:
    credential_id: str
    user_id: str
    public_key: str
    sign_count: int
    name: str
    device_type: str
    created_at: datetime
    last_used_at: datetime | None
    transports: list[str]

class CredentialManagementService:
    def __init__(self, db_session):
        self.db = db_session

    def list_credentials(self, user_id: str) -> list[dict]:
        credentials = self.db.query(PasskeyCredential).filter(
            PasskeyCredential.user_id == user_id
        ).order_by(PasskeyCredential.created_at.desc()).all()

        return [
            {
                "id": cred.credential_id,
                "name": cred.name or f"Passkey #{idx + 1}",
                "deviceType": cred.device_type,
                "createdAt": cred.created_at.isoformat(),
                "lastUsedAt": cred.last_used_at.isoformat() if cred.last_used_at else None,
                "transports": cred.transports,
            }
            for idx, cred in enumerate(credentials)
        ]

    def delete_credential(self, user_id: str, credential_id: str) -> bool:
        cred = self.db.query(PasskeyCredential).filter(
            PasskeyCredential.user_id == user_id,
            PasskeyCredential.credential_id == credential_id,
        ).first()

        if not cred:
            return False

        remaining_count = self.db.query(PasskeyCredential).filter(
            PasskeyCredential.user_id == user_id
        ).count()

        if remaining_count <= 1:
            raise ValueError("最後のPasskeyは削除できません。先にバックアップクレデンシャルを登録してください")

        self.db.delete(cred)
        self.db.commit()
        return True

    def rename_credential(self, user_id: str, credential_id: str, new_name: str) -> bool:
        cred = self.db.query(PasskeyCredential).filter(
            PasskeyCredential.user_id == user_id,
            PasskeyCredential.credential_id == credential_id,
        ).first()

        if not cred:
            return False

        cred.name = new_name[:64]
        self.db.commit()
        return True

    def update_sign_count(self, credential_id: str, new_sign_count: int) -> None:
        cred = self.db.query(PasskeyCredential).filter(
            PasskeyCredential.credential_id == credential_id
        ).first()

        if cred:
            cred.sign_count = new_sign_count
            cred.last_used_at = datetime.utcnow()
            self.db.commit()

クライアント

class PasskeyManager {
  constructor(userId) {
    this.userId = userId;
  }

  async listCredentials() {
    const response = await fetch(`/api/webauthn/credentials?userId=${this.userId}`);
    return response.json();
  }

  async deleteCredential(credentialId) {
    const confirmed = confirm("このPasskeyを削除しますか?元に戻せません。");
    if (!confirmed) return false;

    const response = await fetch("/api/webauthn/credentials/delete", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        userId: this.userId,
        credentialId,
      }),
    });

    const result = await response.json();
    if (result.success) {
      this.renderCredentialList();
    }
    return result.success;
  }

  async renameCredential(credentialId, newName) {
    const response = await fetch("/api/webauthn/credentials/rename", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        userId: this.userId,
        credentialId,
        newName,
      }),
    });

    return (await response.json()).success;
  }

  async renderCredentialList() {
    const credentials = await this.listCredentials();
    const container = document.getElementById("passkey-list");

    container.innerHTML = credentials
      .map(
        (cred) => `
      <div class="passkey-item" data-id="${cred.id}">
        <div class="passkey-info">
          <span class="passkey-name">${cred.name}</span>
          <span class="passkey-device">${cred.deviceType}</span>
          <span class="passkey-date">
            作成日 ${new Date(cred.createdAt).toLocaleDateString()}
            ${cred.lastUsedAt ? `· 最終使用 ${new Date(cred.lastUsedAt).toLocaleDateString()}` : ""}
          </span>
        </div>
        <div class="passkey-actions">
          <button onclick="passkeyManager.renameCredential('${cred.id}', prompt('新しい名前:', '${cred.name}'))">
            名前変更
          </button>
          <button onclick="passkeyManager.deleteCredential('${cred.id}')">
            削除
          </button>
        </div>
      </div>
    `
      )
      .join("");
  }
}

Pattern 4:マルチデバイス・クロスプラットフォームサポート

サーバー

class MultiDevicePasskeyService:
    SUPPORTED_ALGORITHMS = [-7, -257, -37]

    def generate_inclusive_registration_options(
        self,
        user_id: str,
        username: str,
        display_name: str,
        existing_credentials: list[dict],
    ) -> dict:
        challenge = secrets.token_bytes(32)
        challenge_b64 = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode()

        exclude_list = [
            {
                "type": "public-key",
                "id": cred["credential_id"],
            }
            for cred in existing_credentials
        ]

        options = {
            "challenge": challenge_b64,
            "rp": {"id": self.rp_id, "name": self.rp_name},
            "user": {
                "id": base64.urlsafe_b64encode(user_id.encode()).rstrip(b"=").decode(),
                "name": username,
                "displayName": display_name,
            },
            "pubKeyCredParams": [
                {"type": "public-key", "alg": alg}
                for alg in self.SUPPORTED_ALGORITHMS
            ],
            "timeout": 120000,
            "excludeCredentials": exclude_list,
            "authenticatorSelection": {
                "authenticatorAttachment": "platform",
                "requireResidentKey": True,
                "residentKey": "required",
                "userVerification": "preferred",
            },
            "attestation": "none",
        }

        return options, challenge_b64

    def generate_security_key_options(
        self,
        user_id: str,
        username: str,
        display_name: str,
    ) -> dict:
        challenge = secrets.token_bytes(32)
        challenge_b64 = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode()

        options = {
            "challenge": challenge_b64,
            "rp": {"id": self.rp_id, "name": self.rp_name},
            "user": {
                "id": base64.urlsafe_b64encode(user_id.encode()).rstrip(b"=").decode(),
                "name": username,
                "displayName": display_name,
            },
            "pubKeyCredParams": [
                {"type": "public-key", "alg": -7},
                {"type": "public-key", "alg": -257},
            ],
            "timeout": 120000,
            "authenticatorSelection": {
                "authenticatorAttachment": "cross-platform",
                "requireResidentKey": False,
                "residentKey": "discouraged",
                "userVerification": "preferred",
            },
            "attestation": "direct",
        }

        return options, challenge_b64

クライアント

async function registerPlatformPasskey(userId, username, displayName) {
  const existingCreds = await getExistingCredentials(userId);
  const { options, challenge } = await fetchRegistrationOptions({
    userId,
    username,
    displayName,
    existingCredentials: existingCreds,
    type: "platform",
  });

  return executeRegistration(options, challenge);
}

async function registerSecurityKey(userId, username, displayName) {
  const { options, challenge } = await fetchRegistrationOptions({
    userId,
    username,
    displayName,
    type: "cross-platform",
  });

  return executeRegistration(options, challenge);
}

async function detectPasskeySupport() {
  if (!window.PublicKeyCredential) {
    return {
      supported: false,
      platformAuthenticator: false,
      conditionalUI: false,
    };
  }

  const platformAuth = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();

  let conditionalUI = false;
  if (platformAuth && PublicKeyCredential.isConditionalMediationAvailable) {
    conditionalUI = await PublicKeyCredential.isConditionalMediationAvailable();
  }

  return {
    supported: true,
    platformAuthenticator: platformAuth,
    conditionalUI,
  };
}

async function renderRegistrationUI(userId, username, displayName) {
  const support = await detectPasskeySupport();

  const container = document.getElementById("passkey-registration");

  if (support.platformAuthenticator) {
    container.innerHTML += `
      <button onclick="registerPlatformPasskey('${userId}', '${username}', '${displayName}')">
        Passkeyを登録(デバイス生体認証)
      </button>
    `;
  }

  container.innerHTML += `
    <button onclick="registerSecurityKey('${userId}', '${username}', '${displayName}')">
      セキュリティキーを登録(YubiKey等)
    </button>
  `;

  if (!support.supported) {
    container.innerHTML = `
      <p>お使いのブラウザはWebAuthnに対応していません。パスワードでログインしてください。</p>
    `;
  }
}

Pattern 5:フォールバック戦略(パスワード+Passkeyハイブリッド)

サーバー

class HybridAuthService:
    def __init__(self, db_session, rp_id: str, origin: str):
        self.db = db_session
        self.rp_id = rp_id
        self.origin = origin

    def get_login_options(self, user_id: str | None = None) -> dict:
        if user_id:
            credentials = self._get_user_credentials(user_id)
            has_passkey = len(credentials) > 0

            if has_passkey:
                auth_options, challenge = PasskeyAuthService(
                    self.rp_id, self.origin
                ).generate_authentication_options(
                    allow_credentials=[
                        {"id": c.credential_id} for c in credentials
                    ]
                )
                return {
                    "mode": "passkey_preferred",
                    "passkeyOptions": auth_options,
                    "challenge": challenge,
                    "passwordFallback": True,
                }

        return {
            "mode": "password_only",
            "passkeyOptions": None,
            "challenge": None,
            "passwordFallback": True,
        }

    def promote_to_passkey(self, user_id: str) -> dict:
        user = self._get_user(user_id)
        existing_creds = self._get_user_credentials(user_id)

        reg_options, challenge = PasskeyRegistrationService(
            self.rp_id, "MyApp", self.origin
        ).generate_registration_options(
            user_id=user_id,
            username=user.username,
            display_name=user.display_name,
            exclude_credentials=[
                {"id": c.credential_id} for c in existing_creds
            ],
        )

        return {
            "registrationOptions": reg_options,
            "challenge": challenge,
            "message": "アカウントをアップグレード:より安全なパスワードレスログインのためにPasskeyを登録しましょう",
        }

クライアント

class HybridLoginFlow {
  constructor() {
    this.step = "initial";
  }

  async init() {
    const support = await detectPasskeySupport();

    if (support.conditionalUI) {
      this.setupAutofillPasskey();
    }

    this.renderLoginForm(support);
  }

  renderLoginForm(support) {
    const form = document.getElementById("login-form");

    form.innerHTML = `
      <input type="text" id="username" name="username"
        autocomplete="username webauthn"
        placeholder="ユーザー名" required />

      <div id="passkey-section" style="display:none">
        <button type="button" id="passkey-login-btn">
          Passkeyでログイン
        </button>
        <p class="divider-text">またはパスワードを使用</p>
      </div>

      <div id="password-section">
        <input type="password" id="password" name="password"
          autocomplete="current-password"
          placeholder="パスワード" />
        <button type="submit">ログイン</button>
      </div>
    `;

    document.getElementById("username").addEventListener("blur", async () => {
      const username = document.getElementById("username").value;
      if (!username) return;

      const check = await fetch("/api/auth/check", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ username }),
      });

      const { hasPasskey } = await check.json();
      if (hasPasskey && support.platformAuthenticator) {
        document.getElementById("passkey-section").style.display = "block";
      }
    });

    document.getElementById("passkey-login-btn").addEventListener(
      "click",
      () => this.authenticateWithPasskey()
    );

    form.addEventListener("submit", (e) => {
      e.preventDefault();
      this.authenticateWithPassword();
    });
  }

  async setupAutofillPasskey() {
    const { options, challenge } = await fetch(
      "/api/webauthn/authentication/options",
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
      }
    ).then((r) => r.json());

    try {
      const assertion = await navigator.credentials.get({
        publicKey: {
          ...options,
          challenge: base64urlDecode(options.challenge),
          mediation: "conditional",
        },
      });

      await this.verifyAssertion(assertion, challenge);
    } catch {
      // ユーザーがPasskeyを選択しなかった、サイレントに処理
    }
  }

  async authenticateWithPassword() {
    const username = document.getElementById("username").value;
    const password = document.getElementById("password").value;

    const response = await fetch("/api/auth/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ username, password }),
    });

    const result = await response.json();
    if (result.success) {
      if (result.suggestPasskeyUpgrade) {
        this.showPasskeyUpgradePrompt(result.userId);
      } else {
        window.location.href = "/dashboard";
      }
    }
  }

  showPasskeyUpgradePrompt(userId) {
    const upgrade = confirm(
      "お使いのデバイスはPasskeyに対応しています。より安全なパスワードレスログインのために今すぐ登録しますか?"
    );
    if (upgrade) {
      registerPlatformPasskey(userId);
    } else {
      window.location.href = "/dashboard";
    }
  }
}

Pattern 6:プロダクション認証サービス(Python + JavaScript)

サーバー(FastAPI + py_webauthn)

from fastapi import FastAPI, HTTPException, Depends, Request
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import secrets
import base64
from typing import Optional

app = FastAPI(title="WebAuthn Production Auth Service")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://yourapp.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

challenge_store: dict[str, dict] = {}
session_store: dict[str, dict] = {}

class RegistrationOptionsRequest(BaseModel):
    userId: str
    username: str
    displayName: str
    excludeCredentials: Optional[list[dict]] = None

class RegistrationVerifyRequest(BaseModel):
    challenge: str
    credential: dict

class AuthenticationOptionsRequest(BaseModel):
    userId: Optional[str] = None
    conditionalUI: Optional[bool] = False

class AuthenticationVerifyRequest(BaseModel):
    challenge: str
    credential: dict

RP_ID = "yourapp.com"
RP_NAME = "MyApp"
ORIGIN = "https://yourapp.com"

registration_service = PasskeyRegistrationService(RP_ID, RP_NAME, ORIGIN)
auth_service = PasskeyAuthService(RP_ID, ORIGIN)

@app.post("/api/webauthn/registration/options")
async def create_registration_options(req: RegistrationOptionsRequest):
    options, challenge = registration_service.generate_registration_options(
        user_id=req.userId,
        username=req.username,
        display_name=req.displayName,
        exclude_credentials=req.excludeCredentials,
    )

    challenge_store[challenge] = {
        "userId": req.userId,
        "type": "registration",
        "expiresAt": secrets.token_hex(8),
    }

    return {"options": options, "challenge": challenge}

@app.post("/api/webauthn/registration/verify")
async def verify_registration_endpoint(req: RegistrationVerifyRequest):
    stored = challenge_store.get(req.challenge)
    if not stored:
        raise HTTPException(status_code=400, detail="無効または期限切れのchallenge")

    del challenge_store[req.challenge]

    try:
        result = registration_service.verify_registration(
            challenge_b64=req.challenge,
            credential_response=req.credential,
            expected_origin=ORIGIN,
        )

        return {"success": True, "credentialId": result["credential_id"]}
    except Exception as e:
        raise HTTPException(status_code=400, detail=str(e))

@app.post("/api/webauthn/authentication/options")
async def create_authentication_options(req: AuthenticationOptionsRequest):
    allow_credentials = None
    if req.userId:
        pass

    options, challenge = auth_service.generate_authentication_options(
        allow_credentials=allow_credentials,
    )

    challenge_store[challenge] = {
        "userId": req.userId,
        "type": "authentication",
    }

    return {"options": options, "challenge": challenge}

@app.post("/api/webauthn/authentication/verify")
async def verify_authentication_endpoint(req: AuthenticationVerifyRequest):
    stored = challenge_store.get(req.challenge)
    if not stored:
        raise HTTPException(status_code=400, detail="無効または期限切れのchallenge")

    del challenge_store[req.challenge]

    try:
        new_sign_count = auth_service.verify_authentication(
            challenge_b64=req.challenge,
            credential_response=req.credential,
            stored_credential={},
        )

        session_token = secrets.token_urlsafe(32)
        session_store[session_token] = {
            "userId": stored["userId"],
            "authenticatedAt": __import__("datetime").datetime.utcnow().isoformat(),
        }

        return {
            "success": True,
            "sessionToken": session_token,
        }
    except Exception as e:
        raise HTTPException(status_code=401, detail=str(e))

@app.get("/api/webauthn/credentials")
async def list_credentials(userId: str):
    return {"credentials": []}

@app.post("/api/webauthn/credentials/delete")
async def delete_credential(request: Request):
    data = await request.json()
    return {"success": True}

@app.post("/api/webauthn/credentials/rename")
async def rename_credential(request: Request):
    data = await request.json()
    return {"success": True}

クライアント完全統合

class WebAuthnClient {
  constructor(baseUrl = "") {
    this.baseUrl = baseUrl;
  }

  async isSupported() {
    if (!window.PublicKeyCredential) return false;
    return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
  }

  async register(userId, username, displayName) {
    const existingCreds = await this._getExistingCredentials(userId);

    const { options, challenge } = await this._fetch("/api/webauthn/registration/options", {
      userId,
      username,
      displayName,
      excludeCredentials: existingCreds,
    });

    try {
      const credential = await navigator.credentials.create({
        publicKey: this._decodeRegistrationOptions(options),
      });

      const result = await this._fetch("/api/webauthn/registration/verify", {
        challenge,
        credential: this._encodeCredential(credential),
      });

      return { success: true, credentialId: result.credentialId };
    } catch (error) {
      return { success: false, error: error.name, message: error.message };
    }
  }

  async authenticate(userId = null) {
    const { options, challenge } = await this._fetch(
      "/api/webauthn/authentication/options",
      { userId, conditionalUI: false }
    );

    try {
      const assertion = await navigator.credentials.get({
        publicKey: this._decodeAuthenticationOptions(options),
      });

      const result = await this._fetch("/api/webauthn/authentication/verify", {
        challenge,
        credential: this._encodeAssertion(assertion),
      });

      return { success: true, sessionToken: result.sessionToken };
    } catch (error) {
      return { success: false, error: error.name, message: error.message };
    }
  }

  async listCredentials(userId) {
    const response = await fetch(
      `${this.baseUrl}/api/webauthn/credentials?userId=${userId}`
    );
    return response.json();
  }

  async deleteCredential(userId, credentialId) {
    return this._fetch("/api/webauthn/credentials/delete", {
      userId,
      credentialId,
    });
  }

  async renameCredential(userId, credentialId, newName) {
    return this._fetch("/api/webauthn/credentials/rename", {
      userId,
      credentialId,
      newName,
    });
  }

  _decodeRegistrationOptions(options) {
    return {
      ...options,
      challenge: base64urlDecode(options.challenge),
      user: {
        ...options.user,
        id: base64urlDecode(options.user.id),
      },
      excludeCredentials: (options.excludeCredentials || []).map((ec) => ({
        ...ec,
        id: base64urlDecode(ec.id),
      })),
    };
  }

  _decodeAuthenticationOptions(options) {
    return {
      ...options,
      challenge: base64urlDecode(options.challenge),
      allowCredentials: (options.allowCredentials || []).map((ac) => ({
        ...ac,
        id: base64urlDecode(ac.id),
      })),
    };
  }

  _encodeCredential(credential) {
    return {
      id: credential.id,
      rawId: base64urlEncode(credential.rawId),
      type: credential.type,
      response: {
        attestationObject: base64urlEncode(credential.response.attestationObject),
        clientDataJSON: base64urlEncode(credential.response.clientDataJSON),
      },
    };
  }

  _encodeAssertion(assertion) {
    return {
      id: assertion.id,
      rawId: base64urlEncode(assertion.rawId),
      type: assertion.type,
      response: {
        authenticatorData: base64urlEncode(assertion.response.authenticatorData),
        clientDataJSON: base64urlEncode(assertion.response.clientDataJSON),
        signature: base64urlEncode(assertion.response.signature),
        userHandle: assertion.response.userHandle
          ? base64urlEncode(assertion.response.userHandle)
          : null,
      },
    };
  }

  async _fetch(path, body) {
    const response = await fetch(`${this.baseUrl}${path}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.detail || "リクエスト失敗");
    }

    return response.json();
  }

  async _getExistingCredentials(userId) {
    try {
      const { credentials } = await this.listCredentials(userId);
      return credentials.map((c) => ({ id: c.id }));
    } catch {
      return [];
    }
  }
}

const webauthn = new WebAuthnClient();

5つの落とし穴ガイド

落とし穴1:challengeの検証なし・再利用

❌ 誤り:

CHALLENGE = "fixed-challenge-value-12345"

def verify_registration(credential_response):
    verification = verify_registration_response(
        credential=credential_response,
        expected_challenge=CHALLENGE,
        expected_origin=ORIGIN,
        expected_rp_id=RP_ID,
    )
const CHALLENGE = "fixed-challenge-value-12345";
const options = { challenge: CHALLENGE, ... };

✅ 正解:

import secrets

def generate_challenge() -> str:
    challenge = secrets.token_bytes(32)
    challenge_b64 = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode()
    challenge_store[challenge_b64] = {
        "createdAt": datetime.utcnow(),
        "used": False,
    }
    return challenge_b64

def verify_challenge(challenge_b64: str) -> bool:
    stored = challenge_store.get(challenge_b64)
    if not stored or stored["used"]:
        return False
    age = (datetime.utcnow() - stored["createdAt"]).total_seconds()
    if age > 300:
        del challenge_store[challenge_b64]
        return False
    stored["used"] = True
    return True
// クライアントは常にサーバーからchallengeを取得し、クライアント側で生成しない
const { options, challenge } = await fetch("/api/webauthn/registration/options", {
  method: "POST",
  body: JSON.stringify({ userId, username, displayName }),
}).then(r => r.json());

落とし穴2:origin検証の欠落

❌ 誤り:

def verify_registration(credential_response, challenge):
    verification = verify_registration_response(
        credential=credential_response,
        expected_challenge=challenge,
        expected_origin="*",  # originを検証しない
        expected_rp_id=RP_ID,
    )
// クライアントにorigin検証の意識がない

✅ 正解:

ALLOWED_ORIGINS = {
    "https://yourapp.com",
    "https://www.yourapp.com",
}

def verify_registration(credential_response, challenge):
    client_data = json.loads(
        base64.urlsafe_b64decode(
            credential_response["response"]["clientDataJSON"] + "=="
        )
    )
    origin = client_data.get("origin")

    if origin not in ALLOWED_ORIGINS:
        raise ValueError(f"許可されていないorigin: {origin}")

    verification = verify_registration_response(
        credential=credential_response,
        expected_challenge=challenge,
        expected_origin=origin,
        expected_rp_id=RP_ID,
    )
// ページが正しいoriginで実行されていることを確認
if (window.location.origin !== "https://yourapp.com") {
  console.error("WebAuthnは正しいoriginでのみ使用できます");
}

落とし穴3:sign_count未検証によるクローン攻撃

❌ 誤り:

def verify_authentication(credential_response, stored_credential):
    verification = verify_authentication_response(
        credential=credential_response,
        expected_challenge=challenge,
        expected_origin=ORIGIN,
        expected_rp_id=RP_ID,
        credential_public_key=stored_credential["public_key"],
        credential_current_sign_count=0,  # 常に0を渡す
    )
    # 返されたsign_countを無視

✅ 正解:

def verify_authentication(credential_response, stored_credential):
    verification = verify_authentication_response(
        credential=credential_response,
        expected_challenge=challenge,
        expected_origin=ORIGIN,
        expected_rp_id=RP_ID,
        credential_public_key=stored_credential["public_key"],
        credential_current_sign_count=stored_credential["sign_count"],
    )

    if verification.new_sign_count <= stored_credential["sign_count"]:
        # クローンクレデンシャルの可能性、セキュリティイベントを記録
        log_security_event(
            "possible_credential_clone",
            credential_id=stored_credential["credential_id"],
            stored_count=stored_credential["sign_count"],
            received_count=verification.new_sign_count,
        )
        raise ValueError("クレデンシャルのクローン攻撃の可能性を検出")

    stored_credential["sign_count"] = verification.new_sign_count

落とし穴4:residentKey設定ミスによるPasskey同期不可

❌ 誤り:

options = {
    "authenticatorSelection": {
        "authenticatorAttachment": "platform",
        "requireResidentKey": False,  # 誤り:Passkeyにはresident keyが必要
        "residentKey": "discouraged",
        "userVerification": "discouraged",
    },
}
const options = {
  authenticatorSelection: {
    authenticatorAttachment: "platform",
    requireResidentKey: false,
    residentKey: "discouraged",
    userVerification: "discouraged",
  },
};

✅ 正解:

options = {
    "authenticatorSelection": {
        "authenticatorAttachment": "platform",
        "requireResidentKey": True,
        "residentKey": "required",  # Passkeyはrequiredに設定必須
        "userVerification": "required",  # 生体認証を確実に実行
    },
}
const options = {
  authenticatorSelection: {
    authenticatorAttachment: "platform",
    requireResidentKey: true,
    residentKey: "required",
    userVerification: "required",
  },
};

落とし穴5:最後のクレデンシャル削除でアカウントロック

❌ 誤り:

def delete_credential(user_id: str, credential_id: str):
    cred = db.query(Credential).filter_by(id=credential_id).first()
    db.delete(cred)
    db.commit()
    return {"success": True}

✅ 正解:

def delete_credential(user_id: str, credential_id: str):
    remaining = db.query(Credential).filter_by(user_id=user_id).count()

    if remaining <= 1:
        has_password = db.query(User).filter_by(id=user_id).first().password_hash
        if not has_password:
            raise ValueError(
                "これが最後のPasskeyでパスワードも設定されていません。"
                "削除するとログインできなくなります。先にバックアップPasskeyを登録するかパスワードを設定してください。"
            )

    cred = db.query(Credential).filter_by(id=credential_id).first()
    db.delete(cred)
    db.commit()
    return {"success": True}

エラートラブルシューティング表

エラーメッセージ 原因 解決策
DOMException: The operation is insecure ページがHTTPSまたはlocalhostではない HTTPSまたはlocalhost開発環境を使用
DOMException: The operation was cancelled ユーザーがキャンセルまたはタイムアウト NotAllowedErrorをキャッチし、リトライオプションを提供
InvalidStateError デバイスが既にこのRPのクレデンシャルを登録済み excludeCredentialsで登録済みクレデンシャルを除外
SecurityError: The rpId is not valid rpIdが現在のドメインと一致しない rpIdが有効なドメインでページのoriginと一致することを確認
TypeError: Credentials container is empty 一致するallowCredentialsがない クレデンシャルIDのエンコーディングが正しいか確認
DOMException: A requested feature is not supported ブラウザがresidentKeyをサポートしていない residentKey: "preferred"にダウングレード
Verification failed: signature mismatch 公開鍵または署名データが破損 base64urlエンコード/デコードを確認、COSE key形式を確認
Verification failed: challenge mismatch challengeエンコーディングの不一致 サーバーとクライアントが同じbase64urlエンコーディングを使用することを確認
NetworkError: authentication cancelled オーセンティケータとの通信が中断 リトライ、USB/NFC接続を確認(セキュリティキーシナリオ)
ConstraintError: request has too many params excludeCredentialsが多すぎる 除外リストを最大10個のクレデンシャルに制限

高度な最適化

1. Challengeストレージ最適化(Redis + TTL)

import redis
import json

redis_client = redis.Redis(host="localhost", port=6379, db=0)

class RedisChallengeStore:
    CHALLENGE_TTL = 300  # 5分で期限切れ

    def store(self, challenge: str, data: dict) -> None:
        key = f"webauthn:challenge:{challenge}"
        redis_client.setex(key, self.CHALLENGE_TTL, json.dumps(data))

    def retrieve(self, challenge: str) -> dict | None:
        key = f"webauthn:challenge:{challenge}"
        data = redis_client.get(key)
        if data:
            redis_client.delete(key)
            return json.loads(data)
        return None

    def exists(self, challenge: str) -> bool:
        return redis_client.exists(f"webauthn:challenge:{challenge}") > 0

2. クレデンシャルトランスポート層最適化

def store_credential_with_transports(
    user_id: str,
    credential_id: str,
    public_key: str,
    sign_count: int,
    transports: list[str],
) -> None:
    credential = PasskeyCredential(
        credential_id=credential_id,
        user_id=user_id,
        public_key=public_key,
        sign_count=sign_count,
        transports=transports,
        device_type=_classify_device(transports),
        created_at=datetime.utcnow(),
    )
    db.add(credential)
    db.commit()

def _classify_device(transports: list[str]) -> str:
    if "internal" in transports:
        return "platform"
    if "usb" in transports and "nfc" in transports:
        return "security_key_multi"
    if "usb" in transports:
        return "security_key_usb"
    if "nfc" in transports:
        return "security_key_nfc"
    if "ble" in transports:
        return "security_key_ble"
    return "unknown"

3. 認証監査ログ

from dataclasses import dataclass
from datetime import datetime

@dataclass
class AuthAuditLog:
    id: str
    user_id: str
    event_type: str
    credential_id: str
    ip_address: str
    user_agent: str
    success: bool
    error_message: str | None
    created_at: datetime

class AuthAuditService:
    def log_registration(
        self,
        user_id: str,
        credential_id: str,
        success: bool,
        request,
        error: str | None = None,
    ) -> None:
        log = AuthAuditLog(
            id=secrets.token_urlsafe(16),
            user_id=user_id,
            event_type="passkey_registration",
            credential_id=credential_id,
            ip_address=request.client.host,
            user_agent=request.headers.get("user-agent", ""),
            success=success,
            error_message=error,
            created_at=datetime.utcnow(),
        )
        db.add(log)
        db.commit()

    def log_authentication(
        self,
        user_id: str,
        credential_id: str,
        success: bool,
        request,
        error: str | None = None,
    ) -> None:
        log = AuthAuditLog(
            id=secrets.token_urlsafe(16),
            user_id=user_id,
            event_type="passkey_authentication",
            credential_id=credential_id,
            ip_address=request.client.host,
            user_agent=request.headers.get("user-agent", ""),
            success=success,
            error_message=error,
            created_at=datetime.utcnow(),
        )
        db.add(log)
        db.commit()

    def detect_anomaly(self, user_id: str, window_minutes: int = 15) -> bool:
        cutoff = datetime.utcnow() - timedelta(minutes=window_minutes)
        recent_failures = (
            db.query(AuthAuditLog)
            .filter(
                AuthAuditLog.user_id == user_id,
                AuthAuditLog.success == False,
                AuthAuditLog.created_at >= cutoff,
            )
            .count()
        )
        return recent_failures >= 5

認証方式比較

特徴 WebAuthn/Passkey TOTP SMS OTP Magic Link OAuth2
フィッシング対策 ✅ ドメインバインド ❌ フィッシング可能 ❌ フィッシング可能 ⚠️ 部分的 ⚠️ IdP依存
ユーザー体験 ✅ ワンタップ生体認証 ⚠️ 6桁コード入力 ⚠️ SMS待ち ⚠️ メール確認 ✅ ワンクリックリダイレクト
デバイス要件 ⚠️ WebAuthn対応必要 ✅ 任意のスマホ ✅ 任意のスマホ ✅ 任意のメール ✅ 任意のブラウザ
オフライン対応 ✅ ローカル検証 ✅ オフライン生成 ❌ ネットワーク必要 ❌ ネットワーク必要 ❌ ネットワーク必要
マルチデバイス同期 ✅ クラウド同期 ❌ 手動移行 ✅ 電話番号変更なし ✅ メールでログイン可 ✅ アカウント共通
実装コスト ⚠️ 中程度 ✅ 低い ⚠️ SMS費用 ✅ 低い ⚠️ IdP統合必要
セキュリティレベル 🏆 最高 ⭐ 高 ⭐ 中 ⭐ 中 ⭐ 高
標準化 ✅ W3C/FIDO ✅ RFC 6238 ❌ 統一規格なし ❌ 統一規格なし ✅ RFC 6749

まとめ:WebAuthn/FIDO2は「あれば便利」なオプション機能ではなく、2026年の認証における必須要件だ。6つのプロダクションパターンは、登録から認証、クレデンシャル管理からマルチデバイスサポート、フォールバック戦略から完全なサービスまで、フルパイプラインをカバーする。3つの原則を忘れないこと:challengeは真の乱数で使い捨て必須、residentKeyはrequiredに設定必須、常に少なくとも1つのリカバリチャネルを確保。Passkeyはパスワードの終焉をもたらす——ただし正しく実装した場合に限る。


おすすめツール

  • Hashツール - challengeのSHA-256ハッシュを生成、WebAuthn challengeのデバッグに
  • Base64エンコード/デコード - WebAuthnのbase64urlエンコードデータをデコード、クレデンシャルデータのデバッグに
  • JWTデコード - 認証後のセッショントークンをデコード、WebAuthn認証結果の検証に

ブラウザローカルツールを無料で試す →

#WebAuthn#FIDO2#Passkey#无密码认证#生物识别#2026#安全