Back

PasskeyとWebAuthn:Webアプリからパスワードを完全に排除する完全ガイド

ユーザーがまだパスワードを入力している。2026年なのに。Apple、Google、Microsoftが全員Passkey対応を出しているのに、ほとんどのWebアプリは2005年の認証アーキテクチャのままなんですよね。パスワードをハッシュ化してDBに保存して、誰にも見つからないことを祈る構造。

Passkeyが難しいから使われないわけじゃないんです。実装しようとすると、ドキュメント化されていない罠があちこちに潜んでいる。WebAuthn APIはデモだとシンプルに見えるのに本番で壊れるし、allowCredentialsが正確に何をするのか分かりにくいし、navigator.credentials.get()が特定のブラウザで黙って失敗する。既存ユーザー50万人をセッション壊さずにどうマイグレーションするか。そこが本当の問題なんです。

このガイドでは、暗号学の基礎からTypeScriptの完全実装、Conditional UI、DBスキーマ設計、マルチデバイス同期、ユーザーに行動変更を強制せずに自然に移行できるマイグレーション戦略まで、すべてカバーします。


パスワードがいまだにリスクである理由

数字を見てみましょう。2025年のVerizon DBIRレポートによると、Webアプリの侵害の80%以上が盗まれたか弱い認証情報に起因しています。フィッシングが成功するのは、パスワードが共有秘密(shared secret)だからなんですよね。サーバーも知っている、ユーザーも知っている、途中で傍受されたりDBから抜かれたりすればそれで終わり。

Passkeyが本当に解決すること

Passkeyは共有秘密そのものを排除する仕組みなんです。何が違うのか見てみましょう:

パスワード認証:
  ユーザー → パスワードを送信 → サーバーがハッシュを比較
  攻撃面:フィッシング、クレデンシャルスタッフィング、DB漏洩

Passkey認証:
  ユーザー → プライベートキーでチャレンジに署名 → サーバーがパブリックキーで検証
  攻撃面:物理デバイスの盗難(生体認証が必要)

プライベートキーはユーザーのデバイスから絶対に出ません。サーバーが保存するのはパブリックキーだけ。データベースが丸ごと漏洩しても、攻撃者が得るのは使えないパブリックキーだけです。

重要な3つの特性

  1. フィッシングが原理的に効かない。 Passkeyはドメイン(Relying Party ID)に暗号学的にバインドされているんです。evil-example.comの偽ログインページをどんなに巧妙に作っても、example.com用のPasskeyは発動しない。ブラウザがプロトコルレベルでブロックするので、ソーシャルエンジニアリングでもバイパスできません。

  2. 共有秘密がそもそもない。 サーバーが秘密情報を見たり、保存したり、送信したりすることが一切ないんです。ハッシュ化するものもソルトするものも漏洩するものもない。クレデンシャルスタッフィング自体が意味をなさなくなるわけです。

  3. マルチファクターが最初から入っている。 Passkey認証は「持っているもの」(デバイス)+「自分自身」(生体認証)または「知っているもの」(デバイスPIN)を組み合わせます。認証アプリをインストールさせる必要なくMFA相当のセキュリティが手に入るんです。


WebAuthnフローの理解

Web Authentication API(WebAuthn)には2つのコアセレモニーがあります:登録(クレデンシャルの作成)と認証(ログインに使用)。

登録フロー

┌──────────┐         ┌──────────┐         ┌──────────────┐
│  ブラウザ  │         │   サーバー │         │  認証器(デバイス) │
└────┬─────┘         └────┬─────┘         └──────┬───────┘
     │  1. 登録開始        │                      │
     │ ─────────────────> │                      │
     │                    │                      │
     │  2. チャレンジ+オプション │                  │
     │ <───────────────── │                      │
     │                    │                      │
     │  3. クレデンシャル作成 │                    │
     │ ─────────────────────────────────────────>│
     │                    │                      │
     │  4. 生体認証プロンプト │                    │
     │ <─────────────────────────────────────────│
     │                    │                      │
     │  5. パブリックキー+証明 │                   │
     │ ─────────────────> │                      │
     │                    │                      │
     │  6. 検証 & 保存     │                      │
     │ <───────────────── │                      │

認証フロー

┌──────────┐         ┌──────────┐         ┌──────────────┐
│  ブラウザ  │         │   サーバー │         │  認証器(デバイス) │
└────┬─────┘         └────┬─────┘         └──────┬───────┘
     │  1. ログイン開始    │                      │
     │ ─────────────────> │                      │
     │                    │                      │
     │  2. チャレンジ      │                      │
     │ <───────────────── │                      │
     │                    │                      │
     │  3. チャレンジに署名 │                      │
     │ ─────────────────────────────────────────>│
     │                    │                      │
     │  4. 生体認証プロンプト │                    │
     │ <─────────────────────────────────────────│
     │                    │                      │
     │  5. 署名済みアサーション │                   │
     │ ─────────────────> │                      │
     │                    │                      │
     │  6. 署名検証        │                      │
     │ <───────────────── │                      │

ポイントはステップ5です。認証器がサーバーのチャレンジをプライベートキーで署名する。サーバーは保存されたパブリックキーでその署名を検証する。秘密が送信されるのではなく、ユーザーがプライベートキーを持っているという証明だけが送られるわけです。


SimpleWebAuthnで完全実装する

WebAuthnをスクラッチで自前実装するのは微妙なセキュリティバグの温床です。CBORパーシング、attestation検証、チャレンジ管理は十分に複雑で、実戦テスト済みのライブラリを使うのが唯一まともな道。TypeScriptではSimpleWebAuthnが最も広く使われています。

プロジェクトセットアップ

# サーバーサイド npm install @simplewebauthn/server # クライアントサイド npm install @simplewebauthn/browser

データベーススキーマ

認証コードを書く前に、まずストレージレイヤーを設計しましょう。2つのテーブルが必要です:

-- ユーザーテーブル CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), username VARCHAR(255) UNIQUE NOT NULL, display_name VARCHAR(255), -- マイグレーション期間中は維持するレガシーパスワードフィールド password_hash VARCHAR(255), created_at TIMESTAMPTZ DEFAULT NOW() ); -- Passkeyクレデンシャルテーブル(1ユーザーが複数のPasskeyを登録可能) CREATE TABLE passkey_credentials ( id VARCHAR(512) PRIMARY KEY, -- クレデンシャルID (base64url) user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, public_key BYTEA NOT NULL, -- rawバイトで保存 counter BIGINT NOT NULL DEFAULT 0, -- 署名カウンター device_type VARCHAR(50) NOT NULL, -- 'singleDevice' or 'multiDevice' backed_up BOOLEAN NOT NULL DEFAULT false, -- クラウド同期済み? transports TEXT[], -- ['internal', 'hybrid' など] display_name VARCHAR(255), -- "MacBook Pro Touch ID" created_at TIMESTAMPTZ DEFAULT NOW(), last_used_at TIMESTAMPTZ ); CREATE INDEX idx_credentials_user_id ON passkey_credentials(user_id);

設計のポイント:

  • 1対多の関係。 ユーザーは複数のPasskey(スマホ、ノートPC、セキュリティキー)を登録できます。絶対に1つに制限しないこと。
  • counterフィールド。 認証器は使用するたびに署名カウンターをインクリメントします。保存値より低いカウンターが来たら、クレデンシャルがクローンされた証拠なので即座にブロックしてフラグを立てる。
  • device_typebacked_up Passkeyがデバイス間で同期されているか(iCloud Keychain、Google パスワードマネージャー)、単一デバイスにバインドされているか(ハードウェアセキュリティキー)を示します。
  • transports配列。 認証器の通信方式を記録します('internal'はプラットフォーム生体認証、'hybrid'はQRコードによるクロスデバイス、'usb'はセキュリティキー)。以降の認証時にどのトランスポートを最初に試すかブラウザにヒントを与えて高速化します。

サーバー:登録エンドポイント

import { generateRegistrationOptions, verifyRegistrationResponse, type VerifiedRegistrationResponse, } from '@simplewebauthn/server'; const RP_NAME = 'My App'; const RP_ID = 'example.com'; const ORIGIN = 'https://example.com'; // ステップ1:オプション生成 app.post('/api/auth/register/begin', async (req, res) => { const user = await getUserFromSession(req); // 既存クレデンシャルを取得(重複登録防止) const existingCredentials = await db.query( 'SELECT id, transports FROM passkey_credentials WHERE user_id = $1', [user.id] ); const options = await generateRegistrationOptions({ rpName: RP_NAME, rpID: RP_ID, userName: user.username, userDisplayName: user.display_name || user.username, excludeCredentials: existingCredentials.rows.map(cred => ({ id: cred.id, transports: cred.transports, })), authenticatorSelection: { residentKey: 'required', // 重要:これがPasskeyにする核心設定 userVerification: 'preferred', }, attestationType: 'none', }); await setSessionChallenge(req, options.challenge); res.json(options); }); // ステップ2:レスポンス検証 app.post('/api/auth/register/complete', async (req, res) => { const user = await getUserFromSession(req); const expectedChallenge = await getSessionChallenge(req); try { const verification: VerifiedRegistrationResponse = await verifyRegistrationResponse({ response: req.body, expectedChallenge, expectedOrigin: ORIGIN, expectedRPID: RP_ID, }); if (!verification.verified || !verification.registrationInfo) { return res.status(400).json({ error: '検証失敗' }); } const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; await db.query( `INSERT INTO passkey_credentials (id, user_id, public_key, counter, device_type, backed_up, transports) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [ credential.id, user.id, Buffer.from(credential.publicKey), credential.counter, credentialDeviceType, credentialBackedUp, credential.transports ?? [], ] ); res.json({ verified: true }); } catch (error) { console.error('登録検証失敗:', error); res.status(400).json({ error: '登録失敗' }); } });

サーバー:認証エンドポイント

import { generateAuthenticationOptions, verifyAuthenticationResponse, } from '@simplewebauthn/server'; app.post('/api/auth/login/begin', async (req, res) => { const options = await generateAuthenticationOptions({ rpID: RP_ID, userVerification: 'preferred', // allowCredentials空 = discoverableクレデンシャルフロー allowCredentials: [], }); await setChallengeStore(options.challenge, { expiresIn: 300 }); res.json(options); }); app.post('/api/auth/login/complete', async (req, res) => { const { id: credentialId } = req.body; const credRow = await db.query( `SELECT pc.*, u.id as uid, u.username FROM passkey_credentials pc JOIN users u ON pc.user_id = u.id WHERE pc.id = $1`, [credentialId] ); if (credRow.rows.length === 0) { return res.status(401).json({ error: '不明なクレデンシャル' }); } const cred = credRow.rows[0]; const expectedChallenge = await getChallengeStore(req.body.response.clientDataJSON); try { const verification = await verifyAuthenticationResponse({ response: req.body, expectedChallenge, expectedOrigin: ORIGIN, expectedRPID: RP_ID, credential: { id: cred.id, publicKey: new Uint8Array(cred.public_key), counter: Number(cred.counter), transports: cred.transports, }, }); if (!verification.verified) { return res.status(401).json({ error: '検証失敗' }); } // カウンター更新(クローン検出用) const { newCounter } = verification.authenticationInfo; await db.query( `UPDATE passkey_credentials SET counter = $1, last_used_at = NOW() WHERE id = $2`, [newCounter, credentialId] ); const token = await createSession(cred.uid); res.json({ verified: true, token }); } catch (error) { console.error('認証失敗:', error); res.status(401).json({ error: '認証失敗' }); } });

クライアント:ブラウザ連携

import { startRegistration, startAuthentication } from '@simplewebauthn/browser'; async function registerPasskey(): Promise<void> { const optionsRes = await fetch('/api/auth/register/begin', { method: 'POST' }); const options = await optionsRes.json(); const credential = await startRegistration({ optionsJSON: options }); const verifyRes = await fetch('/api/auth/register/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credential), }); const result = await verifyRes.json(); if (result.verified) { console.log('Passkey登録完了'); } } async function loginWithPasskey(): Promise<void> { const optionsRes = await fetch('/api/auth/login/begin', { method: 'POST' }); const options = await optionsRes.json(); const assertion = await startAuthentication({ optionsJSON: options }); const verifyRes = await fetch('/api/auth/login/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(assertion), }); const result = await verifyRes.json(); if (result.verified) { window.location.href = '/dashboard'; } }

Conditional UI:オートフィルにPasskeyを表示する

Passkeyの最大のUX改善は登録フローじゃないんです。Conditional UIです。「Passkeyでログイン」ボタンをクリックさせるのではなく、ユーザー名フィールドのオートフィルドロップダウンに、保存されたパスワードの横にPasskeyが直接表示されます。

仕組み

<!-- autocomplete属性がカギ --> <form> <input type="text" id="username" name="username" autocomplete="username webauthn" placeholder="メールアドレスまたはユーザー名" /> <input type="password" name="password" autocomplete="current-password" /> <button type="submit">ログイン</button> </form>

autocomplete属性のwebauthnトークンがブラウザにオートフィルドロップダウンにPasskeyオプションを含めるよう指示します。属性値の最後に配置する必要があります。

実装

import { startAuthentication, browserSupportsWebAuthnAutofill } from '@simplewebauthn/browser'; async function initConditionalUI(): Promise<void> { // 機能検出:必ず最初にチェック const supported = await browserSupportsWebAuthnAutofill(); if (!supported) { console.log('Conditional UI非対応、ボタンにフォールバック'); return; } const optionsRes = await fetch('/api/auth/login/begin', { method: 'POST' }); const options = await optionsRes.json(); try { // この呼び出しはユーザーがオートフィルからPasskeyを選択するまで「待機」する const assertion = await startAuthentication({ optionsJSON: options, useBrowserAutofill: true, // Conditional UI有効化 }); const verifyRes = await fetch('/api/auth/login/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(assertion), }); const result = await verifyRes.json(); if (result.verified) { window.location.href = '/dashboard'; } } catch (error) { if ((error as Error).name !== 'AbortError') { console.error('Conditional UIエラー:', error); } } } // ページロード時に呼び出す。ボタンクリックではない document.addEventListener('DOMContentLoaded', initConditionalUI);

実装で押さえるべきポイント

1. ページロード時に呼び出す。クリック時じゃない。 Conditional UIはページが表示された時点で開始する必要があります。ブラウザがオートフィルドロップダウンのインタラクションを継続的に監視する仕組みなので、ボタンクリックでしか呼ばないとシームレスなオートフィル体験を逃します。

2. AbortControllerを使うこと。 ユーザーがソーシャルログインやマジックリンクなど別の方法に切り替えたら、保留中のConditional UIリクエストをアボートしないといけません。やらないと「request already pending」エラーが出ます。

3. フォールバック戦略。 Conditional UIをサポートしないブラウザのために「Passkeyでログイン」ボタンを常に表示しておきましょう。標準のモーダルフローがフォールバックになります。


段階的マイグレーション戦略

スイッチ押して一夜でパスワードを全削除なんて無理な話です。本番でちゃんと機能する3フェーズアプローチを見ていきましょう。

フェーズ1:導入(自発的登録)

パスワードログイン成功後に、Passkey登録をさりげなく勧めます。強制はせず、オプションを見せるだけ。

async function postLoginPasskeyPrompt(user: User): Promise<void> { const hasPasskey = await userHasPasskey(user.id); if (hasPasskey) return; const dismissedCount = await getPromptDismissals(user.id); if (dismissedCount >= 3) return; // しつこくしない showPasskeyEnrollmentBanner({ title: 'もっと速いログインを有効にする', description: 'パスワードの代わりにFace IDや指紋を使う', onAccept: () => registerPasskey(), onDismiss: () => incrementPromptDismissals(user.id), }); }

フェーズ2:デフォルト化(新規ユーザーはPasskeyから)

新規ユーザー登録はPasskeyをデフォルトに。パスワードも使えますが、あくまでセカンダリという位置付けです。

async function registerNewUser(userData: NewUser): Promise<void> { const user = await createUser(userData); // WebAuthnサポートをまずチェック const webauthnSupported = await isWebAuthnSupported(); if (webauthnSupported) { await registerPasskey(); // プライマリ — Passkey // オプションでバックアップ用パスワードも収集可能 } else { await setPassword(user.id, userData.password); // フォールバック } }

フェーズ3:移行(パスワードレスのみオプション)

Passkeyを登録済みのユーザーがパスワードを完全に無効化できるようにします。

app.post('/api/auth/disable-password', async (req, res) => { const user = await getUserFromSession(req); const credentials = await getUserCredentials(user.id); if (credentials.length < 2) { return res.status(400).json({ error: 'パスワード無効化前にPasskeyを2つ以上登録してください' }); } const hasSyncedPasskey = credentials.some(c => c.backed_up); if (!hasSyncedPasskey) { return res.status(400).json({ error: 'リカバリ用に同期されたPasskey(iCloud/Google)を1つ以上登録してください' }); } await db.query( 'UPDATE users SET password_hash = NULL WHERE id = $1', [user.id] ); res.json({ success: true }); });

マイグレーション指標ダッシュボード

指標目標何がわかるか
Passkey採用率6ヶ月で30%以上全体のマイグレーション速度
登録完了率開始した80%以上登録UXの摩擦度
認証成功率99%以上Passkeyフローの信頼性
フォールバック使用率下降トレンドユーザーがパスワードから離れているか
プロンプト却下率60%未満登録プロンプトが押し付けがましくないか

本番環境のセキュリティの落とし穴

1. チャレンジリプレイ攻撃

チャレンジは必ずワンタイムで時間制限付きにする。これは絶対です。ステートレスJWTにチャレンジを保存すると、攻撃者が同じチャレンジをリプレイできてしまいます。

// ダメ:署名付きクッキーにチャレンジ(リプレイ可能) const challenge = signJWT({ challenge: randomBytes(32) }); // 良い:TTL付きサーバーサイドストア const challenge = randomBytes(32).toString('base64url'); await redis.setex(`webauthn:challenge:${sessionId}`, 300, challenge); // 検証後に即削除 await redis.del(`webauthn:challenge:${sessionId}`);

2. カウンター検証

署名カウンターはクレデンシャルクローンを検出するための防御手段なんです。カウンターが逆行したら、何かが大きくおかしい証拠です。

async function validateCounter( credentialId: string, newCounter: number ): Promise<void> { const stored = await getStoredCounter(credentialId); if (newCounter > 0 && newCounter <= stored) { await flagCredentialAsCompromised(credentialId); await notifySecurityTeam({ event: 'counter_regression', credentialId, storedCounter: stored, receivedCounter: newCounter, }); throw new Error('クレデンシャルカウンター逆行検出'); } }

注意: 一部のプラットフォーム認証器(特に同期されるPasskey)はカウンターを常に0で報告します。この場合、カウンター検証は事実上無効になります。カウンターが実際に使われているとき(0より大きいとき)のみ逆行を検出しましょう。

3. Origin検証

Originチェックは必ず厳密に。誤設定したOriginチェックはPasskeyの価値であるフィッシング耐性を無効化してしまいます。

// ダメ:部分文字列マッチ(サブドメイン攻撃に脆弱) if (origin.includes('example.com')) { ... } // 良い:許可されたOriginリストとの完全一致 const ALLOWED_ORIGINS = [ 'https://example.com', 'https://app.example.com', ]; if (!ALLOWED_ORIGINS.includes(origin)) { throw new Error('許可されていないOrigin'); }

4. アカウントリカバリ

パスワードレス認証で最も難しい問題です。ユーザーがすべてのデバイスを失ったらどうするか。

interface RecoveryStrategy { minimumPasskeys: 2; requireSyncedCredential: true; fallbacks: [ 'recovery_codes', 'email_magic_link', 'identity_verification' ]; }

初回Passkey登録時にリカバリコードを生成し、安全に保管するようユーザーに案内しましょう。セキュリティキーで何年も使われてきたパターンで、きちんと機能します。


クロスプラットフォームの考慮事項

デバイス同期の動作

プラットフォーム同期メカニズム範囲
AppleiCloud Keychain同じApple IDのすべてのAppleデバイス
GoogleGoogle パスワードマネージャー同じGoogleアカウントのChrome/Android
MicrosoftMicrosoft Authenticator同じMSアカウントのWindows
1Password / Bitwardenサードパーティボルトクロスプラットフォーム、ボルトアクセス可能な全デバイス

クロスデバイスフロー(ハイブリッドトランスポート)

Passkeyがないデバイスでログインしたいとき、「ハイブリッド」トランスポートが発動します:

  1. デスクトップブラウザがQRコードを表示
  2. ユーザーがPasskeyを持つスマホでスキャン
  3. スマホで生体認証プロンプト
  4. デスクトップで認証完了

これは自動的に動きます。特別な実装は不要です。transports'hybrid'が含まれていれば、ブラウザと認証器が全フローを処理してくれます。


パフォーマンスとコストモデル

Passkey認証はスケールするほどパスワード認証より大幅に安くなります:

要素パスワードPasskey
サーバー計算bcrypt/Argon2ハッシング(CPU集約的)Ed25519署名検証(高速)
ユーザーあたりストレージ~128バイト(ハッシュ+ソルト)~256バイト(パブリックキー+メタデータ)
サポートチケット「パスワード忘れた」(サポート量の~40%)登録後ほぼゼロ
漏洩コストインシデントあたり平均444万ドル盗めるクレデンシャルなし
SMS 2FAコスト1通あたり$0.01-0.05$0(検証内蔵)

ユーザー10万人のサービスで、パスワードリセットフローを排除するだけでも月15-20時間のサポート時間を節約できます。


2026年の現実

Passkeyは未来の技術じゃないんです。今すぐ使える現在の技術なんですよね。すべての主要ブラウザがサポート済み、すべての主要プラットフォームが同期済み、WebAuthnの仕様も安定している。SimpleWebAuthnのようなライブラリが危険な暗号学のディテールを全部処理してくれるので、自分で触る必要もない。

本当のブロッカーは技術じゃなくて組織の慣性なんです。「ユーザーがパスワードに慣れているから」「既存のログインフローを壊せない」と躊躇するチームが多い。でも段階的マイグレーションならそれを解決できます。初日からパスワードを廃止する必要はない。より良いものを横に並べるだけでいいんです。

今Passkeyを出荷しているチームは、登録UXがスムーズなら6ヶ月以内に50-70%の自発的採用率を記録しています。ユーザーは本当に、パスワードを打つより指紋タッチを好むんですよね。コンバージョンの摩擦が下がり、サポート負荷が減り、セキュリティポスチャーが劇的に改善される。

Conditional UIから始めてみてはどうでしょうか。UIの変更は最小限で、autocomplete属性1つとページロードスクリプトだけ。Passkeyを持つユーザーは自動的にファストパスを使い、持たないユーザーはいつものログインフォームをそのまま見る。破壊ゼロ、メリット最大です。

パスワードは60年前の技術です。そろそろ引退させてあげましょう。

AuthenticationSecurityWebAuthnPasskeysTypeScriptWeb Development

関連ツールを見る

Pockitの無料開発者ツールを試してみましょう