パスキー認証(WebAuthn)完全実装ガイド2026


はじめに

パスワード認証は長年にわたりWebアプリケーションの標準であったが、フィッシング攻撃、パスワードリスト攻撃、ブルートフォース攻撃といった脅威に対して根本的な脆弱性を抱えている。2026年現在、Apple、Google、Microsoftの主要プラットフォームがパスキー(Passkey)を標準サポートし、パスワードレス認証は実用段階に入った。

本記事では、FIDO2/WebAuthn標準に基づくパスキー認証の仕組みを解説し、React + Node.js(Express)で実際に動作する認証システムを構築する方法を紹介する。

対象読者

  • パスワードレス認証をプロダクションに導入したいエンジニア
  • WebAuthn APIの内部動作を理解したい開発者
  • FIDO2標準に準拠したセキュリティ設計を学びたい方

前提知識

  • TypeScript/JavaScriptの基本的な知識
  • Node.js(Express)でのAPI開発経験
  • React(Hooks)の基本的な理解

パスキーとWebAuthnの基本概念

パスキーとは何か

パスキー(Passkey)は、FIDO Allianceが推進するパスワードレス認証技術である。公開鍵暗号方式を基盤とし、ユーザーのデバイスに保存された秘密鍵と、サーバーに保存された公開鍵のペアで認証を行う。

従来のパスワード認証との根本的な違いは、秘密情報(秘密鍵)がサーバーに送信されない点にある。これにより、サーバー側のデータ漏洩が発生しても認証情報が流出しない。

WebAuthn APIの役割

WebAuthn(Web Authentication)は、W3Cが策定したWeb標準APIであり、ブラウザとAuthenticator(認証器)間の通信プロトコルを定義する。パスキーの技術基盤となっている。

┌──────────────┐     ┌──────────────┐     ┌──────────────────┐
│   ブラウザ    │────▶│ WebAuthn API │────▶│  Authenticator    │
│(Relying     │◀────│              │◀────│(TouchID/FaceID/  │
│  Party Client│     │              │     │ Windows Hello/    │
│              │     │              │     │ セキュリティキー) │
└──────────────┘     └──────────────┘     └──────────────────┘
       │                                          │
       │         ┌──────────────┐                 │
       └────────▶│ RPサーバー    │◀────────────────┘
                 │(認証サーバー)│   Challenge/Response
                 └──────────────┘

FIDO2標準の構成要素

FIDO2は以下の2つの仕様で構成される。

構成要素役割管轄
WebAuthnブラウザとサーバー間の認証プロトコルW3C
CTAP2ブラウザとAuthenticator間の通信プロトコルFIDO Alliance

パスワード認証との比較

項目パスワード認証パスキー認証
フィッシング耐性なし(偽サイトで入力可能)あり(Origin紐づけ)
リプレイ攻撃耐性なしあり(Challenge方式)
サーバー漏洩リスクハッシュ化されていても危険公開鍵のみ(安全)
ユーザー体験パスワード記憶が必要生体認証で完了
クロスデバイスクラウド同期が必要プラットフォーム同期

開発環境のセットアップ

プロジェクト構成

passkey-auth/
├── server/
│   ├── src/
│   │   ├── index.ts
│   │   ├── routes/
│   │   │   └── auth.ts
│   │   ├── services/
│   │   │   └── webauthn.ts
│   │   └── models/
│   │       └── user.ts
│   ├── package.json
│   └── tsconfig.json
├── client/
│   ├── src/
│   │   ├── App.tsx
│   │   ├── hooks/
│   │   │   └── useWebAuthn.ts
│   │   └── components/
│   │       ├── RegisterForm.tsx
│   │       └── LoginForm.tsx
│   ├── package.json
│   └── tsconfig.json
└── README.md

サーバー側の初期化

mkdir -p passkey-auth/server && cd passkey-auth/server
npm init -y
npm install express cors express-session @simplewebauthn/server
npm install -D typescript @types/express @types/cors @types/express-session ts-node

tsconfig.jsonを作成する。

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

クライアント側の初期化

cd ../
npx create-react-app client --template typescript
cd client
npm install @simplewebauthn/browser

SimpleWebAuthnライブラリの概要

SimpleWebAuthnは、WebAuthn APIの複雑なバイナリ処理を抽象化し、TypeScript型付きのインターフェースを提供するライブラリである。サーバー側(@simplewebauthn/server)とクライアント側(@simplewebauthn/browser)の2パッケージで構成される。

主要な関数

// サーバー側(@simplewebauthn/server)
import {
  generateRegistrationOptions,   // 登録オプション生成
  verifyRegistrationResponse,    // 登録レスポンス検証
  generateAuthenticationOptions, // 認証オプション生成
  verifyAuthenticationResponse,  // 認証レスポンス検証
} from '@simplewebauthn/server';

// クライアント側(@simplewebauthn/browser)
import {
  startRegistration,     // 登録フロー開始
  startAuthentication,   // 認証フロー開始
  browserSupportsWebAuthn, // WebAuthnサポート確認
} from '@simplewebauthn/browser';

サーバー側の実装

ユーザーモデルの定義

まず、ユーザーと認証情報を管理するモデルを定義する。本番環境ではデータベースを使用するが、ここではインメモリストアで説明する。

// server/src/models/user.ts
import type {
  AuthenticatorTransportFuture,
  CredentialDeviceType,
} from '@simplewebauthn/types';

export interface StoredCredential {
  credentialID: string;
  credentialPublicKey: Uint8Array;
  counter: number;
  credentialDeviceType: CredentialDeviceType;
  credentialBackedUp: boolean;
  transports?: AuthenticatorTransportFuture[];
}

export interface User {
  id: string;
  username: string;
  credentials: StoredCredential[];
  currentChallenge?: string;
}

// インメモリストア(本番ではDB使用)
const users = new Map<string, User>();

export function findUserByUsername(username: string): User | undefined {
  for (const user of users.values()) {
    if (user.username === username) {
      return user;
    }
  }
  return undefined;
}

export function findUserById(id: string): User | undefined {
  return users.get(id);
}

export function createUser(username: string): User {
  const id = crypto.randomUUID();
  const user: User = {
    id,
    username,
    credentials: [],
  };
  users.set(id, user);
  return user;
}

export function addCredentialToUser(
  userId: string,
  credential: StoredCredential
): void {
  const user = users.get(userId);
  if (!user) {
    throw new Error('User not found');
  }
  user.credentials.push(credential);
}

export function updateUserChallenge(
  userId: string,
  challenge: string
): void {
  const user = users.get(userId);
  if (user) {
    user.currentChallenge = challenge;
  }
}

WebAuthnサービスの実装

WebAuthnの中核ロジックを担うサービス層を実装する。

// server/src/services/webauthn.ts
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type {
  GenerateRegistrationOptionsOpts,
  GenerateAuthenticationOptionsOpts,
  VerifyRegistrationResponseOpts,
  VerifyAuthenticationResponseOpts,
  VerifiedRegistrationResponse,
  VerifiedAuthenticationResponse,
} from '@simplewebauthn/server';
import type {
  RegistrationResponseJSON,
  AuthenticationResponseJSON,
} from '@simplewebauthn/types';
import type { User, StoredCredential } from '../models/user';

// Relying Party(自サイト)の設定
const rpName = 'My Passkey App';
const rpID = 'localhost';
const origin = 'http://localhost:3000';

export async function createRegistrationOptions(
  user: User
): Promise<ReturnType<typeof generateRegistrationOptions>> {
  const opts: GenerateRegistrationOptionsOpts = {
    rpName,
    rpID,
    userID: new TextEncoder().encode(user.id),
    userName: user.username,
    userDisplayName: user.username,
    // タイムアウト: 5分
    timeout: 300000,
    // 既存のクレデンシャルを除外(二重登録防止)
    excludeCredentials: user.credentials.map((cred) => ({
      id: cred.credentialID,
      type: 'public-key',
      transports: cred.transports,
    })),
    // 認証器の要件
    authenticatorSelection: {
      // プラットフォーム認証器を優先(TouchID等)
      authenticatorAttachment: 'platform',
      // Discoverable Credential必須(パスキー対応)
      residentKey: 'required',
      // ユーザー検証必須
      userVerification: 'required',
    },
    // サポートするアルゴリズム
    supportedAlgorithmIDs: [-7, -257], // ES256, RS256
  };

  return generateRegistrationOptions(opts);
}

export async function verifyRegistration(
  user: User,
  response: RegistrationResponseJSON,
  expectedChallenge: string
): Promise<VerifiedRegistrationResponse> {
  const opts: VerifyRegistrationResponseOpts = {
    response,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    requireUserVerification: true,
  };

  return verifyRegistrationResponse(opts);
}

export async function createAuthenticationOptions(
  user?: User
): Promise<ReturnType<typeof generateAuthenticationOptions>> {
  const opts: GenerateAuthenticationOptionsOpts = {
    timeout: 300000,
    rpID,
    userVerification: 'required',
    // ユーザーが特定されている場合、許可するクレデンシャルを指定
    ...(user && {
      allowCredentials: user.credentials.map((cred) => ({
        id: cred.credentialID,
        type: 'public-key' as const,
        transports: cred.transports,
      })),
    }),
  };

  return generateAuthenticationOptions(opts);
}

export async function verifyAuthentication(
  credential: StoredCredential,
  response: AuthenticationResponseJSON,
  expectedChallenge: string
): Promise<VerifiedAuthenticationResponse> {
  const opts: VerifyAuthenticationResponseOpts = {
    response,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: {
      id: credential.credentialID,
      publicKey: credential.credentialPublicKey,
      counter: credential.counter,
      transports: credential.transports,
    },
    requireUserVerification: true,
  };

  return verifyAuthenticationResponse(opts);
}

認証ルーターの実装

Express Router で登録・認証のエンドポイントを構築する。

// server/src/routes/auth.ts
import { Router, Request, Response } from 'express';
import {
  findUserByUsername,
  createUser,
  addCredentialToUser,
  updateUserChallenge,
} from '../models/user';
import {
  createRegistrationOptions,
  verifyRegistration,
  createAuthenticationOptions,
  verifyAuthentication,
} from '../services/webauthn';

const router = Router();

// ==========================================
// 登録フロー
// ==========================================

// Step 1: 登録オプションの取得
router.post(
  '/register/options',
  async (req: Request, res: Response): Promise<void> => {
    try {
      const { username } = req.body;

      if (!username || typeof username !== 'string') {
        res.status(400).json({ error: 'usernameは必須です' });
        return;
      }

      // 既存ユーザーチェック
      let user = findUserByUsername(username);
      if (!user) {
        user = createUser(username);
      }

      // 登録オプション生成
      const options = await createRegistrationOptions(user);

      // Challengeをセッションに保存
      updateUserChallenge(user.id, options.challenge);

      // セッションにユーザーIDを保存
      (req.session as any).userId = user.id;

      res.json(options);
    } catch (error) {
      console.error('登録オプション生成エラー:', error);
      res.status(500).json({ error: '内部エラーが発生しました' });
    }
  }
);

// Step 2: 登録レスポンスの検証
router.post(
  '/register/verify',
  async (req: Request, res: Response): Promise<void> => {
    try {
      const userId = (req.session as any).userId;
      if (!userId) {
        res.status(401).json({ error: 'セッションが無効です' });
        return;
      }

      const user = findUserByUsername(userId) ?? (() => {
        const { findUserById } = require('../models/user');
        return findUserById(userId);
      })();

      if (!user || !user.currentChallenge) {
        res.status(400).json({ error: 'チャレンジが見つかりません' });
        return;
      }

      const verification = await verifyRegistration(
        user,
        req.body,
        user.currentChallenge
      );

      if (verification.verified && verification.registrationInfo) {
        const {
          credential,
          credentialDeviceType,
          credentialBackedUp,
        } = verification.registrationInfo;

        // クレデンシャルをユーザーに紐づけて保存
        addCredentialToUser(user.id, {
          credentialID: credential.id,
          credentialPublicKey: credential.publicKey,
          counter: credential.counter,
          credentialDeviceType,
          credentialBackedUp,
          transports: req.body.response?.transports,
        });

        // Challengeをクリア
        updateUserChallenge(user.id, '');

        res.json({ verified: true });
      } else {
        res.status(400).json({ verified: false, error: '検証に失敗しました' });
      }
    } catch (error) {
      console.error('登録検証エラー:', error);
      res.status(500).json({ error: '内部エラーが発生しました' });
    }
  }
);

// ==========================================
// 認証フロー
// ==========================================

// Step 1: 認証オプションの取得
router.post(
  '/authenticate/options',
  async (req: Request, res: Response): Promise<void> => {
    try {
      const { username } = req.body;
      const user = username ? findUserByUsername(username) : undefined;

      // Discoverable Credentialsの場合はユーザー指定不要
      const options = await createAuthenticationOptions(user);

      // Challengeをセッションに保存
      if (user) {
        updateUserChallenge(user.id, options.challenge);
        (req.session as any).userId = user.id;
      }
      (req.session as any).challenge = options.challenge;

      res.json(options);
    } catch (error) {
      console.error('認証オプション生成エラー:', error);
      res.status(500).json({ error: '内部エラーが発生しました' });
    }
  }
);

// Step 2: 認証レスポンスの検証
router.post(
  '/authenticate/verify',
  async (req: Request, res: Response): Promise<void> => {
    try {
      const expectedChallenge = (req.session as any).challenge;
      if (!expectedChallenge) {
        res.status(401).json({ error: 'チャレンジが見つかりません' });
        return;
      }

      const { id: credentialID } = req.body;

      // クレデンシャルIDからユーザーを特定
      const { findUserById } = require('../models/user');
      let matchedUser = null;
      let matchedCredential = null;

      // 全ユーザーからクレデンシャルを検索
      // 本番ではDBクエリで最適化する
      const userId = (req.session as any).userId;
      if (userId) {
        const user = findUserById(userId);
        if (user) {
          matchedCredential = user.credentials.find(
            (c: any) => c.credentialID === credentialID
          );
          if (matchedCredential) {
            matchedUser = user;
          }
        }
      }

      if (!matchedUser || !matchedCredential) {
        res.status(400).json({ error: 'クレデンシャルが見つかりません' });
        return;
      }

      const verification = await verifyAuthentication(
        matchedCredential,
        req.body,
        expectedChallenge
      );

      if (verification.verified) {
        // カウンターを更新(リプレイ攻撃防止)
        matchedCredential.counter =
          verification.authenticationInfo.newCounter;

        // セッション確立
        (req.session as any).authenticated = true;
        (req.session as any).username = matchedUser.username;

        res.json({
          verified: true,
          username: matchedUser.username,
        });
      } else {
        res.status(400).json({ verified: false });
      }
    } catch (error) {
      console.error('認証検証エラー:', error);
      res.status(500).json({ error: '内部エラーが発生しました' });
    }
  }
);

export default router;

Expressサーバーのエントリーポイント

// server/src/index.ts
import express from 'express';
import cors from 'cors';
import session from 'express-session';
import authRouter from './routes/auth';

const app = express();
const PORT = 8080;

app.use(cors({
  origin: 'http://localhost:3000',
  credentials: true,
}));

app.use(express.json());

app.use(session({
  secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production',
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 24 * 60 * 60 * 1000, // 24時間
  },
}));

app.use('/api/auth', authRouter);

// ヘルスチェック
app.get('/health', (_req, res) => {
  res.json({ status: 'ok' });
});

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

クライアント側の実装

WebAuthnカスタムフックの作成

React Hooksで WebAuthn の登録・認証ロジックをカプセル化する。

// client/src/hooks/useWebAuthn.ts
import { useState, useCallback } from 'react';
import {
  startRegistration,
  startAuthentication,
  browserSupportsWebAuthn,
} from '@simplewebauthn/browser';

const API_BASE = 'http://localhost:8080/api/auth';

interface WebAuthnState {
  isSupported: boolean;
  isLoading: boolean;
  error: string | null;
  isAuthenticated: boolean;
  username: string | null;
}

export function useWebAuthn() {
  const [state, setState] = useState<WebAuthnState>({
    isSupported: browserSupportsWebAuthn(),
    isLoading: false,
    error: null,
    isAuthenticated: false,
    username: null,
  });

  const setLoading = (isLoading: boolean) =>
    setState((prev) => ({ ...prev, isLoading, error: null }));

  const setError = (error: string) =>
    setState((prev) => ({ ...prev, isLoading: false, error }));

  // パスキー登録
  const register = useCallback(async (username: string) => {
    setLoading(true);

    try {
      // Step 1: サーバーから登録オプションを取得
      const optionsRes = await fetch(`${API_BASE}/register/options`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: JSON.stringify({ username }),
      });

      if (!optionsRes.ok) {
        throw new Error('登録オプションの取得に失敗しました');
      }

      const options = await optionsRes.json();

      // Step 2: ブラウザのWebAuthn APIを呼び出し
      // ここでユーザーに生体認証のプロンプトが表示される
      const registrationResponse = await startRegistration({
        optionsJSON: options,
      });

      // Step 3: 登録レスポンスをサーバーに送信して検証
      const verifyRes = await fetch(`${API_BASE}/register/verify`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: JSON.stringify(registrationResponse),
      });

      const verifyResult = await verifyRes.json();

      if (verifyResult.verified) {
        setState((prev) => ({
          ...prev,
          isLoading: false,
          isAuthenticated: true,
          username,
        }));
        return true;
      } else {
        setError('パスキーの登録に失敗しました');
        return false;
      }
    } catch (err) {
      const message =
        err instanceof Error ? err.message : '不明なエラーが発生しました';
      setError(message);
      return false;
    }
  }, []);

  // パスキー認証
  const authenticate = useCallback(async (username?: string) => {
    setLoading(true);

    try {
      // Step 1: サーバーから認証オプションを取得
      const optionsRes = await fetch(`${API_BASE}/authenticate/options`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: JSON.stringify({ username }),
      });

      if (!optionsRes.ok) {
        throw new Error('認証オプションの取得に失敗しました');
      }

      const options = await optionsRes.json();

      // Step 2: ブラウザのWebAuthn APIを呼び出し
      const authResponse = await startAuthentication({
        optionsJSON: options,
      });

      // Step 3: 認証レスポンスをサーバーに送信して検証
      const verifyRes = await fetch(`${API_BASE}/authenticate/verify`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: JSON.stringify(authResponse),
      });

      const verifyResult = await verifyRes.json();

      if (verifyResult.verified) {
        setState((prev) => ({
          ...prev,
          isLoading: false,
          isAuthenticated: true,
          username: verifyResult.username,
        }));
        return true;
      } else {
        setError('認証に失敗しました');
        return false;
      }
    } catch (err) {
      const message =
        err instanceof Error ? err.message : '不明なエラーが発生しました';
      setError(message);
      return false;
    }
  }, []);

  // ログアウト
  const logout = useCallback(() => {
    setState((prev) => ({
      ...prev,
      isAuthenticated: false,
      username: null,
    }));
  }, []);

  return {
    ...state,
    register,
    authenticate,
    logout,
  };
}

登録コンポーネント

// client/src/components/RegisterForm.tsx
import React, { useState } from 'react';
import { useWebAuthn } from '../hooks/useWebAuthn';

export function RegisterForm() {
  const [username, setUsername] = useState('');
  const { register, isLoading, error, isSupported } = useWebAuthn();

  if (!isSupported) {
    return (
      <div className="error-banner">
        このブラウザはパスキー認証に対応していません。
        Chrome、Safari、Edgeの最新版をご利用ください。
      </div>
    );
  }

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!username.trim()) return;

    const success = await register(username.trim());
    if (success) {
      alert('パスキーの登録が完了しました');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <h2>パスキー登録</h2>
      <div>
        <label htmlFor="username">ユーザー名</label>
        <input
          id="username"
          type="text"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
          placeholder="ユーザー名を入力"
          disabled={isLoading}
          autoComplete="username webauthn"
        />
      </div>
      <button type="submit" disabled={isLoading || !username.trim()}>
        {isLoading ? '処理中...' : 'パスキーを登録'}
      </button>
      {error && <p className="error">{error}</p>}
    </form>
  );
}

認証コンポーネント

// client/src/components/LoginForm.tsx
import React, { useState } from 'react';
import { useWebAuthn } from '../hooks/useWebAuthn';

export function LoginForm() {
  const [username, setUsername] = useState('');
  const { authenticate, isLoading, error, isAuthenticated, username: authedUser } = useWebAuthn();

  if (isAuthenticated) {
    return (
      <div>
        <p>ログイン済み: {authedUser}</p>
      </div>
    );
  }

  const handleUsernameLogin = async (e: React.FormEvent) => {
    e.preventDefault();
    await authenticate(username.trim() || undefined);
  };

  // パスキー自動検出(Discoverable Credentials)
  const handlePasskeyLogin = async () => {
    await authenticate();
  };

  return (
    <div>
      <h2>ログイン</h2>

      {/* Discoverable Credentials: ユーザー名入力不要 */}
      <button onClick={handlePasskeyLogin} disabled={isLoading}>
        {isLoading ? '処理中...' : 'パスキーでログイン'}
      </button>

      <hr />

      {/* ユーザー名指定ログイン */}
      <form onSubmit={handleUsernameLogin}>
        <div>
          <label htmlFor="login-username">ユーザー名(任意)</label>
          <input
            id="login-username"
            type="text"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            placeholder="ユーザー名を入力"
            disabled={isLoading}
            autoComplete="username webauthn"
          />
        </div>
        <button type="submit" disabled={isLoading}>
          ユーザー名を指定してログイン
        </button>
      </form>

      {error && <p className="error">{error}</p>}
    </div>
  );
}

認証フローの詳細解説

登録フロー(Registration Ceremony)

パスキー登録は以下の4ステップで行われる。

  1. サーバーがChallengeを生成: ランダムなバイト列を生成し、セッションに保存する
  2. ブラウザがAuthenticatorに登録を要求: navigator.credentials.create() を呼び出す
  3. Authenticatorが鍵ペアを生成: 秘密鍵をデバイスに保存、公開鍵を含むAttestationObjectを返す
  4. サーバーがレスポンスを検証: Challenge一致、Origin一致、公開鍵の妥当性を確認し、クレデンシャルを保存する
// 登録フローの内部動作(SimpleWebAuthnが抽象化)
// navigator.credentials.create() に渡されるオプション
const publicKeyCredentialCreationOptions = {
  challenge: Uint8Array.from(serverChallenge),
  rp: {
    name: 'My Passkey App',
    id: 'localhost',
  },
  user: {
    id: Uint8Array.from(userId),
    name: 'user@example.com',
    displayName: 'User',
  },
  pubKeyCredParams: [
    { alg: -7, type: 'public-key' },   // ES256 (ECDSA P-256)
    { alg: -257, type: 'public-key' },  // RS256 (RSASSA-PKCS1-v1_5)
  ],
  authenticatorSelection: {
    authenticatorAttachment: 'platform',
    residentKey: 'required',
    userVerification: 'required',
  },
  timeout: 300000,
  attestation: 'none',
};

認証フロー(Authentication Ceremony)

  1. サーバーがChallengeを生成: 登録時と同様にランダムなバイト列を生成する
  2. ブラウザがAuthenticatorに認証を要求: navigator.credentials.get() を呼び出す
  3. Authenticatorが署名を生成: 保存された秘密鍵でChallengeに署名する
  4. サーバーが署名を検証: 保存された公開鍵で署名を検証し、カウンターを更新する

カウンター(署名回数)の重要性

WebAuthnの認証レスポンスには signCount(署名回数)が含まれる。サーバーは前回の値より大きいことを検証する。もし前回以下の値が送られてきた場合、クレデンシャルが複製された可能性がある。

// カウンター検証のロジック(SimpleWebAuthnが内部で実行)
function verifyCounter(
  storedCounter: number,
  responseCounter: number
): boolean {
  if (responseCounter > 0 || storedCounter > 0) {
    if (responseCounter <= storedCounter) {
      // 警告: クレデンシャルが複製された可能性
      console.warn(
        `Counter mismatch: stored=${storedCounter}, response=${responseCounter}`
      );
      return false;
    }
  }
  return true;
}

クレデンシャル管理の実装

複数パスキーの管理

ユーザーは複数のデバイスにパスキーを登録できる。管理UIでクレデンシャルの一覧表示と削除を実装する。

// server/src/routes/credentials.ts
import { Router, Request, Response } from 'express';
import { findUserById } from '../models/user';

const router = Router();

// クレデンシャル一覧の取得
router.get('/', (req: Request, res: Response): void => {
  const userId = (req.session as any).userId;
  if (!userId) {
    res.status(401).json({ error: '認証が必要です' });
    return;
  }

  const user = findUserById(userId);
  if (!user) {
    res.status(404).json({ error: 'ユーザーが見つかりません' });
    return;
  }

  // 秘密情報を除外してレスポンス
  const credentials = user.credentials.map((cred) => ({
    id: cred.credentialID,
    deviceType: cred.credentialDeviceType,
    backedUp: cred.credentialBackedUp,
    transports: cred.transports,
    // 最終使用日やデバイス名は別途管理が必要
  }));

  res.json({ credentials });
});

// クレデンシャルの削除
router.delete('/:credentialId', (req: Request, res: Response): void => {
  const userId = (req.session as any).userId;
  if (!userId) {
    res.status(401).json({ error: '認証が必要です' });
    return;
  }

  const user = findUserById(userId);
  if (!user) {
    res.status(404).json({ error: 'ユーザーが見つかりません' });
    return;
  }

  const index = user.credentials.findIndex(
    (c) => c.credentialID === req.params.credentialId
  );

  if (index === -1) {
    res.status(404).json({ error: 'クレデンシャルが見つかりません' });
    return;
  }

  // 最後の1つは削除不可(ロックアウト防止)
  if (user.credentials.length <= 1) {
    res.status(400).json({
      error: '最後のクレデンシャルは削除できません',
    });
    return;
  }

  user.credentials.splice(index, 1);
  res.json({ success: true });
});

export default router;

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

1. Origin検証の厳格化

本番環境では、Originを厳密に設定する。ワイルドカードやサブドメインの許可は最小限にする。

// 本番向けOrigin設定
const ALLOWED_ORIGINS = [
  'https://app.example.com',
  'https://www.example.com',
];

const rpID = 'example.com';

// 検証時にOriginリストを渡す
const verification = await verifyRegistrationResponse({
  response,
  expectedChallenge,
  expectedOrigin: ALLOWED_ORIGINS,
  expectedRPID: rpID,
  requireUserVerification: true,
});

2. Challengeの管理

Challengeは必ず一度きりの使用とし、有効期限を設ける。

// Redis等を使ったChallenge管理の例
import { createClient } from 'redis';

const redis = createClient();

async function storeChallenge(
  userId: string,
  challenge: string,
  ttlSeconds: number = 300
): Promise<void> {
  await redis.setEx(
    `webauthn:challenge:${userId}`,
    ttlSeconds,
    challenge
  );
}

async function consumeChallenge(
  userId: string
): Promise<string | null> {
  const key = `webauthn:challenge:${userId}`;
  const challenge = await redis.get(key);
  if (challenge) {
    await redis.del(key); // 一度使ったら即削除
  }
  return challenge;
}

3. Attestation(認証器の真正性検証)

高セキュリティが求められる場合、Attestationを検証して認証器の種類を制限できる。

// Attestation検証の設定
const options = await generateRegistrationOptions({
  // ...
  attestationType: 'direct', // Attestation証明書を要求
});

// 検証時にMetadata Serviceを使って認証器を確認
import { MetadataService } from '@simplewebauthn/server';

const metadataService = new MetadataService({
  mdsServers: [
    { url: 'https://mds3.fidoalliance.org' },
  ],
});

await metadataService.initialize();

4. レート制限の実装

認証エンドポイントには必ずレート制限を設ける。

import rateLimit from 'express-rate-limit';

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15分
  max: 10, // 最大10回
  message: {
    error: '認証試行回数の上限に達しました。しばらくお待ちください。',
  },
  standardHeaders: true,
  legacyHeaders: false,
});

// 認証エンドポイントに適用
app.use('/api/auth/authenticate', authLimiter);
app.use('/api/auth/register', authLimiter);

5. HTTPS必須化

WebAuthn APIはセキュアコンテキスト(HTTPS)でのみ動作する。localhost以外では必ずHTTPSを使用する。

// Helmet.jsによるセキュリティヘッダー設定
import helmet from 'helmet';

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      connectSrc: ["'self'"],
    },
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true,
  },
}));

データベーススキーマ設計

本番環境では、クレデンシャルをリレーショナルデータベースに保存する。以下はPostgreSQLのスキーマ例である。

-- ユーザーテーブル
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  username VARCHAR(255) UNIQUE NOT NULL,
  display_name VARCHAR(255),
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- クレデンシャルテーブル
CREATE TABLE webauthn_credentials (
  id VARCHAR(512) PRIMARY KEY,     -- Base64URLエンコードされたクレデンシャルID
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  public_key BYTEA NOT NULL,       -- 公開鍵(バイナリ)
  counter BIGINT NOT NULL DEFAULT 0,
  device_type VARCHAR(32) NOT NULL, -- 'singleDevice' or 'multiDevice'
  backed_up BOOLEAN NOT NULL DEFAULT FALSE,
  transports TEXT[],                -- ['internal', 'hybrid', 'usb', etc.]
  device_name VARCHAR(255),         -- ユーザーが設定するデバイス名
  last_used_at TIMESTAMP WITH TIME ZONE,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- インデックス
CREATE INDEX idx_credentials_user_id ON webauthn_credentials(user_id);
CREATE INDEX idx_credentials_last_used ON webauthn_credentials(last_used_at);
// Prismaを使ったスキーマ定義の例
// prisma/schema.prisma
/*
model User {
  id          String   @id @default(uuid())
  username    String   @unique
  displayName String?  @map("display_name")
  credentials WebAuthnCredential[]
  createdAt   DateTime @default(now()) @map("created_at")
  updatedAt   DateTime @updatedAt @map("updated_at")

  @@map("users")
}

model WebAuthnCredential {
  id              String   @id
  userId          String   @map("user_id")
  publicKey       Bytes    @map("public_key")
  counter         BigInt   @default(0)
  deviceType      String   @map("device_type")
  backedUp        Boolean  @default(false) @map("backed_up")
  transports      String[]
  deviceName      String?  @map("device_name")
  lastUsedAt      DateTime? @map("last_used_at")
  createdAt       DateTime @default(now()) @map("created_at")
  user            User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
  @@map("webauthn_credentials")
}
*/

条件付きUI(Conditional UI)の実装

Conditional UIは、ブラウザのオートコンプリートUIにパスキーの選択肢を表示する機能である。ユーザー名の入力フィールドにフォーカスしただけでパスキーの候補が表示される。

// Conditional UIの実装
import {
  startAuthentication,
  browserSupportsWebAuthnAutofill,
} from '@simplewebauthn/browser';

async function initConditionalUI() {
  // Conditional UIがサポートされているか確認
  const supported = await browserSupportsWebAuthnAutofill();
  if (!supported) {
    console.log('Conditional UI is not supported');
    return;
  }

  try {
    // サーバーから認証オプションを取得
    const optionsRes = await fetch('/api/auth/authenticate/options', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify({}),
    });
    const options = await optionsRes.json();

    // useBrowserAutofill: true でConditional UIを有効化
    const authResponse = await startAuthentication({
      optionsJSON: options,
      useBrowserAutofill: true,
    });

    // 認証レスポンスをサーバーに送信
    const verifyRes = await fetch('/api/auth/authenticate/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify(authResponse),
    });

    const result = await verifyRes.json();
    if (result.verified) {
      window.location.href = '/dashboard';
    }
  } catch (err) {
    console.error('Conditional UI error:', err);
  }
}

// ページ読み込み時に初期化
document.addEventListener('DOMContentLoaded', initConditionalUI);

HTMLの input 要素には autocomplete="username webauthn" を指定する。

<input
  type="text"
  name="username"
  autocomplete="username webauthn"
  placeholder="ユーザー名を入力"
/>

パスワードとの併用(ハイブリッド認証)

既存のパスワード認証からパスキーへ段階的に移行する場合、ハイブリッド認証が必要になる。

// server/src/routes/hybrid-auth.ts
import { Router, Request, Response } from 'express';
import bcrypt from 'bcrypt';
import { findUserByUsername } from '../models/user';
import { createAuthenticationOptions, verifyAuthentication } from '../services/webauthn';

const router = Router();

router.post('/login', async (req: Request, res: Response): Promise<void> => {
  const { username, password, webauthnResponse } = req.body;

  const user = findUserByUsername(username);
  if (!user) {
    res.status(401).json({ error: '認証に失敗しました' });
    return;
  }

  // パスキー認証が提供された場合
  if (webauthnResponse) {
    try {
      const challenge = (req.session as any).challenge;
      const credential = user.credentials.find(
        (c) => c.credentialID === webauthnResponse.id
      );

      if (credential && challenge) {
        const verification = await verifyAuthentication(
          credential,
          webauthnResponse,
          challenge
        );

        if (verification.verified) {
          credential.counter = verification.authenticationInfo.newCounter;
          (req.session as any).authenticated = true;
          res.json({ success: true, method: 'passkey' });
          return;
        }
      }
    } catch {
      // パスキー認証失敗時はパスワード認証にフォールバック
    }
  }

  // パスワード認証
  if (password) {
    // 注: 実際のユーザーモデルにはpasswordHashフィールドが必要
    const passwordHash = (user as any).passwordHash;
    if (passwordHash && await bcrypt.compare(password, passwordHash)) {
      (req.session as any).authenticated = true;

      // パスキー未登録の場合、登録を促す
      if (user.credentials.length === 0) {
        res.json({
          success: true,
          method: 'password',
          suggestPasskey: true,
        });
        return;
      }

      res.json({ success: true, method: 'password' });
      return;
    }
  }

  res.status(401).json({ error: '認証に失敗しました' });
});

export default router;

テストの実装

サーバー側のユニットテスト

// server/src/__tests__/webauthn.test.ts
import {
  generateRegistrationOptions,
  generateAuthenticationOptions,
} from '@simplewebauthn/server';
import { createRegistrationOptions, createAuthenticationOptions } from '../services/webauthn';
import { createUser } from '../models/user';

describe('WebAuthn Service', () => {
  describe('createRegistrationOptions', () => {
    it('正しいRPIDとユーザー情報で登録オプションを生成する', async () => {
      const user = createUser('testuser');
      const options = await createRegistrationOptions(user);

      expect(options.rp.name).toBe('My Passkey App');
      expect(options.rp.id).toBe('localhost');
      expect(options.user.name).toBe('testuser');
      expect(options.challenge).toBeTruthy();
      expect(options.pubKeyCredParams).toContainEqual(
        expect.objectContaining({ alg: -7 })
      );
    });

    it('既存のクレデンシャルを除外リストに含める', async () => {
      const user = createUser('testuser2');
      user.credentials.push({
        credentialID: 'existing-cred-id',
        credentialPublicKey: new Uint8Array(32),
        counter: 0,
        credentialDeviceType: 'singleDevice',
        credentialBackedUp: false,
      });

      const options = await createRegistrationOptions(user);
      expect(options.excludeCredentials).toHaveLength(1);
    });
  });

  describe('createAuthenticationOptions', () => {
    it('ユーザー指定なしでDiscoverableCredentials用オプションを生成する', async () => {
      const options = await createAuthenticationOptions();

      expect(options.challenge).toBeTruthy();
      expect(options.rpId).toBe('localhost');
      expect(options.userVerification).toBe('required');
    });
  });
});

まとめ

本記事では、パスキー(Passkey)認証をReact + Node.jsで実装する方法を解説した。以下に要点をまとめる。

導入のポイント

  • SimpleWebAuthnライブラリを使うことで、WebAuthn APIの複雑なバイナリ処理を抽象化できる
  • Discoverable Credentialsを有効にすることで、ユーザー名入力不要のパスキーログインが実現する
  • Conditional UIにより、既存のログインフォームにパスキーの選択肢をシームレスに統合できる
  • ハイブリッド認証で既存のパスワード認証から段階的に移行できる

セキュリティ上の注意点

  • Challengeは必ず一度きりの使用とし、有効期限を設ける
  • カウンター検証を実装してリプレイ攻撃を防止する
  • 本番環境ではOriginとRPIDを厳密に設定する
  • レート制限を必ず実装する
  • クレデンシャルの最後の1つは削除不可とし、ロックアウトを防止する

今後の展望

2026年はパスキーのクロスデバイス同期がさらに普及し、iCloudキーチェーン、Googleパスワードマネージャー、Windows Helloの相互運用性が向上している。新規プロジェクトでは、パスワード認証を設けずパスキーのみで認証を完結させる設計も現実的な選択肢となっている。

パスキー認証の導入は、ユーザー体験の向上とセキュリティの強化を同時に実現する。本記事のコードを基盤として、自プロジェクトへの導入を検討してほしい。

参考資料


関連記事