ブログ記事

WebAuthn/Passkeys実装ガイド2025 - パスワードレス認証の実現

WebAuthnとPasskeysを使用したパスワードレス認証の完全実装ガイド。セキュリティベストプラクティス、実装手順、データベース設計、UI/UX考慮事項まで、本番環境で使える実践的なノウハウを徹底解説します。

Web開発
WebAuthn Passkeys セキュリティ 認証 FIDO2
WebAuthn/Passkeys実装ガイド2025 - パスワードレス認証の実現のヒーロー画像

2025 年、パスワードの時代は終わりを迎えようとしています。Amazon では 1 億 7500 万人以上のユーザーがパスキーを使用し、ログイン速度が 6 倍に向上。Microsoft では 987%のパスキー利用増加を記録し、パスワードベースのログインが 10%減少しました。

本記事では、WebAuthn/Passkeys の実装方法を解説します。セキュリティベストプラクティスや実際の企業での成功事例も紹介し、パスワードレス認証を本番環境で実現するための完全ガイドを提供します。

この記事で学べること

  • WebAuthn と Passkeys の仕組みと違い
  • ステップバイステップの実装方法(フロントエンド・バックエンド)
  • セキュリティベストプラクティスとデータベース設計
  • UI/ux の最適化とユーザー教育
  • 大手企業の導入事例と効果測定
  • トラブルシューティングと本番運用のノウハウ

目次

  1. WebAuthn/Passkeys とは何か
  2. なぜ今パスワードレス認証が必要なのか
  3. 実装の全体像とアーキテクチャ
  4. フロントエンド実装
  5. バックエンド実装とデータベース設計
  6. セキュリティベストプラクティス
  7. UI/ux の最適化
  8. 本番環境での運用と監視

WebAuthn/Passkeysとは何か

基本概念の整理

WebAuthn/Passkeysのエコシステム

チャートを読み込み中...

WebAuthnとPasskeysの違い

WebAuthnとPasskeysの主な違い
項目 WebAuthn Passkeys
定義 W3C標準の認証API仕様 WebAuthnを使用した認証資格情報
役割 技術仕様・プロトコル ユーザー向けのブランド・実装
範囲 ブラウザAPIの定義 クロスプラットフォーム同期を含む
同期 仕様では定義されない iCloud/Google等で自動同期
ユーザー視点 技術的な概念 パスワードの代替手段

なぜ今パスワードレス認証が必要なのか

パスワード認証の限界

データ漏洩の原因がパスワード関連 81 %
ユーザーが同じパスワードを使い回し 51 %
2FAを実際に利用しているユーザー 23 %

企業での導入効果

パスキーにより、お客様は従来の 6 倍の速さでサインインできるようになりました。 1 億 7500 万人以上のお客様がすでにパスキーを利用しています。

セキュリティ責任者 Amazon

パスキーの使用は 987%増加し、パスワードベースのログインは 10%減少しました。 パスキーでのサインインは、従来のパスワードより 3 倍高速です。

Identity Platform責任者 Microsoft

導入メリット

パスキー導入の効果

フィッシング耐性: 共有秘密に依存しないため、フィッシング攻撃が不可能 ✅ ユーザー体験向上: ログイン時間が最大 6 倍高速化 ✅ サポートコスト削減: Branch Insurance では認証関連のチケットが 50%減少 ✅ セキュリティ向上: データベースが侵害されても公開鍵は悪用不可能 ✅ コンプライアンス: FIDO2 認証による規制要件への対応

実装の全体像とアーキテクチャ

システムアーキテクチャ

WebAuthn実装のフロー

チャートを読み込み中...

技術スタックの選定

// 推奨技術スタック
const techStack = {
  frontend: {
    library: '@simplewebauthn/browser',
    framework: 'React/Vue/Angular',
    bundler: 'Vite/Webpack'
  },
  backend: {
    node: '@simplewebauthn/server',
    python: 'webauthn',
    go: 'go-webauthn/webauthn',
    php: 'lbuchs/WebAuthn'
  },
  database: {
    primary: 'PostgreSQL/MySQL',
    cache: 'Redis',
    schema: 'JSON columns for credential data'
  }
}

フロントエンド実装

ステップ1:ライブラリのセットアップ

# SimpleWebAuthnのインストール
npm install @simplewebauthn/browser
npm install --save-dev @simplewebauthn/types

ステップ2:登録フローの実装

// PasskeyRegistration.tsx
import { startRegistration } from '@simplewebauthn/browser';
import { useState } from 'react';

export function PasskeyRegistration() {
  const [isRegistering, setIsRegistering] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleRegister = async () => {
    try {
      setIsRegistering(true);
      setError(null);

      // 1. サーバーから登録オプションを取得
      const response = await fetch('/api/webauthn/register/options', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include'
      });

      const options = await response.json();

      // 2. ブラウザのWebAuthn APIを呼び出し
      const credential = await startRegistration(options);

      // 3. 登録データをサーバーに送信
      const verifyResponse = await fetch('/api/webauthn/register/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credential),
        credentials: 'include'
      });

      const result = await verifyResponse.json();

      if (result.verified) {
        alert('パスキーの登録が完了しました!');
      } else {
        throw new Error('登録の検証に失敗しました');
      }
    } catch (err) {
      if (err.name === 'NotAllowedError') {
        setError('ユーザーがキャンセルしました');
      } else if (err.name === 'NotSupportedError') {
        setError('このブラウザはWebAuthnをサポートしていません');
      } else {
        setError(`エラー: ${err.message}`);
      }
    } finally {
      setIsRegistering(false);
    }
  };

  return (
    <div className="passkey-registration">
      <h2>パスキーの設定</h2>
      <p>パスキーを使用すると、パスワードなしで安全にログインできます。</p>
      
      <button 
        onClick={handleRegister}
        disabled={isRegistering}
        className="register-button"
      >
        {isRegistering ? '登録中...' : 'パスキーを登録'}
      </button>

      {error && (
        <div className="error-message">{error}</div>
      )}

      <div className="supported-devices">
        <h3>対応デバイス</h3>
        <ul>
          <li>✅ Touch ID / Face ID (Mac/iPhone)</li>
          <li>✅ Windows Hello</li>
          <li>✅ Android指紋認証</li>
          <li>✅ セキュリティキー(YubiKey等)</li>
        </ul>
      </div>
    </div>
  );
}
<!-- PasskeyRegistration.vue -->
<template>
  <div class="passkey-registration">
    <h2>パスキーの設定</h2>
    <p>パスキーを使用すると、パスワードなしで安全にログインできます。</p>
    
    <button 
      @click="handleRegister"
      :disabled="isRegistering"
      class="register-button"
    >
      {{ isRegistering ? '登録中...' : 'パスキーを登録' }}
    </button>

    <div v-if="error" class="error-message">
      {{ error }}
    </div>

    <div class="supported-devices">
      <h3>対応デバイス</h3>
      <ul>
        <li>✅ Touch ID / Face ID (Mac/iPhone)</li>
        <li>✅ Windows Hello</li>
        <li>✅ Android指紋認証</li>
        <li>✅ セキュリティキー(YubiKey等)</li>
      </ul>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { startRegistration } from '@simplewebauthn/browser'

const isRegistering = ref(false)
const error = ref<string | null>(null)

const handleRegister = async () => {
  try {
    isRegistering.value = true
    error.value = null

    // 1. サーバーから登録オプションを取得
    const response = await fetch('/api/webauthn/register/options', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include'
    })

    const options = await response.json()

    // 2. ブラウザのWebAuthn APIを呼び出し
    const credential = await startRegistration(options)

    // 3. 登録データをサーバーに送信
    const verifyResponse = await fetch('/api/webauthn/register/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(credential),
      credentials: 'include'
    })

    const result = await verifyResponse.json()

    if (result.verified) {
      alert('パスキーの登録が完了しました!')
    } else {
      throw new Error('登録の検証に失敗しました')
    }
  } catch (err: any) {
    if (err.name === 'NotAllowedError') {
      error.value = 'ユーザーがキャンセルしました'
    } else if (err.name === 'NotSupportedError') {
      error.value = 'このブラウザはWebAuthnをサポートしていません'
    } else {
      error.value = `エラー: ${err.message}`
    }
  } finally {
    isRegistering.value = false
  }
}
</script>
// passkey-registration.js
import { startRegistration } from '@simplewebauthn/browser';

class PasskeyRegistration {
  constructor(containerElement) {
    this.container = containerElement;
    this.isRegistering = false;
    this.init();
  }

  init() {
    this.render();
    this.attachEventListeners();
  }

  render() {
    this.container.innerHTML = `
      <div class="passkey-registration">
        <h2>パスキーの設定</h2>
        <p>パスキーを使用すると、パスワードなしで安全にログインできます。</p>
        
        <button id="register-btn" class="register-button">
          パスキーを登録
        </button>

        <div id="error-message" class="error-message" style="display: none;"></div>

        <div class="supported-devices">
          <h3>対応デバイス</h3>
          <ul>
            <li>✅ Touch ID / Face ID (Mac/iPhone)</li>
            <li>✅ Windows Hello</li>
            <li>✅ Android指紋認証</li>
            <li>✅ セキュリティキー(YubiKey等)</li>
          </ul>
        </div>
      </div>
    `;
  }

  attachEventListeners() {
    const registerBtn = this.container.querySelector('#register-btn');
    registerBtn.addEventListener('click', () => this.handleRegister());
  }

  async handleRegister() {
    const registerBtn = this.container.querySelector('#register-btn');
    const errorDiv = this.container.querySelector('#error-message');

    try {
      this.isRegistering = true;
      registerBtn.disabled = true;
      registerBtn.textContent = '登録中...';
      errorDiv.style.display = 'none';

      // 1. サーバーから登録オプションを取得
      const response = await fetch('/api/webauthn/register/options', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include'
      });

      const options = await response.json();

      // 2. ブラウザのWebAuthn APIを呼び出し
      const credential = await startRegistration(options);

      // 3. 登録データをサーバーに送信
      const verifyResponse = await fetch('/api/webauthn/register/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credential),
        credentials: 'include'
      });

      const result = await verifyResponse.json();

      if (result.verified) {
        alert('パスキーの登録が完了しました!');
      } else {
        throw new Error('登録の検証に失敗しました');
      }
    } catch (err) {
      errorDiv.style.display = 'block';
      
      if (err.name === 'NotAllowedError') {
        errorDiv.textContent = 'ユーザーがキャンセルしました';
      } else if (err.name === 'NotSupportedError') {
        errorDiv.textContent = 'このブラウザはWebAuthnをサポートしていません';
      } else {
        errorDiv.textContent = `エラー: ${err.message}`;
      }
    } finally {
      this.isRegistering = false;
      registerBtn.disabled = false;
      registerBtn.textContent = 'パスキーを登録';
    }
  }
}

// 使用例
const container = document.getElementById('passkey-container');
new PasskeyRegistration(container);

ステップ3:認証フローの実装

// PasskeyLogin.tsx
import { startAuthentication } from '@simplewebauthn/browser';

export function PasskeyLogin() {
  const handleLogin = async () => {
    try {
      // 1. 認証オプションを取得
      const optionsResponse = await fetch('/api/webauthn/authenticate/options', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' }
      });

      const options = await optionsResponse.json();

      // 2. 認証を実行
      const credential = await startAuthentication(options);

      // 3. サーバーで検証
      const verifyResponse = await fetch('/api/webauthn/authenticate/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credential)
      });

      const result = await verifyResponse.json();

      if (result.verified) {
        // ログイン成功
        window.location.href = '/dashboard';
      }
    } catch (error) {
      console.error('認証エラー:', error);
    }
  };

  return (
    <button onClick={handleLogin} className="login-button">
      パスキーでログイン
    </button>
  );
}

バックエンド実装とデータベース設計

データベーススキーマ

-- ユーザーテーブル
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    username VARCHAR(100) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- WebAuthn資格情報テーブル
CREATE TABLE webauthn_credentials (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    credential_id TEXT UNIQUE NOT NULL,
    public_key JSONB NOT NULL,
    counter BIGINT NOT NULL DEFAULT 0,
    aaguid VARCHAR(36),
    transports JSONB,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    last_used_at TIMESTAMP,
    device_name VARCHAR(255),
    backup_eligible BOOLEAN DEFAULT FALSE,
    backup_state BOOLEAN DEFAULT FALSE,
    INDEX idx_user_credentials (user_id),
    INDEX idx_credential_id (credential_id)
);

-- 認証チャレンジテーブル(一時的なチャレンジ保存)
CREATE TABLE webauthn_challenges (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID REFERENCES users(id),
    challenge TEXT NOT NULL,
    type VARCHAR(20) NOT NULL, -- 'registration' or 'authentication'
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP NOT NULL,
    INDEX idx_user_challenges (user_id),
    INDEX idx_expires (expires_at)
);

-- 監査ログテーブル
CREATE TABLE authentication_logs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID REFERENCES users(id),
    credential_id TEXT,
    event_type VARCHAR(50) NOT NULL,
    success BOOLEAN NOT NULL,
    ip_address INET,
    user_agent TEXT,
    error_message TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_user_logs (user_id),
    INDEX idx_created_at (created_at)
);

Node.js/Express実装

// webauthn.controller.ts
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';

// 登録オプションの生成
export async function generateRegisterOptions(req: Request, res: Response) {
  const user = req.user;
  
  // 既存の資格情報を取得
  const existingCredentials = await db.query(
    'SELECT credential_id, transports FROM webauthn_credentials WHERE user_id = $1',
    [user.id]
  );

  const options = await generateRegistrationOptions({
    rpName: 'My Awesome App',
    rpID: 'example.com',
    userID: user.id,
    userName: user.username,
    userDisplayName: user.email,
    attestationType: 'none',
    excludeCredentials: existingCredentials.rows.map(cred => ({
      id: isoBase64URL.toBuffer(cred.credential_id),
      transports: cred.transports || []
    })),
    authenticatorSelection: {
      authenticatorAttachment: 'platform',
      requireResidentKey: true,
      residentKey: 'required',
      userVerification: 'required'
    }
  });

  // チャレンジを保存
  await db.query(
    `INSERT INTO webauthn_challenges (user_id, challenge, type, expires_at)
     VALUES ($1, $2, 'registration', NOW() + INTERVAL '5 minutes')`,
    [user.id, options.challenge]
  );

  res.json(options);
}

// 登録の検証
export async function verifyRegister(req: Request, res: Response) {
  const user = req.user;
  const credential = req.body;

  // チャレンジを取得
  const challengeResult = await db.query(
    `SELECT challenge FROM webauthn_challenges 
     WHERE user_id = $1 AND type = 'registration' 
     AND expires_at > NOW() 
     ORDER BY created_at DESC LIMIT 1`,
    [user.id]
  );

  if (!challengeResult.rows.length) {
    return res.status(400).json({ error: 'チャレンジが無効です' });
  }

  const expectedChallenge = challengeResult.rows[0].challenge;

  try {
    const verification = await verifyRegistrationResponse({
      response: credential,
      expectedChallenge,
      expectedOrigin: 'https://example.com',
      expectedRPID: 'example.com',
      requireUserVerification: true
    });

    if (verification.verified) {
      const { credentialPublicKey, credentialID, counter, credentialBackedUp } = 
        verification.registrationInfo!;

      // 資格情報を保存
      await db.query(
        `INSERT INTO webauthn_credentials 
         (user_id, credential_id, public_key, counter, aaguid, transports, 
          backup_eligible, backup_state, device_name)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
        [
          user.id,
          isoBase64URL.fromBuffer(credentialID),
          JSON.stringify(credentialPublicKey),
          counter,
          verification.registrationInfo!.aaguid,
          JSON.stringify(credential.response.transports || []),
          credentialBackedUp,
          credentialBackedUp,
          req.body.deviceName || 'Unknown Device'
        ]
      );

      // 監査ログ
      await logAuthEvent(user.id, 'passkey_registered', true);

      res.json({ verified: true });
    } else {
      throw new Error('検証に失敗しました');
    }
  } catch (error) {
    await logAuthEvent(user.id, 'passkey_registration_failed', false, error.message);
    res.status(400).json({ error: error.message });
  }
}

セキュリティベストプラクティス

1. チャレンジの管理

チャレンジのセキュリティ

  • チャレンジは必ず一度だけ使用し、使用後は削除
  • 有効期限を短く設定(5 分程度)
  • 暗号学的に安全な乱数生成器を使用
  • セッションごとに新しいチャレンジを生成
// チャレンジの安全な管理
class ChallengeManager {
  private readonly EXPIRY_MINUTES = 5;

  async createChallenge(userId: string, type: 'registration' | 'authentication') {
    const challenge = crypto.randomBytes(32).toString('base64url');
    
    await db.query(
      `INSERT INTO webauthn_challenges (user_id, challenge, type, expires_at)
       VALUES ($1, $2, $3, NOW() + INTERVAL '${this.EXPIRY_MINUTES} minutes')`,
      [userId, challenge, type]
    );

    // 古いチャレンジを削除
    await this.cleanupExpiredChallenges();

    return challenge;
  }

  async verifyAndConsumeChallenge(
    userId: string, 
    challenge: string, 
    type: string
  ): Promise<boolean> {
    const result = await db.query(
      `DELETE FROM webauthn_challenges 
       WHERE user_id = $1 AND challenge = $2 AND type = $3 
       AND expires_at > NOW()
       RETURNING id`,
      [userId, challenge, type]
    );

    return result.rowCount > 0;
  }

  private async cleanupExpiredChallenges() {
    await db.query('DELETE FROM webauthn_challenges WHERE expires_at < NOW()');
  }
}

2. カウンターの検証

// リプレイ攻撃の防止
async function verifyCounter(
  credentialId: string, 
  newCounter: number
): Promise<boolean> {
  const result = await db.query(
    'SELECT counter FROM webauthn_credentials WHERE credential_id = $1',
    [credentialId]
  );

  const storedCounter = result.rows[0].counter;

  if (newCounter <= storedCounter) {
    // カウンターが後退または同じ = リプレイ攻撃の可能性
    await logSecurityEvent('counter_rollback_detected', { credentialId });
    return false;
  }

  // カウンターを更新
  await db.query(
    'UPDATE webauthn_credentials SET counter = $1 WHERE credential_id = $2',
    [newCounter, credentialId]
  );

  return true;
}

3. 監査とロギング

// 包括的な監査ログ
interface AuditLog {
  userId: string;
  event: AuthEvent;
  success: boolean;
  metadata?: Record<string, any>;
}

enum AuthEvent {
  PASSKEY_REGISTERED = 'passkey_registered',
  PASSKEY_REMOVED = 'passkey_removed',
  LOGIN_SUCCESS = 'login_success',
  LOGIN_FAILED = 'login_failed',
  COUNTER_MISMATCH = 'counter_mismatch',
  INVALID_ORIGIN = 'invalid_origin'
}

async function auditLog(log: AuditLog) {
  const { userId, event, success, metadata } = log;
  
  await db.query(
    `INSERT INTO authentication_logs 
     (user_id, event_type, success, ip_address, user_agent, metadata)
     VALUES ($1, $2, $3, $4, $5, $6)`,
    [
      userId,
      event,
      success,
      req.ip,
      req.get('user-agent'),
      JSON.stringify(metadata)
    ]
  );

  // 異常なパターンを検出
  if (!success) {
    await checkForAnomalies(userId);
  }
}

UI/UXの最適化

プログレッシブエンハンスメント

// ❌ パスキーのみに依存 function LoginForm() { return ( <div> <button onClick={loginWithPasskey}> ログイン </button> </div> ); }
// ✅ 段階的な移行を促す function LoginForm() { const [hasPasskey, setHasPasskey] = useState(false); useEffect(() => { checkUserPasskeys().then(setHasPasskey); }, []); return ( <div> {hasPasskey ? ( <button onClick={loginWithPasskey} className="primary"> パスキーでログイン </button> ) : ( <> <input type="email" placeholder="メールアドレス" /> <input type="password" placeholder="パスワード" /> <button onClick={loginWithPassword}>ログイン</button> <button onClick={setupPasskey} className="secondary"> パスキーを設定してより安全に </button> </> )} </div> ); }
基本的な実装
// ❌ パスキーのみに依存 function LoginForm() { return ( <div> <button onClick={loginWithPasskey}> ログイン </button> </div> ); }
段階的な導入
// ✅ 段階的な移行を促す function LoginForm() { const [hasPasskey, setHasPasskey] = useState(false); useEffect(() => { checkUserPasskeys().then(setHasPasskey); }, []); return ( <div> {hasPasskey ? ( <button onClick={loginWithPasskey} className="primary"> パスキーでログイン </button> ) : ( <> <input type="email" placeholder="メールアドレス" /> <input type="password" placeholder="パスワード" /> <button onClick={loginWithPassword}>ログイン</button> <button onClick={setupPasskey} className="secondary"> パスキーを設定してより安全に </button> </> )} </div> ); }

ユーザー教育とオンボーディング

// PasskeyOnboarding.tsx
function PasskeyOnboarding() {
  const [step, setStep] = useState(0);

  const steps = [
    {
      title: "パスキーとは?",
      content: "パスワードの代わりに顔認証や指紋認証でログインできる新しい方法です",
      icon: "🔐"
    },
    {
      title: "なぜ安全?",
      content: "パスキーはデバイスに保存され、フィッシング攻撃から完全に保護されます",
      icon: "🛡️"
    },
    {
      title: "簡単セットアップ",
      content: "わずか30秒で設定完了。今すぐ始めましょう!",
      icon: "⚡"
    }
  ];

  return (
    <div className="onboarding-modal">
      <div className="step-indicator">
        {steps.map((_, i) => (
          <div 
            key={i} 
            className={`dot ${i === step ? 'active' : ''}`}
          />
        ))}
      </div>
      
      <div className="step-content">
        <div className="icon">{steps[step].icon}</div>
        <h3>{steps[step].title}</h3>
        <p>{steps[step].content}</p>
      </div>

      <div className="actions">
        {step > 0 && (
          <button onClick={() => setStep(step - 1)}>戻る</button>
        )}
        {step < steps.length - 1 ? (
          <button onClick={() => setStep(step + 1)}>次へ</button>
        ) : (
          <button onClick={startPasskeySetup}>設定を開始</button>
        )}
      </div>
    </div>
  );
}

本番環境での運用と監視

メトリクスとKPI

パスキー導入の主要KPI
指標 目標値 測定方法
パスキー採用率 90日で25% 登録ユーザー数/全ユーザー数
認証成功率 99%以上 成功ログイン/全ログイン試行
平均認証時間 3秒以内 認証開始から完了までの時間
サポートチケット削減 50%削減 パスワード関連チケット数
フォールバック率 5%以下 パスワード使用/全認証

監視ダッシュボード

// 監視用のメトリクス収集
class PasskeyMetrics {
  async collectMetrics(): Promise<DashboardData> {
    const [adoption, performance, errors] = await Promise.all([
      this.getAdoptionMetrics(),
      this.getPerformanceMetrics(),
      this.getErrorMetrics()
    ]);

    return {
      adoption: {
        totalUsers: adoption.total,
        passkeyUsers: adoption.withPasskey,
        adoptionRate: (adoption.withPasskey / adoption.total) * 100,
        trend: adoption.trend
      },
      performance: {
        avgLoginTime: performance.avgTime,
        p95LoginTime: performance.p95Time,
        successRate: performance.successRate
      },
      errors: {
        byType: errors.byType,
        byBrowser: errors.byBrowser,
        trending: errors.trending
      }
    };
  }
}

トラブルシューティングガイド

よくある問題と解決方法

1. “SecurityError: The operation is insecure”

  • 原因:HTTPS でないか、localhost でない
  • 解決:本番環境では必ず HTTPS を使用

2. “NotAllowedError: The request is not allowed”

  • 原因:ユーザーがキャンセルまたはタイムアウト
  • 解決:明確な ui フィードバックとリトライオプション

3. “InvalidStateError: The authenticator is already registered”

  • 原因:同じ認証器が既に登録済み
  • 解決:excludeCredentials を適切に設定

4. ブラウザ互換性の問題

  • 対策:フィーチャー検出とフォールバック実装
if (!window.PublicKeyCredential) {
  // WebAuthn非対応ブラウザ
  showFallbackLogin();
}

今後の展望

パスキー同期の標準化

クロスプラットフォーム同期の統一仕様

条件付きUI普及

自動入力UIでのパスキー選択

パスワード廃止企業増加

Fortune 500の50%がパスワードレス化

規制要件化

金融・医療でパスキー必須化

まとめ

WebAuthn/Passkeys の実装は、セキュリティとユーザー体験の両方を劇的に改善する技術です。実装の複雑さはありますが、適切なライブラリとベストプラクティスに従うことで、確実に導入できます。

ブラウザサポート率 98 %
スマートフォン対応率 75 %
企業採用率(2025年) 60 %

重要なのは、段階的な移行戦略です。既存のパスワード認証と併用しながら、ユーザーを徐々にパスキーへ誘導することで、スムーズな移行が可能になります。

この記事は役に立ちましたか?

Daily Hackでは、開発者の皆様に役立つ情報を毎日発信しています。