IBM Verifyで実装するPasskey認証 - 既存アプリへの追加実装ガイド
1. はじめに
本記事では、IBM VerifyのFIDO2 APIを使用してPasskey認証を実装する方法を、実際のコード例とともに詳しく解説します。
この記事について
本記事は、IBM BOB(AI駆動型開発アシスタント)を活用して執筆・実装されています。IBM BOBは、コード生成、デバッグ、ドキュメント作成などを支援し、開発生産性を大幅に向上させるツールです。
- IBM BOBとは?: https://www.ibm.com/jp-ja/new/announcements/ibm-project-bob
- トライアル申し込み: https://www.ibm.com/products/bob#Form
本記事で学べること
- IBM Verify FIDO2 APIの具体的な使い方
- Passkey登録・認証フローの実装方法
- データベース、Redisが必要な理由
- セキュリティ考慮事項とベストプラクティス
前提知識
- TypeScript/JavaScriptの基礎知識
- REST APIの基本的な理解
- 非同期処理(async/await)の理解
動作環境
- Node.js 18以上
- PostgreSQL 14以上
- Redis 7以上
- Docker & Docker Compose
- IBM Verifyテナント
Passkeyとは
従来のパスワード認証には以下の課題があります:
- パスワードの使い回しによる情報漏洩リスク
- フィッシング攻撃への脆弱性
- パスワード管理の煩雑さ
Passkeyはこれらの課題を解決する次世代の認証方式です:
- パスワード不要: 生体認証(指紋・顔認証)やPINで認証
- フィッシング耐性: ドメインに紐付けられ、偽サイトでは動作しない
- 秘密鍵の保護: 秘密鍵はデバイス内に保存され、外部に出ない
WebAuthnとFIDO2の関係
Passkey認証に関連する主要な用語とその役割を整理します。
| 用語 | 説明 | 役割 |
|---|---|---|
| WebAuthn | W3C標準のブラウザAPI | ブラウザと認証器(Authenticator)の通信 |
| FIDO2 | FIDO Allianceの認証規格 | WebAuthnとCTAPを含む包括的な規格 |
| CTAP | 認証器とクライアントの通信プロトコル | USBキーなどの外部認証器用 |
| Passkey | FIDO2認証の実装 | ユーザー向けの呼称 |
IBM Verifyの役割
IBM VerifyはFIDO2準拠のPasskey認証を実現するためのAPIを提供します。アプリケーションはこれらのAPIを呼び出すことで、複雑な暗号処理を自前で実装することなく、セキュアなPasskey認証を実装できます。
IBM Verify APIが提供する機能:
- チャレンジ生成API:ランダムな文字列を生成(図中: ステップ3)
- 公開鍵の保存API:ユーザーの公開鍵を安全に保管(図中: ステップ9で使用)
- 署名検証API:秘密鍵で署名されたか検証(図中: ステップ8-9)
- クレデンシャル管理API:登録済みPasskeyの管理(図中: ステップ8-9で参照)
Webアプリケーション側で実装が必要な点:
- ユーザー管理:データベースでユーザー情報を管理(図中: ステップ1、ステップ10で使用)
- セッション管理:JWTトークンでセッションを管理(図中: ステップ10「JWT発行」)
- チャレンジ管理:Redisで一時的なチャレンジを保存(図中: ステップ4-7のチャレンジ保持)
- フロントエンド実装:WebAuthn APIの呼び出し(図中: ステップ5-6「WebAuthn API呼び出し」)
既存システムへのPasskey認証追加
既存のID/パスワード認証システムにPasskey認証を追加する際の認証フローの変化を示します。従来の認証方式と比較することで、Passkeyがもたらすセキュリティと利便性の向上が明確になります。
Before: 従来のID/パスワード認証
従来のID/パスワード認証の課題:
- パスワードの記憶・管理が必要
- フィッシング攻撃のリスク
- パスワード漏洩による不正アクセスの可能性
- ユーザー体験の低下(入力の手間)
After: Passkey認証追加後
Passkey認証による改善点:
- パスワード不要(生体認証やPINで認証)
- フィッシング耐性(公開鍵暗号方式)
- 秘密鍵がデバイス内に保存され、サーバーに送信されない
- シームレスなユーザー体験
- IBM Verifyによる安全な鍵管理
IBM Verify APIを利用するメリット
IBM VerifyはFIDO2サーバーとしての機能をREST APIとして提供します。アプリケーションはこれらのAPIを呼び出すだけで、以下のメリットが得られます:
-
FIDO2準拠の実装が容易
- WebAuthnの複雑な仕様を意識せずに実装可能
- チャレンジ生成・署名検証などの暗号処理をIBM Verify APIが処理
-
セキュリティの向上
- エンタープライズグレードのセキュリティ基盤
- 公開鍵の安全な保存と管理
- 定期的なセキュリティアップデート
-
開発工数の削減
- WebAuthnの低レベルAPIを直接扱う必要がない
- 認証フローの実装が簡素化
- テストとデバッグが容易
-
スケーラビリティ
- クラウドベースのインフラで自動スケール
- 大規模ユーザーにも対応可能
- 高可用性の保証
-
マルチデバイス対応
- 複数のPasskeyを1ユーザーに紐付け可能
- デバイス間でのシームレスな認証体験
本記事のサンプルコードは、IBM BOBを使って実装した実際に動作するアプリケーションから抽出したものです。IBM BOBの支援により、効率的かつ高品質な実装を実現しています。
2. Passkey実装に必要なステップ(概要)
全体アーキテクチャ
本システムは、フロントエンド(React)、バックエンド(Express)、データベース(PostgreSQL/Redis)、IBM Verify APIの4つのコンポーネントで構成されます。
実装に必要な4つの要素
-
FIDO2サーバー(IBM Verify)
- チャレンジ生成と署名検証を担当
- 公開鍵の安全な保管
-
バックエンド(Express API)
- ユーザー管理とセッション管理
- IBM Verify APIとの連携
-
データベース(PostgreSQL)
- ユーザー情報の永続化
- Passkey情報の管理
-
一時ストレージ(Redis)
- チャレンジの一時保存(5分間)
- リプレイ攻撃の防止
実装の流れ(5ステップ)
-
環境構築
- Docker、PostgreSQL、Redisのセットアップ
- IBM Verifyテナントの準備
-
IBM Verify連携設定
- API Clientの作成
- Relying Partyの設定
- → IBM Verify管理コンソールでの設定作業
-
Passkey登録実装
- バックエンド: IBM Verify APIでチャレンジ取得
- フロントエンド: WebAuthn APIで認証器と通信
- バックエンド: IBM Verify APIで登録完了
-
Passkey認証実装
- バックエンド: IBM Verify APIでチャレンジ取得
- フロントエンド: WebAuthn APIで認証器と通信
- バックエンド: IBM Verify APIで認証完了
-
セキュリティ強化
- チャレンジ管理(Redis)
- カウンター検証
- JWTトークン管理
3. IBM Verifyのセットアップ
テナントの準備
前提条件:
- IBM Verifyのテナントアカウント
- 管理者権限
API Clientの作成
手順:
- IBM Verifyテナントにログイン
- Security → API accessに移動
- Add API clientをクリック
- 以下の権限を付与:
- ☑
manageEnrollMFAMethodAnyUser(Passkey登録管理 - 必須) - ☑
manageEnrollMFAMethod(Passkey登録管理 - 必須) - ☑
readEnrollMFAMethodAnyUser(Passkey情報取得 - 必須) - ☑
readEnrollMFAMethod(Passkey情報取得 - 必須) - ☑
authnAnyUser(認証処理 - 必須) - ☑
manageAllUserGroups(SCIM API用 - 必須)
- ☑
- Client IDとClient Secretを取得
Relying Partyの作成
手順:
- Authentication → FIDO2 Settingsに移動
- Create relying partyをクリック
- 以下を設定:
Relying Partyの基本設定項目です。RP IDとOriginは認証時の検証に使用されます。
| 設定項目 | 値 | 説明 |
|---|---|---|
| Display Name | Your App Name | アプリケーション名 |
| Relying party identifier | verify-passkey-demo | ドメイン名(本番環境では実際のドメイン) |
| Device | Include all device metadata and metadata services | デバイス設定 |
| Allowed origins | https://verify-passkey-demo | アプリケーションのURL |
- Relying Party UUIDを取得
アプリケーションの環境変数設定
IBM Verifyから取得した情報を、アプリケーション(バックエンド)の環境変数として設定します。
これは設定例です。アプリケーションの実装にあわせて設定方法含めて見直しください。
# IBM Verify Configuration
ISV_TENANT_URL=https://your-tenant.verify.ibm.com
ISV_CLIENT_ID=your-client-id
ISV_CLIENT_SECRET=your-client-secret
ISV_RP_ID=verify-passkey-demo
ISV_RP_ORIGIN=https://verify-passkey-demo
IBM Verifyから取得した値をアプリケーションに設定する環境変数の一覧です。
| 変数名 | 説明 | IBM Verifyでの取得方法 |
|---|---|---|
| ISV_TENANT_URL | テナントURL | テナント管理画面から確認 |
| ISV_CLIENT_ID | API ClientのID | API Client作成時に取得 |
| ISV_CLIENT_SECRET | API Clientのシークレット | API Client作成時に取得 |
| ISV_RP_ID | Relying PartyのID | Relying Party設定で指定した値 |
| ISV_RP_ORIGIN | Relying PartyのOrigin | Relying Party設定で指定した値 |
4. データベース設計
なぜデータベースが必要か
IBM Verify APIだけでは不十分な理由:
- IBM Verify APIはPasskeyの公開鍵の保存・検証機能のみを提供
- アプリケーション固有のユーザー情報(プロフィール、権限等)の管理機能は提供されない
- Credential IDとアプリケーションユーザーの紐付けは自前で実装が必要
Passkey機能追加で必要なデータ:
- Credential IDとユーザーの紐付け
- 認証カウンター(リプレイ攻撃防止用)
- 最終使用日時などのメタデータ
既存のusersテーブル(参考)
既存アプリケーションに存在するユーザー管理テーブルです。Passkey機能では、このテーブルの一部フィールドを参照します。
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP WITH TIME ZONE,
is_active BOOLEAN DEFAULT true
);
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);
Passkey機能で使用するフィールド:
| フィールド | 型 | Passkey機能での用途 |
|---|---|---|
| id | UUID | passkey_credentialsテーブルとの紐付け |
| username | VARCHAR(255) | IBM Verify User ID取得時のSCIM検索キー |
| display_name | VARCHAR(255) | Passkey登録時のdisplayNameパラメータ |
password_hash、email等の他のフィールドは既存のパスワード認証で使用されますが、Passkey機能では使用しません。
passkey_credentialsテーブル(新規追加)
Passkey機能のために新規作成するテーブルです。各ユーザーが登録したPasskey情報を保存します。1ユーザーが複数のPasskeyを登録できます。
CREATE TABLE IF NOT EXISTS passkey_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,
counter BIGINT NOT NULL DEFAULT 0,
device_type VARCHAR(50),
backed_up BOOLEAN DEFAULT false,
transports TEXT[],
aaguid TEXT,
credential_name VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP WITH TIME ZONE,
is_active BOOLEAN DEFAULT true
);
CREATE INDEX idx_passkey_credentials_user_id ON passkey_credentials(user_id);
CREATE INDEX idx_passkey_credentials_credential_id ON passkey_credentials(credential_id);
| フィールド | 型 | 説明 |
|---|---|---|
| user_id | UUID | usersテーブルへの外部キー |
| credential_id | TEXT | WebAuthn Credential ID(Base64URL) |
| counter | BIGINT | 署名カウンター(リプレイ攻撃防止) |
| credential_name | VARCHAR(255) | ユーザーが設定したPasskey名 |
| transports | TEXT[] | サポートされる転送方式 |
設計のポイント
- credential_idをUNIQUE制約: 同じPasskeyの重複登録を防止
- ON DELETE CASCADE: ユーザー削除時にPasskeyも自動削除
- counterフィールド: リプレイ攻撃防止に必須
- インデックス: user_idとcredential_idに高速検索用インデックス
public_keyフィールドは不要です。IBM Verifyが公開鍵を保存し、署名検証も実行するためです。
5. チャレンジ管理(Redis)
なぜRedisが必要か
メモリ内保存の理由:
- チャレンジは一時的なデータ(5分間のみ有効)
- 高速なアクセスが必要
- 自動削除機能(TTL)が必要
TTL(有効期限)の必要性:
- リプレイ攻撃の防止
- 古いチャレンジの自動削除
- メモリの効率的な使用
Redisクライアントの設定
import { createClient } from 'redis';
const redisClient = createClient({
socket: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
},
password: process.env.REDIS_PASSWORD,
});
redisClient.on('error', (err) => {
console.error('Redis Client Error:', err);
});
export async function connectRedis(): Promise<void> {
await redisClient.connect();
console.log('Redis connected successfully');
}
export { redisClient };
チャレンジの保存
// 登録用チャレンジの保存
const challengeKey = `challenge:registration:${userId}`;
await redisClient.setEx(challengeKey, 300, challenge); // 5分間有効
// 認証用チャレンジの保存(ユーザー名なし)
const challengeKey = `challenge:auth:usernameless:${challenge}`;
await redisClient.setEx(challengeKey, 300, challenge);
キー命名規則:
Redisに保存するチャレンジのキー形式と有効期限を定義します。
| 用途 | キー形式 | TTL |
|---|---|---|
| 登録 | challenge:registration:{userId} |
300秒 |
| 認証(ユーザー名あり) | challenge:auth:{userId} |
300秒 |
| 認証(ユーザー名なし) | challenge:auth:usernameless:{challenge} |
300秒 |
チャレンジの取得と削除
// チャレンジの取得
const expectedChallenge = await redisClient.get(challengeKey);
if (!expectedChallenge) {
throw new Error('Challenge expired or not found');
}
// 使用後は即座に削除(リプレイ攻撃防止)
await redisClient.del(challengeKey);
チャレンジは必ず使用後に削除してください。削除しないとリプレイ攻撃のリスクがあります。
6. 既存のセッション管理(参考)
既存アプリのJWT実装
既存アプリケーションでは、ID/パスワード認証後のセッション管理にJWTを使用しています。Passkey機能では、この既存のJWT実装をそのまま活用します。
この章で説明するコードは既存アプリに実装済みのものです。Passkey機能追加のために新規実装する必要はありません。
既存のJWT実装の特徴:
-
統一されたセッション管理
- ID/パスワード認証とPasskey認証で同じトークン形式を使用
- 認証方式に関わらず、同じ認証ミドルウェアで検証
-
ステートレス認証
- サーバー側でセッション状態を保持不要
- スケーラビリティの向上
-
セキュリティ
- 署名により改ざん検知が可能
- 有効期限の設定が容易
JWTペイロードに含まれる情報:
- ユーザーID(userId)
- ユーザー名(username)
- メールアドレス(email)
- 発行日時(iat)と有効期限(exp)
既存システムとの統合について
既存システムが別のセッション管理方式(Cookie-based sessionなど)を使用している場合は、そちらに統合することも可能です。本記事ではJWTを例として説明します。
JWTトークンの生成
import jwt, { SignOptions } from 'jsonwebtoken';
export interface JwtPayload {
userId: string;
username: string;
email: string;
}
export function generateToken(payload: JwtPayload): string {
const options: SignOptions = {
expiresIn: '24h',
};
return jwt.sign(payload, process.env.JWT_SECRET!, options);
}
トークンペイロード例:
{
"userId": "123e4567-e89b-12d3-a456-426614174000",
"username": "alice",
"email": "alice@example.com",
"iat": 1705315200,
"exp": 1705401600
}
JWTトークンの検証(既存実装)
既存アプリでは、以下のようにJWTトークンを検証しています。
export function verifyToken(token: string): JwtPayload {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
return decoded;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new Error('Token expired');
}
if (error instanceof jwt.JsonWebTokenError) {
throw new Error('Invalid token');
}
throw error;
}
}
認証ミドルウェア(既存実装)
既存アプリの認証ミドルウェアです。Passkey認証後のAPI呼び出しでも、このミドルウェアを使用します。
import { Request, Response, NextFunction } from 'express';
import { verifyToken } from '../utils/jwt';
export interface AuthRequest extends Request {
user?: {
userId: string;
username: string;
email: string;
};
}
export const authenticate = (
req: AuthRequest,
res: Response,
next: NextFunction
): void => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const token = authHeader.substring(7);
try {
const payload = verifyToken(token);
req.user = payload;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
};
使用例:
// 認証が必要なエンドポイント
router.post('/api/passkey/register/start', authenticate, registerStart);
7. ユーザーとPasskeyの紐付け
なぜ両方で管理が必要か
IBM VerifyがFIDO2準拠の認証処理を担当し、データベースがアプリケーション固有のユーザー管理とビジネスロジックを担当するという役割分担により、セキュリティと柔軟性を両立できます。
IBM Verifyとデータベースの役割分担:
各データをどこで管理するかを明確に分けることで、セキュリティと機能性を両立します。
| 管理場所 | 管理内容 | 理由 |
|---|---|---|
| IBM Verify | 公開鍵、署名検証 | FIDO2サーバーとしての役割 |
| データベース | Credential ID、ユーザー紐付け | アプリケーション固有の情報 |
| データベース | カウンター値 | リプレイ攻撃防止 |
データフロー全体図
Passkey登録と認証の全体的なデータフローを示します。各コンポーネント間でどのようにデータがやり取りされるかを理解できます。
8. IBM Verify APIの概要
APIの全体像
IBM VerifyはFIDO2サーバーとしての機能をREST APIで提供します。アプリケーションはこれらのAPIエンドポイントを呼び出すことで、Passkey認証を実装できます。
主要なAPIエンドポイントと用途:
| API | 用途 | エンドポイント |
|---|---|---|
| OAuth 2.0 | アクセストークン取得 | /v1.0/endpoint/default/token |
| SCIM | ユーザー管理 | /v2.0/Users |
| FIDO2登録開始 | チャレンジ取得 | /v2.0/factors/fido2/relyingparties/{rpUuid}/attestation/options |
| FIDO2登録完了 | 公開鍵保存 | /v2.0/factors/fido2/relyingparties/{rpUuid}/attestation/result |
| FIDO2認証開始 | チャレンジ取得 | /v2.0/factors/fido2/relyingparties/{rpUuid}/assertion/options |
| FIDO2認証完了 | 署名検証 | /v2.0/factors/fido2/relyingparties/{rpUuid}/assertion/result |
OAuth 2.0認証
トークン取得
HTTPリクエスト例:
POST /v1.0/endpoint/default/token HTTP/1.1
Host: your-tenant.verify.ibm.com
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=your-client-id
&client_secret=your-client-secret
実装例:
private async getAccessToken(): Promise<string> {
const now = Date.now();
// キャッシュされたトークンが有効ならそれを返す
if (this.accessToken && this.tokenExpiry > now) {
return this.accessToken;
}
try {
const response = await axios.post(
`${verifyConfig.tenantUrl}/v1.0/endpoint/default/token`,
new URLSearchParams({
grant_type: 'client_credentials',
client_id: verifyConfig.clientId,
client_secret: verifyConfig.clientSecret,
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
this.accessToken = response.data.access_token;
// 有効期限の1分前に更新
this.tokenExpiry = now + (response.data.expires_in * 1000) - 60000;
return this.accessToken!;
} catch (error) {
logger.error('Failed to get access token', error);
throw new Error('Failed to authenticate with IBM Verify');
}
}
レスポンス例:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid"
}
トークンの使用
constructor() {
this.axiosInstance = axios.create({
baseURL: verifyConfig.tenantUrl,
timeout: 30000,
});
// リクエストインターセプターでトークンを自動付与
this.axiosInstance.interceptors.request.use(async (config) => {
const token = await this.getAccessToken();
config.headers.Authorization = `Bearer ${token}`;
return config;
});
}
API呼び出しの基本パターン
9. Passkey登録フロー
全体の流れ
Passkey登録は4つの主要ステップで完了します。IBM Verify APIとWebAuthn APIを連携させて、安全な認証情報を生成・保存します。
詳細なデータフロー(参考)
以下の図は、各ステップ内で発生するリクエスト/レスポンスの詳細なフローを示しています。
ステップ1: IBM Verify User ID取得(図の1-2)
なぜ必要か
IBM Verify User IDの役割:
- IBM Verify内部でユーザーを一意に識別
- Passkey登録時に必須のパラメータ
- データベースのユーザーIDとは別物
SCIM APIでユーザー検索
HTTPリクエスト例:
GET /v2.0/Users?filter=userName eq "alice" HTTP/1.1
Host: your-tenant.verify.ibm.com
Authorization: Bearer {access_token}
レスポンス例:
{
"totalResults": 1,
"Resources": [
{
"id": "755002237F",
"userName": "alice",
"name": {
"givenName": "Alice",
"familyName": "Smith"
}
}
]
}
実装例:
async getUserIdByUserName(userName: string): Promise<string | null> {
try {
const response = await this.axiosInstance.get('/v2.0/Users', {
params: {
filter: `userName eq "${userName}"`,
},
});
if (response.data.totalResults === 0) {
return null;
}
return response.data.Resources[0].id;
} catch (error) {
logger.error('Failed to get user ID from IBM Verify', { userName, error });
throw new Error('Failed to retrieve user information from IBM Verify');
}
}
ステップ2: 登録開始(attestation/options)(図の3-4)
何をするAPI?
IBM Verify APIのattestation/optionsエンドポイントの役割:
- サーバー側でランダムなチャレンジを生成
- WebAuthn登録に必要なパラメータをレスポンスとして返却
- アプリケーションはこのレスポンスをブラウザのWebAuthn APIに渡す
リクエスト
HTTPリクエスト例:
POST /v2.0/factors/fido2/relyingparties/{rpUuid}/attestation/options HTTP/1.1
Host: your-tenant.verify.ibm.com
Authorization: Bearer {access_token}
Content-Type: application/json
{
"userId": "755002237F",
"displayName": "Alice",
"authenticatorSelection": {
"requireResidentKey": true,
"userVerification": "preferred"
},
"attestation": "none"
}
パラメータ詳細:
各パラメータの意味と設定値を以下の表で説明します。
| パラメータ | 必須 | 説明 |
|---|---|---|
| userId | ○ | IBM Verify User ID(SCIM APIで取得) |
| displayName | ○ | ユーザーの表示名 |
| requireResidentKey | ○ |
trueでパスワードレス認証が可能 |
| userVerification | ○ |
preferredで生体認証を推奨 |
| attestation | ○ |
noneで証明書不要 |
レスポンス
{
"challenge": "Ho_xVdyDYEC5ztp_LCfEIwTMwbAaks_wpM497uhH_A8",
"rp": {
"id": "verify-passkey-demo",
"name": "IBM"
},
"user": {
"id": "NzU1MDAyMjM3Rg",
"name": "alice",
"displayName": "Alice"
},
"pubKeyCredParams": [
{ "type": "public-key", "alg": -7 },
{ "type": "public-key", "alg": -257 }
],
"timeout": 240000,
"authenticatorSelection": {
"requireResidentKey": true,
"userVerification": "preferred"
},
"attestation": "none"
}
レスポンスフィールド解説:
WebAuthn APIに渡す主要なフィールドの意味を説明します。
| フィールド | 説明 |
|---|---|
| challenge | ランダムに生成されたチャレンジ(Base64URL) |
| rp.id | Relying Party ID(ドメイン名) |
| user.id | IBM Verify User ID(Base64エンコード) |
| pubKeyCredParams | サポートされる公開鍵アルゴリズム |
| timeout | タイムアウト時間(ミリ秒) |
バックエンド実装
async initiateRegistration(
userId: string,
displayName: string
): Promise<VerifyRegistrationInitResponse> {
const requestBody = {
userId: userId,
displayName: displayName,
authenticatorSelection: {
requireResidentKey: true,
userVerification: "preferred"
},
attestation: "none"
};
const response = await this.axiosInstance.post(
`/v2.0/factors/fido2/relyingparties/${verifyConfig.rpUuid}/attestation/options`,
requestBody
);
return response.data;
}
コントローラー実装:
export const registerStart = async (
req: AuthRequest,
res: Response
): Promise<void> => {
const userId = req.user?.userId;
const username = req.user?.username;
// ユーザー情報取得
const userResult = await pool.query(
'SELECT display_name FROM users WHERE id = $1',
[userId]
);
const displayName = userResult.rows[0].display_name || username;
// IBM Verify User IDを取得
const ibmVerifyUserId = await verifyUserManagementService.getUserIdByUserName(username);
if (!ibmVerifyUserId) {
res.status(404).json({
error: 'IBM Verify user not found. Please ensure user is registered.',
});
return;
}
// 登録開始
const options = await verifyAuthService.initiateRegistration(
ibmVerifyUserId,
displayName
);
// チャレンジをRedisに保存
const challengeKey = `challenge:registration:${userId}`;
await redisClient.setEx(challengeKey, 300, options.challenge);
res.json({ success: true, options });
};
ステップ3: WebAuthn API呼び出し(図の5-6)
フロントエンド実装
export async function registerPasskey(deviceName?: string): Promise<void> {
// 1. サーバーから登録オプションを取得
const { options } = await api.passkeyRegisterStart();
// 2. JSON形式をブラウザAPI用に変換
const publicKeyOptions = convertCreationOptions(options);
// 3. ブラウザの標準WebAuthn APIを使用して認証器と通信
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions,
});
if (!credential || credential.type !== 'public-key') {
throw new Error('認証器からの応答が無効です');
}
// 4. ブラウザのCredentialをJSON形式に変換
const credentialJSON = convertRegistrationCredential(
credential as PublicKeyCredential
);
// 5. サーバーに認証情報を送信して登録完了
await api.passkeyRegisterComplete({
credential: credentialJSON,
deviceName,
});
}
何が起こる?
- ブラウザがデバイスの認証器(Authenticator: Touch ID、Windows Hello等)を呼び出し
- ユーザーが生体認証またはPINで本人確認
- 認証器が公開鍵・秘密鍵ペアを生成
- 秘密鍵はデバイス内のセキュアエンクレーブに保存
- 公開鍵と署名データをブラウザに返却
ステップ4: 登録完了(attestation/result)(図の7-10)
何をするAPI?
このAPIの役割:
- ブラウザが生成した公開鍵を検証
- チャレンジの署名を検証
- 公開鍵をIBM Verifyのデータベースに保存
リクエスト
HTTPリクエスト例:
POST /v2.0/factors/fido2/relyingparties/{rpUuid}/attestation/result HTTP/1.1
Host: your-tenant.verify.ibm.com
Authorization: Bearer {access_token}
Content-Type: application/json
{
"id": "JI_m6aKOfu4I74WVx9igSbUct1SLPkukVa-vUVvxixs",
"rawId": "JI_m6aKOfu4I74WVx9igSbUct1SLPkukVa-vUVvxixs",
"response": {
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIi...",
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRo..."
},
"type": "public-key",
"nickname": "My iPhone",
"enabled": true
}
レスポンス
{
"id": "7046c263-0807-4e4c-a1e6-be992075a87e",
"type": "fido2",
"userId": "755002237F",
"enabled": true,
"validated": true,
"created": "2026-02-05T01:07:08.589Z",
"attributes": {
"credentialId": "JI_m6aKOfu4I74WVx9igSbUct1SLPkukVa-vUVvxixs",
"credentialPublicKey": "v2EzYi03Yi0xAWItMlggZ96N9sjv...",
"counter": 0,
"nickname": "My iPhone"
}
}
バックエンド実装
サービス層実装:
/**
* Complete FIDO registration
* POST /v2.0/factors/fido2/relyingparties/{rpUuid}/attestation/result
*/
async completeRegistration(
credentialData: any,
nickname: string = 'My Passkey'
): Promise<VerifyRegistrationCompleteResponse> {
const requestBody = {
id: credentialData.id,
rawId: credentialData.rawId,
response: {
clientDataJSON: credentialData.response.clientDataJSON,
attestationObject: credentialData.response.attestationObject,
},
type: credentialData.type,
nickname,
enabled: true,
};
const response = await this.axiosInstance.post<VerifyRegistrationCompleteResponse>(
`/v2.0/factors/fido2/relyingparties/${verifyConfig.rpUuid}/attestation/result`,
requestBody
);
return response.data;
}
コントローラー実装:
export const registerComplete = async (
req: AuthRequest,
res: Response
): Promise<void> => {
const userId = req.user?.userId;
const { response, credentialName } = req.body;
// チャレンジ検証
const challengeKey = `challenge:registration:${userId}`;
const expectedChallenge = await redisClient.get(challengeKey);
if (!expectedChallenge) {
res.status(400).json({ error: 'Challenge expired or not found' });
return;
}
// IBM Verifyで登録完了
const result = await verifyAuthService.completeRegistration(
response,
credentialName || 'My Passkey'
);
// データベースに保存
await pool.query(
`INSERT INTO passkey_credentials
(user_id, credential_id, credential_name, created_at, is_active)
VALUES ($1, $2, $3, NOW(), true)`,
[userId, response.id, credentialName]
);
// チャレンジ削除
await redisClient.del(challengeKey);
res.json({ success: true, credentialId: result.id });
};
登録完了時点で、公開鍵はIBM Verifyに保存され、秘密鍵はデバイス内に保存されています。
10. Passkey認証フロー
全体の流れ
Passkey認証は3つの主要ステップで完了します。保存済みの認証情報を使用して、パスワードレスでユーザーを認証します。
詳細なデータフロー(参考)
以下の図は、各ステップ内で発生するリクエスト/レスポンスの詳細なフローを示しています。
ステップ1: 認証開始(assertion/options)(図の1-3)
リクエスト
HTTPリクエスト例(ユーザー名なし):
POST /v2.0/factors/fido2/relyingparties/{rpUuid}/assertion/options HTTP/1.1
Host: your-tenant.verify.ibm.com
Authorization: Bearer {access_token}
Content-Type: application/json
{
"userVerification": "required"
}
userIdを省略すると、デバイスに保存されたすべてのPasskeyから選択できます(Discoverable Credentials)。
これにより、ユーザー名入力なしでログインが可能になります(パスワードレス+ユーザー名レス)。
レスポンス
{
"challenge": "MwZWd5uqlvaUoq-cCSKoQ3wMMShEmKHBgZhEztEBvO4",
"timeout": 240000,
"rpId": "verify-passkey-demo",
"userVerification": "required"
}
バックエンド実装
export const authStart = async (
req: Request,
res: Response
): Promise<void> => {
// ユーザー名なし認証をサポート
const { username } = req.body;
let ibmVerifyUserId: string | undefined;
if (username) {
// ユーザー名ありの場合、IBM Verify User IDを取得
ibmVerifyUserId = await verifyUserManagementService.getUserIdByUserName(username);
}
// 認証開始
const options = await verifyAuthService.initiateAuthentication(ibmVerifyUserId);
// チャレンジをRedisに保存
const challengeKey = ibmVerifyUserId
? `challenge:auth:${ibmVerifyUserId}`
: `challenge:auth:usernameless:${options.challenge}`;
await redisClient.setEx(challengeKey, 300, options.challenge);
res.json({ success: true, options });
};
ステップ2: WebAuthn API呼び出し(図の4-5)
フロントエンド実装
export async function authenticateWithPasskey(username?: string): Promise<{
token: string;
user: any;
}> {
// 1. サーバーから認証オプションを取得
const { options, userId } = await api.passkeyAuthStart(username);
// 2. JSON形式をブラウザAPI用に変換
const publicKeyOptions = convertRequestOptions(options);
// 3. ブラウザの標準WebAuthn APIを使用して認証器と通信
const credential = await navigator.credentials.get({
publicKey: publicKeyOptions,
});
if (!credential || credential.type !== 'public-key') {
throw new Error('認証器からの応答が無効です');
}
// 4. ブラウザのCredentialをJSON形式に変換
const credentialJSON = convertAuthenticationCredential(
credential as PublicKeyCredential
);
// 5. サーバーに認証情報を送信して認証完了
const response = await api.passkeyAuthComplete({
userId,
credential: credentialJSON,
});
return {
token: response.token,
user: response.user,
};
}
ステップ3: 認証完了(assertion/result)(図の6-10)
リクエスト
HTTPリクエスト例:
POST /v2.0/factors/fido2/relyingparties/{rpUuid}/assertion/result HTTP/1.1
Host: your-tenant.verify.ibm.com
Authorization: Bearer {access_token}
Content-Type: application/json
{
"id": "FSFhEerKtDp93uOQS3tPjzW_x_0pvJEbDFZi1JWhSM0",
"rawId": "FSFhEerKtDp93uOQS3tPjzW_x_0pvJEbDFZi1JWhSM0",
"response": {
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0Ii...",
"authenticatorData": "mCLUwMGr72CgUu7HW2dnQLkUCCexdiPpMdTdyfqnK8wFAAAAAQ",
"signature": "MEUCIQDcYxZgeH5CYBUFnUBUyId9jqO8eMP9mNhWRSLhvhY09QIgAUyCiQYddVdbFg5zRRQWD3sV8HjGBF9A3aSuo9VfTLQ",
"userHandle": "NzU1MDAyMjM3Rg"
},
"type": "public-key"
}
レスポンス
{
"id": "5473db9a-6737-44d5-95b2-3f90658d1986",
"type": "fido2",
"userId": "755002237F",
"enabled": true,
"validated": true,
"attributes": {
"counter": 1,
"credentialId": "FSFhEerKtDp93uOQS3tPjzW_x_0pvJEbDFZi1JWhSM0"
}
}
バックエンド実装(カウンター検証含む)
export async function passkeyAuthComplete(req: Request, res: Response): Promise<void> {
const { userId, response: webauthnResponse } = req.body;
// 1. Credential IDでデータベースから検索
const credResult = await pool.query(
'SELECT user_id, counter FROM passkey_credentials WHERE credential_id = $1 AND is_active = true',
[credential.id]
);
if (credResult.rows.length === 0) {
res.status(400).json({ error: 'Credential not found' });
return;
}
const { user_id, counter: storedCounter } = credResult.rows[0];
// 2. ユーザー情報取得
const userResult = await pool.query(
'SELECT id, username, email FROM users WHERE id = $1 AND is_active = true',
[user_id]
);
if (userResult.rows.length === 0) {
res.status(404).json({ error: 'User not found' });
return;
}
const user = userResult.rows[0];
// 3. チャレンジ検証
const clientDataJSON = Buffer.from(
credential.response.clientDataJSON,
'base64url'
).toString();
const clientData = JSON.parse(clientDataJSON);
const challenge = clientData.challenge;
const challengeKey = `challenge:auth:usernameless:${challenge}`;
const expectedChallenge = await redisClient.get(challengeKey);
if (!expectedChallenge) {
res.status(400).json({ error: 'Challenge expired or not found' });
return;
}
// 4. IBM Verifyで署名検証
const verifyResult = await verifyAuthService.completeAuthentication(credential);
// 5. カウンター検証(リプレイ攻撃防止)
const authenticatorData = Buffer.from(
credential.response.authenticatorData,
'base64url'
);
const newCounter = authenticatorData.readUInt32BE(33);
if (newCounter <= storedCounter) {
logger.error('Counter did not increase - possible replay attack', {
stored: storedCounter,
received: newCounter,
});
res.status(401).json({ error: 'Authentication failed' });
return;
}
// 6. カウンター更新
await pool.query(
'UPDATE passkey_credentials SET counter = $1, last_used_at = NOW() WHERE credential_id = $2',
[newCounter, credential.id]
);
// 7. チャレンジ削除
await redisClient.del(challengeKey);
// 8. JWT発行
const token = generateToken({
userId: user.id,
username: user.username,
email: user.email,
});
res.json({ token, user });
};
カウンター検証は必須です。スキップするとリプレイ攻撃に脆弱になります。
11. セキュリティ考慮事項
チャレンジ管理
TTLの設定理由
// チャレンジは5分間のみ有効
await redisClient.setEx(challengeKey, 300, challenge);
理由:
- 長すぎるとリプレイ攻撃のリスク増加
- 短すぎるとユーザー体験が悪化
- 5分は適切なバランス
使用後削除の重要性
// 認証完了後、必ず削除
await redisClient.del(challengeKey);
理由:
- 同じチャレンジの再利用を防止
- リプレイ攻撃の防止
- メモリの効率的な使用
カウンター検証
実装例:
async verifyAuthentication(
response: AuthenticationResponseJSON,
credential: PasskeyCredential
): Promise<VerificationResult> {
// IBM Verifyで署名検証
const verifyResult = await this.completeAuthentication(response);
// カウンターを抽出
const authenticatorData = Buffer.from(
response.response.authenticatorData,
'base64url'
);
const counter = authenticatorData.readUInt32BE(33);
// カウンターの増加を確認
if (counter <= credential.counter) {
logger.error('Counter did not increase - possible replay attack', {
stored: credential.counter,
received: counter
});
return { verified: false, newCounter: credential.counter };
}
return { verified: true, newCounter: counter };
}
カウンターが増加しない場合は、リプレイ攻撃の可能性があります。必ず認証を失敗させてください。
データベースセキュリティ
Passkey機能に関連するデータベースセキュリティの考慮事項:
- カウンター値の整合性:トランザクション内でカウンター更新を実行し、リプレイ攻撃を防止
SQL インジェクション対策、最小権限の原則、暗号化通信などの一般的なデータベースセキュリティは既存アプリで実装済みのため、ここでは説明を省略します。
12. Passkey管理機能
登録一覧取得
HTTPリクエスト例:
GET /v2.0/factors/fido2/registrations?search=userId="{userId}" HTTP/1.1
Host: your-tenant.verify.ibm.com
Authorization: Bearer {access_token}
レスポンス例:
{
"fido2": [
{
"id": "registration-id-1",
"userId": "755002237F",
"attributes": {
"credentialId": "credential-id-1",
"nickname": "My iPhone",
"counter": 42
},
"created": "2024-01-01T00:00:00Z"
}
]
}
実装例:
export const listCredentials = async (
req: AuthRequest,
res: Response
): Promise<void> => {
const userId = req.user?.userId;
const result = await pool.query(
`SELECT id, credential_id, credential_name, counter, created_at, last_used_at
FROM passkey_credentials
WHERE user_id = $1 AND is_active = true
ORDER BY created_at DESC`,
[userId]
);
const credentials = result.rows.map((row: any) => ({
id: row.id,
credential_id: row.credential_id,
device_name: row.credential_name,
counter: row.counter,
created_at: row.created_at,
last_used_at: row.last_used_at,
}));
res.json(credentials);
};
登録削除
HTTPリクエスト例:
DELETE /v2.0/factors/fido2/registrations/{id} HTTP/1.1
Host: your-tenant.verify.ibm.com
Authorization: Bearer {access_token}
実装例:
export const deleteCredential = async (
req: AuthRequest,
res: Response
): Promise<void> => {
const userId = req.user?.userId;
const { id } = req.params;
// 所有権確認
const checkResult = await pool.query(
'SELECT user_id, credential_id FROM passkey_credentials WHERE id = $1',
[id]
);
if (checkResult.rows.length === 0) {
res.status(404).json({ error: 'Credential not found' });
return;
}
if (checkResult.rows[0].user_id !== userId) {
res.status(403).json({ error: 'Forbidden' });
return;
}
const credentialId = checkResult.rows[0].credential_id;
// IBM Verifyから削除
const username = req.user?.username;
const ibmVerifyUserId = await verifyUserManagementService.getUserIdByUserName(username);
if (ibmVerifyUserId) {
const registrations = await verifyAuthService.listRegistrations(ibmVerifyUserId);
const matchingReg = registrations.find((reg: any) =>
reg.attributes?.credentialId === credentialId
);
if (matchingReg) {
await verifyAuthService.deleteRegistration(matchingReg.id);
}
}
// データベースから削除
await pool.query(
'DELETE FROM passkey_credentials WHERE id = $1',
[id]
);
res.json({ success: true, message: 'Passkey deleted successfully' });
};
13. まとめ
IBM BOBを使ってIBM VerifyのAPIでPasskey実装を試してみました。本記事では、実際に動作するPasskey認証システムの構築方法を、コードとともに詳しく解説しました。
参考リンク
実装中に問題が発生した場合は、IBM Verifyのドキュメントやコミュニティフォーラムを参照してください。