パスキーとパスワードの比較は、共有秘密と公開鍵認証の比較です。パスワードはユーザーが知っており、サーバーもハッシュ化された形で保持しています。そして、求めてくるページであれば、偽のページを含めてどこでも使えてしまいます。パスキーは WebAuthn の認証情報です。1 つのサイトのために作成される鍵ペアで、ユーザーのデバイス上で指紋、顔、または PIN によってロックが解除され、サイトの身元に結び付けられた新しいチャレンジへの署名に使われます。
比較表
| パスワード | パスキー | |
|---|---|---|
| ユーザーが証明するもの | 共有秘密を知っていること | 生体認証または PIN でローカルにロックを解除する秘密鍵を所持していること |
| サーバーが保存するもの | ソルトと低速なハッシュ値(Argon2id、scrypt、PBKDF2) | 認証情報 ID と公開鍵(下のデモの P-256 では 91 バイト) |
| 本物に似せたフィッシングサイト | ユーザーが入力すればパスワードを受け取る | 使えるものは何も得られない(認証情報はサイトの RP ID とオリジンに結び付いている) |
| サイト間での使い回し | よくあり、パスワードリスト攻撃を招く | 不可能(サイトとアカウントごとに 1 つの鍵ペア) |
| データベースの漏洩 | ハッシュ値に対するオフラインでの推測 | 漏洩するのは公開鍵だけで、リプレイできるものはない |
| NIST SP 800-63B-4(2025年7月) | 単一要素:15 文字以上、AAL1 | 多要素の暗号認証器:AAL2。デバイスに紐付いたハードウェアキーは AAL3 に達しうるが、同期パスキーは達しない |
| デバイスの紛失 | 影響なし | 同期パスキーはプロバイダーのアカウントで復元でき、デバイスに紐付いた鍵にはバックアップ用の認証情報が必要 |
| ほかのデバイス | どこでも入力できる | プロバイダーのデバイス間で同期、それ以外は QR コードと Bluetooth による近接確認(ハイブリッド) |
| 標準 | HTML フォームと HTTP 認証以外にはなし | W3C WebAuthn Level 3(2026年8月25日から勧告)、FIDO CTAP 2.2 |
ログイン時に行われること
登録時に、認証器(スマートフォン、ノートパソコン、パスワードマネージャー、セキュリティキー)は、サイトのリライングパーティ ID(たとえば example.com)に対して鍵ペアを作成し、サイトは公開鍵を保存します。ログイン時には、サイトがランダムなチャレンジを送信します。ブラウザーはページのオリジンを署名対象のクライアントデータに追加し、認証器はユーザー検証の後、そのデータに RP ID のハッシュ値と合わせて署名します。サーバーは、チャレンジ、オリジン、RP ID のハッシュ値、署名を確認します。
以下のスクリプト(Node.js 26.10、Apple M4 Max)は、それぞれの方式でサーバーに何が残るか、そしてどのチェックが本物に似せたサイトを阻止するかを示します。
import { argon2Sync, createHash, generateKeyPairSync, randomBytes, sign, verify } from "node:crypto";
const sha256 = (b) => createHash("sha256").update(b).digest();
// What a password database stores: an Argon2id hash (OWASP minimum: 19 MiB, t=2, p=1)
const params = { nonce: randomBytes(16), memory: 19456, passes: 2, parallelism: 1, tagLength: 32 };
const t0 = performance.now();
const tag = argon2Sync("argon2id", { message: "correct horse battery staple", ...params });
const ms = performance.now() - t0;
console.log(`password: stores salt + ${tag.length}-byte Argon2id hash; one guess costs ${ms.toFixed(0)} ms`);
// What a passkey database stores: the credential's public key
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
const spki = publicKey.export({ type: "spki", format: "der" });
console.log(`passkey: stores a ${spki.length}-byte public key; a leak lets nobody sign in`);
// Authenticator: signs authenticatorData || SHA-256(clientDataJSON)
function getAssertion(rpId, origin, challenge) {
const clientDataJSON = Buffer.from(JSON.stringify({ type: "webauthn.get", challenge, origin }));
const authData = Buffer.concat([sha256(rpId), Buffer.from([0x05]), Buffer.alloc(4)]);
const signature = sign("sha256", Buffer.concat([authData, sha256(clientDataJSON)]), privateKey);
return { clientDataJSON, authData, signature };
}
// Relying party: the checks from WebAuthn §7.2
function check(a, challenge) {
const c = JSON.parse(a.clientDataJSON);
return {
challenge: c.challenge === challenge,
origin: c.origin === "https://example.com",
rpIdHash: a.authData.subarray(0, 32).equals(sha256("example.com")),
signature: verify("sha256", Buffer.concat([a.authData, sha256(a.clientDataJSON)]), publicKey, a.signature),
};
}
const challenge = randomBytes(32).toString("base64url");
console.log("real site:", check(getAssertion("example.com", "https://example.com", challenge), challenge));
console.log("look-alike:", check(getAssertion("examp1e.com", "https://examp1e.com", challenge), challenge));
password: stores salt + 32-byte Argon2id hash; one guess costs 25 ms
passkey: stores a 91-byte public key; a leak lets nobody sign in
real site: { challenge: true, origin: true, rpIdHash: true, signature: true }
look-alike: { challenge: true, origin: false, rpIdHash: false, signature: true }
似せたサイトのケースは、意図的に攻撃者に有利な条件にしてあります。実際には、ブラウザーは example.com の認証情報を別のサイトで使うことをそもそも拒否するため、署名は生成されません。セッションを中継して署名を手に入れたとしても、その署名には誤ったオリジンと RP ID のハッシュ値が含まれます。そのため NIST は、検証者名のバインディングによって WebAuthn をフィッシング耐性のある方式に分類しており、パスワード、ワンタイムコード、プッシュ通知による承認はそうではないとしています。Argon2id によってパスワードの推測ごとに数十ミリ秒のコストがかかりますが、よく使われるパスワードや使い回されたパスワードを選んだユーザーは、それでもすぐに見破られます。公開鍵には、攻撃者が推測できるものが何もありません。
それぞれに残る弱点
パスワードは、使い回し(漏洩したリストを使ったパスワードリスト攻撃)、フィッシングページ、ワンタイムコードまで盗み取るリアルタイムのフィッシングプロキシ、そして弱いパスワードの選択によって破られます。そのため NIST SP 800-63B-4 は、単一要素のパスワードに 15 文字以上(多要素認証の一部として使う場合は 8 文字)を要求し、文字種の組み合わせルールと定期的な変更を禁止し、新しいパスワードを漏洩済みの値やよく使われる値と照合することを要求し、64 文字以上を許可することを推奨しています。ドメインが一致した場合にだけ自動入力するパスワードマネージャーを使えば、使い回しと一部のフィッシングは防げますが、すべてのユーザーが使っているわけではありません。
パスキーでは、リスクが 2 つの場所に移ります。1 つ目はプロバイダーのアカウントです。同期パスキーの安全性は、それを保持する Apple、Google、Microsoft、またはパスワードマネージャーのアカウントの安全性を超えません。そのため NIST は、同期されエクスポートできる鍵を AAL3 では認めていません。2 つ目は、自分のサイトでのアカウントの復旧です。紛失したパスキーをメールのリンクや SMS のコードで置き換えられるなら、攻撃者は代わりにその経路を使います。また、ユーザーがロックアウトされないためには、2 つ目の認証情報か、同期するプロバイダーが必要です。
それぞれの用途
- 一般消費者向けのサイトとアプリ:パスキーをメインの方法にし、次回のパスワードによるログインの直後に作成します。パスワードは任意とします。
- 従業員のログイン:ID プロバイダーを通じてパスキーを使います。管理者やそのほかの AAL3 が必要な役割には、デバイスに紐付いたセキュリティキーを使います。
- 価値の高い一般消費者向けアカウント(銀行、メール、クラウドコンソール):パスキーと強固な復旧手順を組み合わせます。理想的には、2 つ目のパスキーを登録しておきます。
- 共有デバイスやキオスク端末:共有デバイスに認証情報を保存するのではなく、ユーザーのスマートフォンからハイブリッド方式でログインします。
- WebAuthn に対応していないプロトコル(IMAP、従来型の VPN、SSH):アプリ固有のパスワードか、パスキーが使えるブラウザーで開始する OAuth フローを使います。
移行時の注意点
- パスワードと並行してパスキーを追加する:パスワードによるログインに成功した後でパスキーの作成を提案するか、ブラウザーのパスワードマネージャーが対応していれば、WebAuthn Level 3 の条件付き作成を使って自動的に追加します。
- 自動入力 UI を使う:ログインページで
mediation: "conditional"を指定してnavigator.credentials.getを呼び出し、ユーザー名フィールドの候補にパスキーが表示されるようにします。 - 必要なものを保存する:認証情報 ID、公開鍵、署名カウンター、トランスポート、バックアップフラグ、そしてプロバイダーを示す AAGUID です。AAGUID 検索ツールで AAGUID を製品に対応付けられます。
- パスワードを廃止する前に復旧手順を見直す:メールのリンクだけで済ませず、既存のパスキー、登録済みの 2 つ目のパスキー、または本人確認を必須にします。
- プロバイダーとの同期を保つ:ユーザーが認証情報を削除したり名前を変更したりしたときは、Level 3 のシグナルメソッドでプロバイダーに反映させます。複数のドメインにまたがってログインする場合は、関連オリジンを宣言します。
- パスキーサンドボックスでテストする:導入前にテストを行い、WebAuthn インスペクターで実際のレスポンスも確認します。
参考資料
- W3C の WebAuthn Level 3 仕様(§7.1 登録、§7.2 認証セレモニー)
- NIST SP 800-63B-4:認証と認証器の管理(§3.1.1.2 パスワード、§3.2.5 フィッシング耐性、付録 B 同期可能な認証器)
- FIDO Alliance:クライアント・認証器間プロトコル(CTAP)2.2
- OWASP パスワード保存チートシート
- RFC 9106:Argon2