WebAuthn Passkeyデプロイ:2026年パスワードレス認証の開発から本番まで完全ガイド

前端安全

パスワード認証の終わりが来た

ユーザーが123456をパスワードに設定し、クレデンシャルスタッフィング後にセキュリティが悪いと文句を言う。複雑なパスワードを強制すると、付箋に書いてモニターに貼る。SMS OTPを追加すると、SIMスワップ攻撃で傍受される。2026年、Passkeyがついに「パスワードレス認証」をコンセプトからプロダクションへ——Apple、Google、Microsoftが全面サポート、ブラウザカバレッジ95%超。

本記事はWebAuthnプロトコル原理から出発し、Passkey登録→検証→マルチデバイス同期→フォールバック戦略のフルパイプラインを実践します。


WebAuthnプロトコルコア概念

概念 説明
Relying Party (RP) 証明書依存者、あなたのウェブサイト/サーバー
Authenticator 認証器、プラットフォーム認証器(TouchID/FaceID)とローミング認証器(YubiKey)
Credential クレデンシャル、公開鍵暗号の鍵ペア、秘密鍵は認証器内に保存され決して出ない
Challenge サーバー生成のランダムチャレンジ値、リプレイ攻撃防止
Attestation 証明、認証器がRPに正当性を証明する方法
Client Data クライアントデータ、challenge、origin等を含む
Assertion アサーション、認証時に認証器が返す署名証明

登録と検証フロー

登録フロー(Registration):
1. ユーザーが「Passkeyを登録」をクリック
2. サーバーがchallengeを生成、PublicKeyCredentialCreationOptionsを返す
3. ブラウザがnavigator.credentials.create()を呼び出し
4. 認証器が鍵ペアを生成、秘密鍵をセキュアチップに保存
5. PublicKeyCredential(公開鍵、attestation含む)を返す
6. サーバーがattestationを検証、公開鍵とcredentialIDを保存

検証フロー(Authentication):
1. ユーザーが「Passkeyでサインイン」をクリック
2. サーバーがchallengeを生成、PublicKeyCredentialRequestOptionsを返す
3. ブラウザがnavigator.credentials.get()を呼び出し
4. 認証器が秘密鍵でchallengeに署名
5. PublicKeyCredential(署名アサーション含む)を返す
6. サーバーが保存した公開鍵で署名を検証

問題分析:Passkeyデプロイの5つの課題

  1. クロスブラウザ互換性:Safari/Chrome/FirefoxのWebAuthn実装に微妙な差異
  2. マルチデバイス同期:Passkeyはクラウド同期に依存(iCloud Keychain/Google Password Manager)、クロスエコシステム非互換
  3. Conditional UI:Autofill UI統合に特定のAPI呼び出しが必要
  4. フォールバック戦略:Passkey非対応デバイス/ブラウザは従来認証にフォールバック
  5. サーバー実装の複雑さ:COSE鍵形式、CBORエンコーディング、署名検証ロジックが煩雑

ステップバイステップ:完全Passkey実装

Step 1:サーバー——登録オプション生成

package webauthn

import (
    "crypto/rand"
    "encoding/base64"
)

type RegistrationOptions struct {
    Challenge        string   `json:"challenge"`
    RpID             string   `json:"rpId"`
    RpName           string   `json:"rpName"`
    UserID           string   `json:"userId"`
    UserName         string   `json:"userName"`
    UserDisplayName  string   `json:"userDisplayName"`
    Timeout          int      `json:"timeout"`
    Attestation      string   `json:"attestation"`
    AuthenticatorSel AuthSel  `json:"authenticatorSelection"`
    PubKeyCredParams []CredParam `json:"pubKeyCredParams"`
    ExcludeCreds     []CredDescriptor `json:"excludeCredentials"`
}

type AuthSel struct {
    AuthenticatorAttachment string `json:"authenticatorAttachment"`
    RequireResidentKey      bool   `json:"requireResidentKey"`
    ResidentKey             string `json:"residentKey"`
    UserVerification        string `json:"userVerification"`
}

type CredParam struct {
    Type string `json:"type"`
    Alg  int    `json:"alg"`
}

type CredDescriptor struct {
    Type string `json:"type"`
    ID   string `json:"id"`
}

func GenerateRegistrationOptions(rpID, rpName, userID, userName, displayName string, excludeCreds []string) (*RegistrationOptions, error) {
    challenge := make([]byte, 32)
    if _, err := rand.Read(challenge); err != nil {
        return nil, err
    }

    excludeCredentials := make([]CredDescriptor, len(excludeCreds))
    for i, id := range excludeCreds {
        excludeCredentials[i] = CredDescriptor{Type: "public-key", ID: id}
    }

    return &RegistrationOptions{
        Challenge:       base64.RawURLEncoding.EncodeToString(challenge),
        RpID:            rpID,
        RpName:          rpName,
        UserID:          base64.RawURLEncoding.EncodeToString([]byte(userID)),
        UserName:        userName,
        UserDisplayName: displayName,
        Timeout:         60000,
        Attestation:     "none",
        AuthenticatorSel: AuthSel{
            AuthenticatorAttachment: "platform",
            RequireResidentKey:      true,
            ResidentKey:             "required",
            UserVerification:        "preferred",
        },
        PubKeyCredParams: []CredParam{
            {Type: "public-key", Alg: -7},   // ES256
            {Type: "public-key", Alg: -257}, // RS256
        },
        ExcludeCreds: excludeCredentials,
    }, nil
}

Step 2:サーバー——登録レスポンス検証

package webauthn

import (
    "encoding/base64"
    "encoding/json"
    "fmt"
)

type RegistrationResponse struct {
    ID    string `json:"id"`
    RawID string `json:"rawId"`
    Type  string `json:"type"`
    Response struct {
        ClientDataJSON    string `json:"clientDataJSON"`
        AttestationObject string `json:"attestationObject"`
    } `json:"response"`
}

type ClientData struct {
    Type      string `json:"type"`
    Challenge string `json:"challenge"`
    Origin    string `json:"origin"`
}

func VerifyRegistrationResponse(response *RegistrationResponse, expectedChallenge, expectedOrigin string) (*VerifiedRegistration, error) {
    clientDataBytes, err := base64.RawURLEncoding.DecodeString(response.Response.ClientDataJSON)
    if err != nil {
        return nil, fmt.Errorf("decode client data: %w", err)
    }

    var clientData ClientData
    if err := json.Unmarshal(clientDataBytes, &clientData); err != nil {
        return nil, fmt.Errorf("parse client data: %w", err)
    }

    if clientData.Type != "webauthn.create" {
        return nil, fmt.Errorf("unexpected type: %s", clientData.Type)
    }
    if clientData.Challenge != expectedChallenge {
        return nil, fmt.Errorf("challenge mismatch")
    }
    if clientData.Origin != expectedOrigin {
        return nil, fmt.Errorf("origin mismatch: got %s, want %s", clientData.Origin, expectedOrigin)
    }

    credID, err := base64.RawURLEncoding.DecodeString(response.ID)
    if err != nil {
        return nil, fmt.Errorf("decode credential ID: %w", err)
    }

    return &VerifiedRegistration{
        CredentialID: credID,
        ClientData:   &clientData,
    }, nil
}

type VerifiedRegistration struct {
    CredentialID []byte
    ClientData   *ClientData
}

Step 3:フロントエンド——Passkey登録

async function registerPasskey(options: RegistrationOptions): Promise<PublicKeyCredential | null> {
  if (!window.PublicKeyCredential) {
    console.error('WebAuthn is not supported');
    return null;
  }

  const isAvailable = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
  if (!isAvailable) {
    console.error('Platform authenticator not available');
    return null;
  }

  const creationOptions: PublicKeyCredentialCreationOptions = {
    challenge: base64URLDecode(options.challenge),
    rp: { id: options.rpId, name: options.rpName },
    user: {
      id: base64URLDecode(options.userId),
      name: options.userName,
      displayName: options.userDisplayName,
    },
    pubKeyCredParams: options.pubKeyCredParams.map(p => ({
      type: p.type as PublicKeyCredentialType,
      alg: p.alg,
    })),
    timeout: options.timeout,
    attestation: options.attestation as AttestationConveyancePreference,
    authenticatorSelection: {
      authenticatorAttachment: options.authenticatorSelection.authenticatorAttachment as AuthenticatorAttachment,
      requireResidentKey: options.authenticatorSelection.requireResidentKey,
      residentKey: options.authenticatorSelection.residentKey as ResidentKeyRequirement,
      userVerification: options.authenticatorSelection.userVerification as UserVerificationRequirement,
    },
    excludeCredentials: options.excludeCredentials.map(c => ({
      type: c.type as PublicKeyCredentialType,
      id: base64URLDecode(c.id),
    })),
  };

  try {
    const credential = await navigator.credentials.create({ publicKey: creationOptions });
    return credential as PublicKeyCredential;
  } catch (err) {
    if ((err as DOMException).name === 'InvalidStateError') {
      console.error('Passkey already exists for this account');
    }
    throw err;
  }
}

function base64URLDecode(str: string): ArrayBuffer {
  const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
  const binary = atob(base64);
  const buffer = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    buffer[i] = binary.charCodeAt(i);
  }
  return buffer.buffer;
}

Step 4:フロントエンド——Passkey検証(Conditional UI付き)

async function authenticatePasskey(rpId: string, challenge: string): Promise<PublicKeyCredential | null> {
  const requestOptions: PublicKeyCredentialRequestOptions = {
    challenge: base64URLDecode(challenge),
    rpId: rpId,
    timeout: 60000,
    userVerification: 'preferred',
    allowCredentials: [],
  };

  try {
    const credential = await navigator.credentials.get({ publicKey: requestOptions });
    return credential as PublicKeyCredential;
  } catch (err) {
    console.error('Authentication failed:', err);
    return null;
  }
}

async function authenticateWithAutofill(rpId: string, challenge: string): Promise<void> {
  if (!window.PublicKeyCredential) return;

  const isConditionalAvailable = 'isConditionalMediationAvailable' in PublicKeyCredential
    && await (PublicKeyCredential as any).isConditionalMediationAvailable();

  if (!isConditionalAvailable) return;

  const requestOptions: PublicKeyCredentialRequestOptions = {
    challenge: base64URLDecode(challenge),
    rpId: rpId,
    timeout: 60000,
    userVerification: 'preferred',
    allowCredentials: [],
  };

  try {
    const credential = await navigator.credentials.get({
      publicKey: requestOptions,
      mediation: 'conditional' as CredentialMediationRequirement,
    });
    if (credential) {
      await sendAssertionToServer(credential as PublicKeyCredential);
    }
  } catch (err) {
    console.error('Conditional UI auth failed:', err);
  }
}

Step 5:フォールバック戦略

async function login(): Promise<void> {
  const hasWebAuthn = !!window.PublicKeyCredential;
  const hasPlatformAuth = hasWebAuthn
    && await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();

  if (hasPlatformAuth) {
    try {
      const credential = await authenticatePasskey(rpId, challenge);
      if (credential) {
        await sendAssertionToServer(credential);
        return;
      }
    } catch {
      // Passkey認証失敗、フォールバック
    }
  }

  await fallbackToPasswordLogin();
}

落とし穴ガイド

落とし穴1:challengeがbase64urlエンコードされていない

// ❌ 誤り:hex文字列を直接渡す
const challenge = crypto.randomUUID();

// ✅ 正しい:ランダムバイトを生成してbase64urlエンコード
const challengeBuffer = new Uint8Array(32);
crypto.getRandomValues(challengeBuffer);
const challenge = arrayBufferToBase64URL(challengeBuffer);

落とし穴2:RP IDがドメインと一致しない

// ❌ 誤り:完全URLを使用
const rpId = 'https://auth.example.com';

// ✅ 正しい:有効ドメインのみ使用
const rpId = 'example.com';

落とし穴3:requireResidentKey=falseでPasskeyが発見不可

// ❌ 誤り:非レジデント鍵はConditional UIに表示されない
authenticatorSelection: {
  requireResidentKey: false,
  residentKey: 'discouraged',
}

// ✅ 正しい:Passkeyはレジデント鍵でなければならない
authenticatorSelection: {
  requireResidentKey: true,
  residentKey: 'required',
}

落とし穴4:ユーザーキャンセルの処理忘れ

// ❌ 誤り:NotAllowedErrorを処理しない
const credential = await navigator.credentials.create({ publicKey: options });

// ✅ 正しい:ユーザーキャンセルをキャッチ
try {
  const credential = await navigator.credentials.create({ publicKey: options });
} catch (err) {
  if ((err as DOMException).name === 'NotAllowedError') {
    showToast('操作がキャンセルされました。再試行してください');
    return;
  }
  throw err;
}

落とし穴5:attestation="direct"でプライバシー警告

// ❌ 誤り:直接証明を要求、ブラウザがプライバシー警告を表示
attestation: 'direct'

// ✅ 正しい:本番では"none"を使用(デバイス証明が必要な場合を除く)
attestation: 'none'

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

# エラーメッセージ 原因 解決方法
1 The operation either timed out or was not allowed ユーザーキャンセルまたはタイムアウト NotAllowedErrorをキャッチ、リトライを促す
2 An authenticator appropriate for this request could not be found 利用可能な認証器なし isUserVerifyingPlatformAuthenticatorAvailableを確認、フォールバック提供
3 At least one credential is required allowCredentialsが空でレジデント鍵なし 登録時にresidentKey=requiredを保証
4 The RP ID is not a registrable domain suffix RP ID形式エラー プロトコル/ポートなしの有効ドメインを使用
5 The challenge is not a ArrayBuffer challenge型エラー challengeがArrayBufferであることを保証、文字列不可
6 A public key credential with the specified ID already exists 同一クレデンシャルの重複登録 excludeCredentialsで既存を除外
7 The authenticator cannot create a credential アルゴリズム非対応 ES256(-7)とRS256(-257)の両方を追加
8 The operation could not be completed HTTPS未設定 WebAuthnはHTTPSが必要(localhost除く)
9 The request could not be satisfied authenticatorSelection制約不満 制約を緩和またはcross-platformオプション追加
10 CBOR decoding failed サーバーattestationObject解析エラー 標準CBORライブラリを使用、base64urlデコード確認

高度な最適化

1. マルチデバイスPasskey同期検出

async function checkPasskeySyncStatus(credentialId: string): Promise<'synced' | 'local' | 'unknown'> {
  if ('isExternalPasskeySynced' in PublicKeyCredential) {
    const isSynced = await (PublicKeyCredential as any).isExternalPasskeySynced({
      credentialId: base64URLDecode(credentialId),
    });
    return isSynced ? 'synced' : 'local';
  }
  return 'unknown';
}

2. Passkey登録率トラッキング

interface PasskeyMetrics {
  webauthnSupported: boolean;
  platformAuthAvailable: boolean;
  conditionalUIAvailable: boolean;
  registrationAttempted: boolean;
  registrationSucceeded: boolean;
  registrationError: string | null;
}

function collectPasskeyMetrics(): PasskeyMetrics {
  return {
    webauthnSupported: !!window.PublicKeyCredential,
    platformAuthAvailable: false,
    conditionalUIAvailable: false,
    registrationAttempted: false,
    registrationSucceeded: false,
    registrationError: null,
  };
}

3. AAGUIDフィルタリング——認証器タイプ制限

var allowedAAGUIDs = map[string]bool{
    "08987058-5a24-4c8b-8f4b-3dd4d5f5d5f5": true, // Apple TouchID
    "de1e552d-db1d-4423-a619-3db8da29e0b0": true, // Android Fingerprint
    "f8a011f3-8c0a-4d15-aa6b-0040a2c8e6e0": true, // Windows Hello
}

func isAllowedAuthenticator(aaguid string) bool {
    return allowedAAGUIDs[aaguid]
}

比較分析

次元 Passkey/WebAuthn パスワード OTP/TOTP OAuth2/OIDC ハードウェアトークン
フィッシング耐性 ✅強 ❌弱 ⚠️中 ⚠️中 ✅強
ユーザー体験 ⭐優秀 ⭐悪い ⭐中程度 ⭐良い ⭐悪い
マルチデバイス同期 ✅クラウド ✅手動 ❌追加アプリ
実装複雑度
オフライン
プライバシー ✅追跡なし ⚠️
ブラウザ対応 95%+ 100% 100% 100% USB/NFC必要
コスト 高($25-60)

まとめ:Passkeyは「パスワードの代替」ではなく「フィッシング攻撃のターミネーター」です。WebAuthnの公開鍵暗号はクレデンシャル漏洩リスクを根本的に排除——秘密鍵はデバイスから決して出ず、challenge-responseメカニズムがリプレイとフィッシングを杜绝します。2026年のデプロイパス:まずConditional UIを実装(Autofill統合、学習コストゼロ)→Passkey登録プロモーション→段階的にパスワードログインを非推奨に。鍵は堅牢なフォールバック戦略で、非対応デバイスのシームレスな退化を実現することです。


オンラインツール推奨

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

#WebAuthn#Passkey#无密码认证#FIDO2#生物识别#前端安全#身份认证#CTAP