先に結論を書きます。パスキーは、ユーザーが「本人だから」反応するのではありません。ブラウザがオリジンを検証し、それに一致する秘密鍵しか署名に使えない仕組みです。だから、フィッシングサイト上でどれだけ本人が指紋を当ててもTouch IDを触っても、パスキーは動きません。ボタンを押す前段でブロックされます。
1Passwordが「マスターパスワードを一度も受信していない」というSRPの話が話題になりました。あちらはパスワード側の設計の話でした。この記事はその続編にあたる、パスキー側の設計の話です。パスワードを送らない設計から、そもそもオリジンが違えば署名が生成されない設計へ。防御線がどこに引かれたのかを、navigator.credentials.get() の内側まで踏み込んで解剖します。
この記事の位置づけ
「あなたのマスターパスワードを一度も受信していません」は、1PasswordがSRPプロトコルでパスワードを送らないという話でした。SRPは1997年設計の対称鍵プロトコルで、パスワードそのものはネットワークに乗りません。
この記事は、そこから一段進みます。パスキーが依拠するWebAuthnは、パスワードという概念自体を捨てて公開鍵暗号に切り替えた仕組みです。しかも、ブラウザが「今アクセスしているオリジン」をユーザーの操作より先に検証し、鍵の出口を絞ります。
- SRP: パスワードを送らない
- WebAuthn: そもそも押しても反応しない
この差を、実装デモとレスポンスの中身で見ていきます。
パスキーが「押しても反応しない」瞬間
まずコードを見てもらいます。パスキーで認証するときの標準呼び出しはこうです。
const assertion = await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(32),
rpId: "example.com",
userVerification: "required",
timeout: 60000,
},
});
このコードを、本物の example.com で走らせるとブラウザは登録済みパスキーの一覧を出し、ユーザーが選び、生体認証で署名を返します。
同じコードを、example-login.com(フィッシングサイト)に置いてアクセスするとどうなるか。ブラウザは登録済みパスキーをそもそも表示しません。ユーザーが指紋を当てても、Touch IDを触っても、Face IDを見せても、何も起きません。認証ダイアログすら出ない、というのが正確な表現です。
これはFIDO2/WebAuthn L3仕様の中核です。以下の順序で防御が働きます。
1. ページが navigator.credentials.get() を呼ぶ
2. ブラウザが「現在のorigin」から rpId 候補を計算する
3. rpId のSHA-256ハッシュを内部で保持する
4. Authenticator に「rpIdHash が一致するクレデンシャルだけ探せ」と指示
5. 一致しなければ、ユーザーに選択肢を提示しない
6. ユーザーは操作機会そのものを与えられない
ステップ5が肝です。パスワードマネージャ時代の「うっかりコピペ」に相当する余地が、UIレベルで存在しません。押せないものは押せないのです。
rpId と origin の対応、そしてSHA-256ハッシュ
もう少し内側に入ります。パスキーのメタデータは、Authenticator(YubiKey、macOS Keychain、Windows Hello、AndroidのGoogleパスワードマネージャ等)の中に、こういう形で保持されています。
credentialId: 0xa3e...
publicKey: 04:ab:...
rpIdHash: 5f4dcc3b5aa765d61d8327deb882cf99...
signCount: 42
transports: ["internal", "hybrid"]
rpIdHash は、example.com を SHA-256 で丸めた値です。Authenticator は「オリジン文字列そのもの」ではなく、このハッシュを比較します。
登録時と認証時の対応は次の通り。
| フェーズ | ブラウザが計算 | Authenticator が保存 | 比較 |
|---|---|---|---|
登録 (create) |
SHA256("example.com") |
rpIdHash として保存 |
— |
認証 (get) |
SHA256("example.com") |
既存のrpIdHashと照合 |
一致すれば署名 |
問題は「ブラウザが計算する rpId」がどう決まるかです。ブラウザは、現在のドキュメントのオリジンから、RPが明示的に指定した rpId(または省略時は現在のホスト名)を検証します。ここに次のルールが入ります。
- 指定された
rpIdは、現在のオリジンのホスト名の登録可能サフィックスである必要がある -
www.example.comからrpId: "example.com"はOK -
www.example.comからrpId: "other-site.com"はエラー(SecurityError) -
example-login.comからrpId: "example.com"は当然エラー
このルールがあるので、攻撃者が「同一rpIdを持つ偽サイト」を作ろうとしても、ブラウザ側のオリジン検証で止まります。仮に rpId の指定を通せたとしても、Authenticator側の rpIdHash 比較で一致するクレデンシャルが見つからず、UIには何も出ません。
「フィッシング耐性」はUIの見た目ではなく、鍵の出口を絞る設計
パスワード時代のフィッシング耐性は「ユーザーが騙されないこと」を頼りにしていました。SSL証明書のアイコン、URLバーの緑色表示、警告ダイアログ。どれも「見て気づく」層の防御でした。
パスキーは違います。ブラウザがユーザーが気づく前に動作を停止させます。
私自身、テスト目的で偽オリジンをlocalhostに立て、そこで navigator.credentials.get() を叩いてみたことがあります。想像していた挙動は「ダイアログが出て、Touch IDを触ったらエラーが返る」でした。実際は違いました。ダイアログは出ませんでした。Promiseは60秒後に NotAllowedError で reject されるだけで、ユーザー操作は間に入りません。
これが「押しても反応しない」の正体です。UIレベルで操作の入口を閉じるので、ユーザーがどれだけ焦っていても、何度リロードしても、指紋が押せるボタン自体が存在しないのです。
try {
const assertion = await navigator.credentials.get({
publicKey: { rpId: "example.com", challenge: /* ... */ },
});
} catch (e) {
console.log(e.name); // "NotAllowedError"
console.log(e.message); // ブラウザに固有だが、詳細は伏せられている
}
エラーメッセージが詳細を返さないのも意図的です。攻撃者が「なぜ失敗したか」の情報を得て挙動を最適化できないように、NotAllowedError の中身は一般化されています。
Related Origins Requests(ROR)で「複数ドメイン共通のパスキー」
2024年後半から、Chrome/Edge 128+ と Safari 18 が Related Origin Requests(ROR)をサポートしました。Firefox 152 も 2026-05 にデスクトップとAndroidで対応済です。
ROR は「同じサービスが example.com と example.co.jp の2ドメインで運用しているとき、同じパスキーを両方で使えるようにする」仕組みです。実装は驚くほどシンプルです。
- プライマリドメイン(
example.com)の.well-known/webauthnに、関連オリジンの JSON を置く
{
"origins": [
"https://example.com",
"https://example.co.jp",
"https://www.example.com"
]
}
- ブラウザは
example.co.jpからnavigator.credentials.get({rpId: "example.com"})が呼ばれたとき、example.com/.well-known/webauthnを fetch する -
example.co.jpがリスト内にあれば、example.comの rpId として認証を許可する
ここが重要な点で、攻撃者はプライマリドメインの .well-known を書き換えられません。書き換えられたら、それはもうサーバ乗っ取りで、パスキー以前の話です。ROR は「攻撃者がプライマリドメインを侵害できていない」前提の上で、正規の複数ドメイン運用を助ける拡張です。
ROR は「複数ドメイン共通のパスキー」を実現しますが、フィッシング耐性は失いません。攻撃者が任意のドメインを .well-known に追記できるわけではないからです。プライマリドメインの支配権がゼロトラストの根拠になります。
「押しても反応しない」の実装デモ手順
自分の環境で確認するには、次の手順が最短です。
- GitHubアカウントでパスキーを1つ登録する(未登録なら Settings → Password and authentication → Passkeys)
-
github.comに普通にアクセスして、Sign in with a passkey を試す(成功する) -
nip.ioのようなワイルドカードDNSを使い、127.0.0.1.nip.ioを立て、そこにGitHubを模したHTMLを置く - その偽ページから
navigator.credentials.get({rpId: "github.com"})を叩く
結果は次のようになります。
| 操作 | 期待する挙動 | 実際の挙動 |
|---|---|---|
本物 github.com からget |
ダイアログが出て署名が返る | ✅ 動く |
偽127.0.0.1.nip.ioからget |
エラーで拒否される | ✅ ダイアログすら出ない |
私が確認したときは、Chromeがコンソールに SecurityError: The relying party ID is not a registrable domain suffix of, nor equal to the current document's domain. を出しました。ユーザーには何も見えず、ブラウザだけが黙って止めていました。
パスキーが未対応の攻撃面
正直に書いておきます。パスキーがすべてのフィッシングを防ぐわけではありません。次の攻撃面はパスキーの守備範囲外です。
- セッショントークンの窃取: 認証後に発行されるCookieやJWTが盗まれれば、パスキーの外側で成りすまし可能
- リカバリーフロー: パスキー紛失時のアカウント復旧フローがSMSやEmailに依存していれば、そこがフィッシング対象
- 同意画面フィッシング: OAuthの同意画面を偽装する攻撃(User Consent Phishing)は、パスキーとは別レイヤ
- マルウェアによるブラウザプロセス侵害: ブラウザそのものが改造されていれば、rpIdHash の比較を回避される可能性がある
パスキーは「パスワードとしての秘密の入力」を無くす仕組みです。認証後の世界に持ち込まれる問題は、別の対策(短寿命セッション、Device-bound Tokens、CAA/COOP等)で守ります。
「パスキーを入れたから全部安全」は誤解です。パスキーが強いのは「認証の瞬間」だけで、その前後(リカバリー・セッション・OAuth連携)にはそれぞれ別の設計が要ります。
パスワードとパスキーの本質的な差
比較表で締めます。
| 観点 | パスワード | パスワード + SRP(1Password式) | パスキー(WebAuthn) |
|---|---|---|---|
| 秘密が送信されるか | ✅ される | ❌ 送らない | ❌ 送らない |
| 秘密を鯖側が保持するか | ✅ ハッシュで保持 | ❌ 検証値のみ | ❌ 公開鍵のみ |
| フィッシングサイトで発動するか | ✅ 発動する | ✅ 発動する(ユーザー入力) | ❌ 発動しない |
| ユーザー操作前に防御が働くか | ❌ 働かない | ❌ 働かない | ✅ 働く |
| 対応する攻撃面 | 弱い | 中間者攻撃・DB漏洩 | フィッシング・中間者攻撃 |
パスキーの本質は「秘密を送らない」ことではなく、「そもそも押せなくする」ことです。UIの入口を閉じる設計が、パスワードとの決定的な差になっています。
2026年時点の移行動向
主要プラットフォームの状況を並べておきます。
- Apple: iCloud Keychain 経由で iOS/macOS デバイス間同期。パスキー登録済ユーザー比率が主要サービスで20%超
- Google: Chromeプロファイル同期で Android と Windows/Mac の Chrome 間でパスキー共有
- Microsoft: Windows Hello + Microsoft Entra ID(旧Azure AD)でエンタープライズ展開加速。パスワードレス移行が既定に
- GitHub: 2FA として、また 2FA を超える主要認証手段として推奨
- 1Password/Bitwarden: 独立系パスワードマネージャがパスキー同期を提供、ベンダーロックイン回避の選択肢
移行フェーズにあるサービスの多くは、パスキーとパスワードを並行させています。移行が終わっていない領域(リカバリーフロー、レガシークライアント)は依然として攻撃面です。
まとめ
- パスキーはフィッシングサイトで押しても反応しない。ダイアログすら出ない
- 仕組みは、Authenticator に保存された
rpIdHashとブラウザが計算するSHA256(origin)の照合 - ユーザーが気づく前にブラウザが動作を停止させるのが、パスワード時代との決定的な差
- Related Origins Requests(ROR)で複数ドメイン共通のパスキーが可能。プライマリドメインの支配が根拠
- パスキーは「認証の瞬間」を守る。セッション窃取・リカバリー・OAuth連携は別対策が必要
- 2026年時点、Apple/Google/Microsoft/GitHub がパスキー移行を加速中。パスワード併存フェーズは依然として攻撃面
私は今回、自分の環境で偽オリジンを立てて navigator.credentials.get() を叩き、実際に「ダイアログが出ない」ことを確認しました。想像より徹底した設計で、パスワード時代の「見て気づく」層に頼らない防御になっています。「あなたのパスキーは、フィッシングサイトでは押しても反応しません」という一文は、UIの見た目ではなく鍵の出口の設計に基づいた事実です。面白くいきましょう。
MCPやAIエージェント連携で認証情報を外部プロセスに渡す設計は、パスキーとは別の攻撃面を生みます。「秘密が漏れない設計」から「秘密が使えない環境で漏らさない設計」までを、拙著にまとめています。
MCPセキュリティ実践
Sources: