コンテンツにスキップ

WebAuthn とパスキー

WebAuthn とパスキー — W3C WebAuthn Level 3

  • パスキーは公開鍵暗号のクレデンシャル。 端末が鍵ペアを作り、公開鍵だけをサーバーに預ける。 サーバー側に「盗んで使える秘密」が存在しないので、DB が漏れてもログインされない。
  • フィッシングが成立しないのは、署名の対象にアクセス先のオリジンが焼き込まれているから。 生体認証だからでも、端末から鍵が出せないからでもない。 TOTP が持てなかった性質はここから来る。
  • 仕様は W3C WebAuthn Level 3(2026-08-25 に勧告)。 Level 3 で 「passkey」が仕様の正規用語表に載った。Level 2 までの解説とは前提が変わっている。
文書 版・日付 この記事で扱う範囲
WebAuthn Level 3 W3C 勧告 2026-08-25 API、検証手順、フラグ、アテステーション
CTAP 2.3 Proposed Standard 2026-02-26 認証器との通信。1 節だけ
passkeys.dev 用語集 passkey / synced / device-bound の定義
NIST SP 800-63B-4 2025-07-31 フィッシング耐性の定義、AAL、BE/BS の運用
IANA COSE Algorithms alg の値
RFC 8809 2020 WebAuthn のレジストリを定める

「パスキー = 生体認証」ではない。 passkeys.dev の用語集の定義:

The high level, end-user centric term for a FIDO2/WebAuthn Discoverable Credential.

discoverable credential(発見可能クレデンシャル)の、利用者向けの呼び名。 WebAuthn Level 3 はこの語を用語定義に載せた:

Client-side discoverable Public Key Credential Source / Client-side discoverable Credential / Discoverable Credential / Passkey / [DEPRECATED] Resident Credential

discoverable とは、RP が credential ID を渡さなくても認証器が自分で見つけられること。 だから「ユーザー名を入力せずにログイン」ができる。

flowchart TB
    P["パスキー<br/>(discoverable credential)"]
    S["synced passkey<br/>クラウド同期される"]
    D["device-bound passkey<br/>端末から出ない"]

    P --> S
    P --> D

    S --> S1["BE=1<br/>複数端末で使える"]
    S --> S2["同期先が侵害されると<br/>鍵も危険"]
    D --> D1["BE=0<br/>その端末だけ"]
    D --> D2["端末を失うと<br/>復旧できない"]

    style S fill:#4054b2,color:#fff
    style D fill:#4054b2,color:#fff
    style S2 fill:#7a4a12,color:#fff
用語 定義(passkeys.dev)
synced passkey “can reliably be used for bootstrapping sign-in, without requiring other login challenges”
device-bound passkey “bound to a single authenticator … the credential cannot leave the device”

2 つのプロトコルが別々の区間を担当する。 混同しやすい。

flowchart TB
    RP["RP のサーバー"]
    B["ブラウザ / OS<br/>(クライアント)"]
    A["認証器<br/>(端末の TPM / セキュリティキー)"]

    RP <-->|"WebAuthn<br/>navigator.credentials"| B
    B <-->|"CTAP<br/>USB / NFC / BLE / 内部"| A

    style B fill:#4054b2,color:#fff
プロトコル 区間 仕様
WebAuthn RP のサーバー ↔ ブラウザ(JavaScript API) W3C Level 3
CTAP ブラウザ / OS ↔ 認証器 FIDO Alliance CTAP 2.3

RP は CTAP を知らなくてよい。 認証器が USB のセキュリティキーでも、 端末内蔵の Secure Enclave でも、スマホを Bluetooth で使う hybrid transport でも、 RP から見える API は同じ

CTAP 側の拡張で知っておくとよいものが 2 つある。

  • hmac-secret — 認証器の中の秘密から決定的な HMAC 出力を得る。 クレデンシャルに紐づいた対称鍵を導出でき、ローカル暗号化に使われる
  • credProtect — そのクレデンシャルを UV なしで発見・使用できるかのポリシーを指定する
sequenceDiagram
    autonumber
    participant U as 利用者 (ブラウザ)
    participant RP as RP のサーバー
    participant A as 認証器

    U->>RP: パスキーを作りたい
    RP->>RP: challenge を生成<br/>16 バイト以上
    RP-->>U: PublicKeyCredentialCreationOptions<br/>rp / user / challenge<br/>pubKeyCredParams
    U->>A: navigator.credentials.create()
    A->>A: 利用者を検証 (PIN / 生体)<br/>鍵ペアを生成
    A-->>U: attestationObject<br/>clientDataJSON
    U->>RP: 送信
    RP->>RP: 29 ステップで検証<br/>公開鍵を保存
    RP-->>U: 登録完了

秘密鍵は 5 番で作られ、認証器の外に出ない(synced passkey では同期先へは出る)。 サーバーが受け取るのは公開鍵だけ。

sequenceDiagram
    autonumber
    participant U as 利用者 (ブラウザ)
    participant RP as RP のサーバー
    participant A as 認証器

    U->>RP: ログインしたい
    RP->>RP: challenge を生成
    RP-->>U: PublicKeyCredentialRequestOptions<br/>challenge / rpId<br/>allowCredentials
    U->>A: navigator.credentials.get()
    A->>A: 利用者を検証<br/>秘密鍵で署名
    A-->>U: authenticatorData<br/>clientDataJSON / signature
    U->>RP: 送信
    RP->>RP: 25 ステップで検証<br/>公開鍵で署名を確かめる
    RP-->>U: セッション Cookie を発行

allowCredentials を空にすると usernameless になる。 認証器が RP ID だけで discoverable credential を見つけてくるので、利用者はユーザー名を入力しない。

実際に署名して検証できる値で追う。 RP は example.com、鍵は P-256(ES256)。 すべて openssl で再現できる。

WebAuthn §13.4.3:

In order to prevent replay attacks, the challenges MUST contain enough entropy to make guessing them infeasible. Challenges SHOULD therefore be at least 16 bytes long.

challenge (hex) = 9a41d7c3e05f28b6c1f4370c9e58bd26 (16 バイト)
challenge (base64url) = mkHXw-BfKLbB9DcMnli9Jg

2. clientDataJSON — ブラウザが作る

Section titled “2. clientDataJSON — ブラウザが作る”

ここにオリジンが入る。これがフィッシング耐性の源。

{"type":"webauthn.get","challenge":"mkHXw-BfKLbB9DcMnli9Jg","origin":"https://example.com","crossOrigin":false}

111 バイト。SHA-256 は:

b0bc9da54f2d0ac9e1158102288a4124be8f0e150177c5a047fccf29c9741804
メンバ 意味
type webauthn.create / webauthn.get 登録と認証で値が違う。署名の取り違えを防ぐため
challenge base64url RP が送った challenge
origin https://example.com ブラウザが入れる。スクリプトからは詐称できない
crossOrigin false iframe から呼ばれたか
topOrigin crossOrigin が真のときのみ) 最上位のオリジン

type が分かれている理由も仕様に書いてある — signature confusion 攻撃を防ぐため。 登録時の署名を認証時の応答として使い回せない。

3. authenticatorData — 認証器が作る

Section titled “3. authenticatorData — 認証器が作る”

バイト位置まで決まっている。 §6.1。

オフセット 長さ フィールド
0–31 32 rpIdHash — RP ID の SHA-256
32 1 flags
33–36 4 signCount(32 bit unsigned ビッグエンディアン
37– 可変 attestedCredentialData(AT=1 のときのみ)
可変 extensions(ED=1 のときのみ)

AT も ED も無ければ、ちょうど 37 バイト。

rpIdHash = SHA-256("example.com")
= a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947
flags = 1d
signCount = 00000000
authenticatorData (37 バイト)
= a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce19471d00000000
ターミナルウィンドウ
printf '%s' 'example.com' | openssl dgst -sha256
# a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947

rpIdHash はドメイン名のハッシュであって、オリジンのハッシュではない。 スキームもポートも入らない(後述)。

0x1d = 0b00011101 を分解する。

ビット 名前 この例 意味
0 UP (User Present) 1 利用者がその場にいる(タップした)
1 RFU1 0 予約。ゼロ固定
2 UV (User Verified) 1 利用者を検証した(PIN / 生体)
3 BE (Backup Eligibility) 1 複数端末で使えるクレデンシャル
4 BS (Backup State) 1 現在バックアップされている
5 RFU2 0 予約
6 AT (Attested credential data) 0 登録時のみ 1
7 ED (Extension data) 0 拡張データの有無

UPUV は別物。 UP は「その場にいる」(タップ)だけ、 UV は「本人だと確かめた」(PIN や生体)。仕様の文言:

The UP flag SHALL be set if and only if the authenticator performed a test of user presence. The UV flag SHALL be set if and only if the authenticator performed user verification.

AT は登録時のみ 1。 仕様が明示している:

For assertion signatures, the AT flag MUST NOT be set.

署名するのは 2 つを連結したもの。 §6.1.2 / §7.2 ステップ 21。

署名対象 = authenticatorData || SHA-256(clientDataJSON)
flowchart TB
    CD["clientDataJSON<br/>111 バイト<br/>origin を含む"]
    AD["authenticatorData<br/>37 バイト<br/>rpIdHash を含む"]
    H["SHA-256(clientDataJSON)<br/>32 バイト"]
    CAT["連結 = 69 バイト"]
    SIG["秘密鍵で ECDSA 署名<br/>DER 70 バイト"]

    CD --> H
    AD --> CAT
    H --> CAT
    CAT --> SIG

    style CD fill:#4054b2,color:#fff
    style SIG fill:#1f5c33,color:#fff
署名対象 (69 バイト)
= a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947 ← rpIdHash
1d ← flags
00000000 ← signCount
b0bc9da54f2d0ac9e1158102288a4124be8f0e150177c5a047fccf29c9741804 ← SHA-256(clientDataJSON)

origin そのものは署名対象に直接入らない。ハッシュを経由して入る。 だから clientDataJSON を 1 バイトでも書き換えると署名が合わなくなる。

署名(DER, 70 バイト):

30440220 4716e68ac07a6adc969db3f2c50035998cb4bcacde78a41de95b11394e463273
0220 5924c594706da88ccca8279da335e863c1169c3c274604141dadcdb3459aa438

公開鍵(この記事の鍵ペアの公開鍵):

-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYCDiZEn8tEwSwhhYhRJDrIR8YCzo
KFNty0ORARpfmpf3XzBjg/deVF/Se4Ex5mNYtTCCF5lDzbP0q01RVE7V4A==
-----END PUBLIC KEY-----
ターミナルウィンドウ
# 署名対象と署名をファイルに落として検証する
xxd -r -p <<'EOF' > signed.bin
a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce19471d00000000
b0bc9da54f2d0ac9e1158102288a4124be8f0e150177c5a047fccf29c9741804
EOF
xxd -r -p <<'EOF' > sig.bin
304402204716e68ac07a6adc969db3f2c50035998cb4bcacde78a41de95b11394e46327302
205924c594706da88ccca8279da335e863c1169c3c274604141dadcdb3459aa438
EOF
openssl dgst -sha256 -verify pub.pem -signature sig.bin signed.bin
# Verified OK

7. オリジンを 1 文字変えると落ちる

Section titled “7. オリジンを 1 文字変えると落ちる”

ここがこのページの核心。 偽サイト https://exampIe.com (小文字の l が大文字の I)で同じことをすると、ブラウザが入れる origin が変わる。

本物 origin = https://example.com
SHA-256(clientDataJSON) = b0bc9da54f2d0ac9e1158102288a4124be8f0e150177c5a047fccf29c9741804
偽物 origin = https://exampIe.com
SHA-256(clientDataJSON) = 3d7d5c749235413c2861dd0821cf8609286627f8f4707cb5c194a364681dec93

ハッシュが全く違う値になるので、署名対象が変わり、検証が落ちる。

openssl dgst -sha256 -verify pub.pem -signature sig.bin signed_evil.bin
# Verification failure
sequenceDiagram
    autonumber
    participant U as 利用者 (ブラウザ)
    participant M as 偽サイト (AiTM)
    participant S as 本物のサーバー

    U->>M: 偽サイトを開く
    M->>S: challenge を取りに行く
    S-->>M: challenge
    M-->>U: 同じ challenge を渡す
    U->>U: ブラウザが origin に<br/>偽サイトのドメインを入れる
    U-->>M: 署名 (偽オリジンで作られたもの)
    M->>S: そのまま転送
    S->>S: origin が一致しない<br/>署名も一致しない
    S-->>M: 認証失敗

中間者は署名を作り直せない。 秘密鍵を持っていないので、 origin を本物に書き換えると署名が合わなくなる。 TOTP では 6 桁を転送するだけで通ったのに対し、構造的に不可能になる。

さらに、認証器がそもそも署名を出さない。認証器は RP ID を見て 「このサイト向けのクレデンシャルは持っていない」と答えるので、 利用者が偽サイトだと気づかなくても何も起きない

なぜフィッシングが成立しないのか

Section titled “なぜフィッシングが成立しないのか”

鍵の形がサイトの名前そのもので、別のサイトの鍵穴には物理的に入らないという比喩

生体認証だからではない。 NIST SP 800-63B-4 §3.2.5 のフィッシング耐性の定義:

phishing resistance is the ability of the authentication protocol to prevent the disclosure of authentication secrets and valid authenticator outputs to an impostor verifier … without relying on the vigilance of the claimant

“without relying on the vigilance of the claimant” — 利用者の注意力に頼らない、が要件。 そして耐性を与える方式は 2 つだけとされ、WebAuthn は後者の例として名指しされている。

方式 中身
Channel binding TLS のチャネルに束縛する
Verifier name binding 検証者のドメイン名で鍵を選ぶ

WebAuthn … is an example of a standard that provides phishing resistance through verifier name binding by choosing an authenticator secret based on the authenticated domain name of the verifier

「認証されたドメイン名に基づいて秘密を選ぶ」。 ここに生体認証は出てこない。 UV は「多要素であること」や「認証の意図」の要件であって、 フィッシング耐性の根拠ではない

rp.id にオリジンをそのまま入れるのは誤り。 仕様の注記:

An RP ID is based on a host’s domain name. It does not itself include a scheme or port, as an origin does.

そして使える値のルール(§4):

The RP ID must be equal to the origin’s effective domain, or a registrable domain suffix of the origin’s effective domain.

オリジンが https://login.example.com:1337 のとき:

rp.id 可否 理由
login.example.com (省略時の既定) 実効ドメインそのもの
example.com 登録可能なドメインの接尾辞
m.login.example.com 不可 兄弟・下位のドメイン
com 不可 短すぎる(パブリックサフィックス)
https://login.example.com 不可 スキームは入らない

rp.idexample.com にすると、app.example.com でも www.example.com でも 同じパスキーが使える。 逆に login.example.com にすると、そのホストでしか使えない。 後から広げられないので最初に決める。

  • オリジンは https であること。ただし localhosthttp でもよい
  • ポートは制限されない

BE と BS で同期の有無を見分ける

Section titled “BE と BS で同期の有無を見分ける”

パスキーが「同期されるもの」か「その端末だけのもの」かは、この 2 ビットで分かる。

BE BS 意味
0 0 single-device credential(device-bound passkey)
0 1 仕様上ありえない組み合わせ。検証で弾く
1 0 multi-device credential だが、いま同期されていない
1 1 multi-device credential で、同期されている
  • BE は登録時に確定し、以後 MUST NOT change 変わったら検証を失敗させる
  • BS は変わる。 同期の状態を表す
flowchart TB
    R["登録時に BE を記録する"]
    C{"BE = 0 か"}
    D["device-bound<br/>その端末が壊れたら復旧できない"]
    S["multi-device<br/>同期先の安全性に依存する"]
    D1["追加の認証器の登録を促す<br/>回復手段を用意する (SHOULD)"]
    S1["BS が 0 から 1 になったら<br/>パスワード削除を促してよい (MAY)"]
    S2["BS が 1 から 0 になったら<br/>他の要素の確認を促す (SHOULD)"]

    R --> C
    C -->|"はい"| D --> D1
    C -->|"いいえ"| S
    S --> S1
    S --> S2

    style D fill:#7a4a12,color:#fff
    style S fill:#4054b2,color:#fff

NIST SP 800-63B-4 の Appendix B(規範的)もこのフラグに触れている。

This flag is necessary to distinguish authenticators that are device-bound from those that may be cloned to more than one device.

ただし BS のほうには釘を刺している。

Agencies SHOULD NOT condition acceptance based on this flag for public-facing applications due to user experience concerns.

BS で受け入れ可否を決めない。 同期の状態は利用者にはどうしようもないことが多い。

signCount は必ず増えるわけではない

Section titled “signCount は必ず増えるわけではない”

「signCount は必ず検証しなければならない」は誤り。

  • 認証器が実装するのは SHOULD(MUST ではない)
  • “Authenticators that do not implement a signature counter leave the signCount … constant at zero” — 0 固定は正式に許容されている
  • §7.2 ステップ 22 が動くのは “If authData.signCount is nonzero or credentialRecord.signCount is nonzero” のときだけ。 両方 0 なら何もしない

増えていなかったときの扱いも RP 次第:

This is a signal, but not proof, that the authenticator may be cloned

クローンの「兆候」であって「証拠」ではない。 認証を失敗させるかどうかは RP のポリシー。

synced passkey では実質的に機能しない。 複数端末が同じクレデンシャルを使うので、 カウンタを一貫して増やせない。多くの実装が 0 を返す。

「必ず検証しなければならない」は誤り。 attestation既定値は "none"

意味
none(既定) アテステーションを求めない。クライアントが None statement に置き換える
indirect クライアントが匿名化 CA 経由の statement に差し替えてよい
direct 認証器が生成した statement をそのまま受け取りたい
enterprise AAGUID を含む識別可能な statement。ユーザーエージェントか認証器の設定で許可されていなければ MUST NOT

形式は Level 3 で 9 種類。

形式 備考
packed 汎用・コンパクト
tpm TPM ベース
android-key Android の secure environment
android-safetynet Level 3 で deprecated。将来削除予定と明記
fido-u2f U2F 互換
none 情報なし
apple Apple 独自
compound Level 3 新設。 複数の statement をまとめて運ぶ

一般公開のサービスでアテステーションを必須にしない。 NIST の助言:

The unavailability of attestations SHOULD NOT block the use of syncable authenticators for broad public-facing applications.

必須にすると、アテステーションを出せない認証器の利用者が フィッシングに弱い代替手段(SMS など)へ押し戻される

必要なのは、認証器の機種を制限したい企業内の用途だけ。

ここは実際に混乱を招く。 IANA の COSE レジストリと WebAuthn 仕様が逆のことを言っている

alg 名前 IANA の Recommended 列
-7 ES256 Deprecated
-8 EdDSA Deprecated
-257 RS256 No
-9 ESP256 Yes
-19 Ed25519 Yes

RFC 9864(Fully-Specified Algorithms)が ES256 のような曲線が一意に決まらない識別子を非推奨にし、 ESP256 / Ed25519 を新設した。理由は “it is not known which of the curves Ed25519 and/or Ed448 are supported”

しかし WebAuthn Level 3 は逆を言う。

The following fully-specified COSEAlgorithmIdentifier values introduced by [RFC9864] are NOT RECOMMENDED in pubKeyCredParams: -9 (ESP256); use -7 (ES256) instead or in addition.

理由は相互運用性 — “they are not interchangeable in practice since many implementations support the latter identifiers but not the former ones”。 WebAuthn は §5.8.5 で「ES256 なら曲線は P-256 固定」と別途縛っているので、 RFC 9864 が問題にした曖昧さがそもそも発生しない。

結論: WebAuthn では -7 / -8 / -257 を使う。 レジストリの Deprecated に 引きずられて -9 に変えると、動かない認証器が出る。

pubKeyCredParams: [
{ type: 'public-key', alg: -8 }, // EdDSA
{ type: 'public-key', alg: -7 }, // ES256
{ type: 'public-key', alg: -257 }, // RS256
]

この 3 つを並べるのが仕様の推奨。 順番が優先度になる。

パスワードレスと 2 要素目は設定が違う

Section titled “パスワードレスと 2 要素目は設定が違う”

「WebAuthn は 2 要素認証の 2 要素目専用」も「同じ設定で両方できる」も誤り。

flowchart TB
    Q{"何をさせたいか"}
    PL["パスワードレス<br/>これ 1 つでログイン"]
    SF["2 要素目<br/>パスワードの後に追加"]

    Q -->|"単独で認証"| PL
    Q -->|"追加の要素"| SF

    PL --> P1["residentKey: required<br/>userVerification: required<br/>allowCredentials は空でよい"]
    SF --> S1["residentKey: discouraged で可<br/>userVerification: preferred<br/>allowCredentials に ID を並べる"]

    style PL fill:#1f5c33,color:#fff
    style SF fill:#4054b2,color:#fff
パスワードレス 2 要素目
residentKey required discouraged でよい
userVerification required preferred(既定)
allowCredentials 空にできる 登録済みを並べる
利用者が入力するもの PIN / 生体だけ パスワード + タップ
NIST 上の扱い UV があれば多要素相当 単要素の暗号認証器

UV が無ければ単要素扱いになる。 NIST SP 800-63B-4 Appendix B:

If the user is not verified, agencies SHALL treat the authenticator as a single-factor cryptographic authenticator.

メールアドレスやユーザー名を入れてはいけない。 規範文:

The user handle MUST NOT contain personally identifying information about the user, such as a username or e-mail address

制約 内容
最大長 64 バイト
MUST NOT be empty
内容 個人を識別できる情報を入れない
一定値 アカウント間で同じ値にしない(SHOULD NOT)

アカウントごとのランダムなバイト列を使い、DB で内部 ID に対応づける。

// 良い: アカウント作成時に一度だけ生成して保存する
const userHandle = crypto.randomBytes(32); // 64 バイト以内
// 悪い: 認証器に平文で保存され、他サイトでの表示にも使われうる
const userHandle = Buffer.from('taro@example.com');

理由は、この値が認証器に保存され、アカウント選択画面などに関わるから。 端末を他人に渡したときに、そこから利用者が特定できてしまう。

conditional UI(パスキーの自動入力)

Section titled “conditional UI(パスキーの自動入力)”

ログイン欄をタップするとパスキーの候補が出て、端末の生体認証で確定する画面

利用者が「パスキーでログイン」を押さなくても、入力欄をタップした時点で候補が出る。 これが conditional mediation。

if (await PublicKeyCredential.isConditionalMediationAvailable?.()) {
navigator.credentials.get({
publicKey: { challenge, rpId: 'example.com' }, // allowCredentials は空
mediation: 'conditional',
});
}

HTML 側は autocompletewebauthn を足す。

<input type="text" name="username" autocomplete="username webauthn" />

使ってよいケースと使ってはいけないケース

Section titled “使ってよいケースと使ってはいけないケース”
  • rp.id を決めてある(後から広げられない。サブドメイン構成を先に固める)
  • HTTPS で提供しているlocalhost を除き http では動かない)
  • user.id にランダムなバイト列を使う(メールアドレスを入れない)
  • challenge をサーバー側セッションに保存し、使い捨てにしている
  • 複数の認証器を登録できる(1 つ壊れても締め出されない)
  • 回復手段がある(BE=0 の device-bound では特に必要)
  • 公開鍵と credentialRecord を保存する設計になっている

ひとつでも欠けたら止まる。 特に 1 つ目は、後から rp.id を変えると 登録済みのパスキーが全部使えなくなる

WebAuthn が成立する 6 つの条件を 1 枚にまとめた復習カード

試験前に見返すならこの 1 枚。 コーラルで示した rp.id は、 後から変えると全パスキーが無効になる唯一の項目。

flowchart TB
    Q1{"フィッシング耐性が<br/>要るか"}
    Q2{"AAL3 が要件か"}
    Q3{"利用者が対応端末を<br/>持っているか"}

    OK["パスキー<br/>synced で可"]
    DB["device-bound のみ<br/>セキュリティキーを配る"]
    MIX["パスキーを主に<br/>TOTP を代替に置く"]
    NG["TOTP で十分<br/>SMS は避ける"]

    Q1 -->|"いいえ"| NG
    Q1 -->|"はい"| Q2
    Q2 -->|"はい"| DB
    Q2 -->|"いいえ"| Q3
    Q3 -->|"はい"| OK
    Q3 -->|"いいえ"| MIX

    style OK fill:#1f5c33,color:#fff
    style DB fill:#4054b2,color:#fff
    style NG fill:#7a2222,color:#fff
ケース 成立する理由 一緒にやること
一般向けサービスのパスワード置き換え 同期パスキーなら端末をまたげる パスワードは残す。TOTP を代替に置く
管理画面・特権アカウント フィッシングが最大の脅威で、それを構造的に止められる device-bound(セキュリティキー)を必須に
社内 SSO の OIDC の OP 側 OP に 1 回入れれば全 RP に効く OP でのみ実装すればよい
既存の 2 要素の強化 パスワード + パスキーで、片方が漏れても通らない residentKey: discouraged でよい
AAL3 が要件の政府系 非エクスポートのハードウェアで満たせる synced passkey は使えない。アテステーションで機種を確認
ケース できないこと
synced passkey で AAL3 を満たそうとする NIST が SHALL NOT。秘密鍵がエクスポート可能だから
一般公開サービスでアテステーションを必須にする 出せない認証器の利用者を SMS へ押し戻す
rp.id にオリジン(スキーム付き)を入れる RP ID はホスト名のみ。スキームもポートも入らない
user.id にメールアドレスを入れる 規範文が MUST NOT。認証器に残る
signCount の不増加で必ず失敗させる 0 固定の認証器と synced passkey が弾かれる
認証器を 1 つしか登録させない 紛失で締め出される。回復が最大の攻撃面になる
challenge を使い回す / クライアントで生成する リプレイが通る
パスワードを即座に削除する 端末を持たない環境で入れなくなる。BS の変化を見て段階的に
種別
パスキー提供元 iCloud キーチェーン、Google パスワードマネージャー、1Password、Bitwarden、Windows Hello
セキュリティキー YubiKey、Google Titan、SoloKeys
サーバー側ライブラリ @simplewebauthn/server(Node.js)、webauthn4j(Java)、py_webauthn(Python)
IdP の機能として Auth0、Okta、Keycloak、Microsoft Entra ID
動作確認 webauthn.iopasskeys.dev
// サーバーから options を受け取る。challenge はサーバーが生成しセッションに保存済み
const options = await fetch('/webauthn/register/options', { method: 'POST' }).then(r => r.json());
const credential = await navigator.credentials.create({
publicKey: {
rp: { id: 'example.com', name: 'devnote' }, // ホスト名のみ。スキームを入れない
user: {
id: base64urlToBuffer(options.userHandle), // ランダムなバイト列。64 バイト以内
name: 'taro@example.com', // 表示用。user.id とは別物
displayName: '山田 太郎',
},
challenge: base64urlToBuffer(options.challenge), // 16 バイト以上
pubKeyCredParams: [
{ type: 'public-key', alg: -8 }, // EdDSA
{ type: 'public-key', alg: -7 }, // ES256
{ type: 'public-key', alg: -257 }, // RS256
],
authenticatorSelection: {
residentKey: 'required', // パスキーにする
userVerification: 'required', // 多要素相当にする
},
excludeCredentials: options.existing, // 同じ認証器の二重登録を防ぐ
attestation: 'none', // 既定。一般向けはこれでよい
timeout: 60_000,
},
});
await fetch('/webauthn/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
attestationObject: bufferToBase64url(credential.response.attestationObject),
transports: credential.response.getTransports?.() ?? [],
},
}),
});

excludeCredentials を渡す。 渡さないと、同じ認証器で二重に登録できてしまい、 どちらが有効か分からなくなる。

ライブラリを使うのが正解だが、何を確かめているかは押さえておく。 下は §7.2 の主要ステップだけを素で書いたもの。

import crypto from 'node:crypto';
const RP_ID = 'example.com';
const EXPECTED_ORIGIN = 'https://example.com';
export function verifyAssertion({ clientDataJSON, authenticatorData, signature }, record, expectedChallenge) {
// --- clientDataJSON 側 (ステップ 9〜14) ---
const C = JSON.parse(clientDataJSON.toString('utf8'));
if (C.type !== 'webauthn.get') throw new Error('type mismatch'); // ステップ 10
if (C.challenge !== expectedChallenge) throw new Error('challenge mismatch'); // ステップ 11
if (C.origin !== EXPECTED_ORIGIN) throw new Error('origin mismatch'); // ステップ 12
if (C.crossOrigin) throw new Error('unexpected cross-origin'); // ステップ 13
// --- authenticatorData 側 (ステップ 15〜19) ---
const rpIdHash = authenticatorData.subarray(0, 32);
const flags = authenticatorData[32];
const signCount = authenticatorData.readUInt32BE(33);
const expectedRpIdHash = crypto.createHash('sha256').update(RP_ID).digest();
if (!crypto.timingSafeEqual(rpIdHash, expectedRpIdHash)) throw new Error('rpIdHash mismatch');
const UP = !!(flags & 0x01), UV = !!(flags & 0x04);
const BE = !!(flags & 0x08), BS = !!(flags & 0x10);
if (!UP) throw new Error('user not present'); // ステップ 16
if (!UV) throw new Error('user not verified'); // ステップ 17 (UV 必須のとき)
if (!BE && BS) throw new Error('invalid BE/BS combination'); // ステップ 18
if (BE !== record.backupEligible) throw new Error('BE changed'); // ステップ 19
// --- 署名の検証 (ステップ 20〜21) ---
const hash = crypto.createHash('sha256').update(clientDataJSON).digest();
const signed = Buffer.concat([authenticatorData, hash]);
const ok = crypto.verify('sha256', signed, record.publicKeyPem, signature);
if (!ok) throw new Error('bad signature');
// --- signCount (ステップ 22) ---
// どちらも 0 なら何もしない。0 固定の認証器と synced passkey がここに来る
if (signCount !== 0 || record.signCount !== 0) {
if (signCount <= record.signCount) {
// 「クローンの兆候であって証拠ではない」。失敗させるかは RP のポリシー
logPossibleClone(record.id, signCount, record.signCount);
}
}
// --- 記録の更新 (ステップ 24) ---
return { signCount, backupState: BS, userVerified: UV };
}

押さえる点は 4 つ。

  1. origin の比較は完全一致。前方一致にすると https://example.com.evil.test が通る
  2. rpIdHash は RP ID のハッシュ。オリジンのハッシュではない
  3. BE !== record.backupEligible は失敗(BE は変わらないと仕様が定めている)。 BS は記録するだけでポリシー判断に使う
  4. signCount は両方 0 なら何もしない。0 固定を弾かない
// 発行: サーバー側セッションに保存する。クライアントに状態を持たせない
app.post('/webauthn/login/options', (req, res) => {
const challenge = crypto.randomBytes(32); // 16 バイト以上
req.session.webauthnChallenge = challenge.toString('base64url');
req.session.challengeIssuedAt = Date.now();
res.json({ challenge: challenge.toString('base64url'), rpId: RP_ID });
});
// 検証: 1 回使ったら必ず消す
app.post('/webauthn/login/verify', async (req, res) => {
const expected = req.session.webauthnChallenge;
delete req.session.webauthnChallenge; // 先に消す
if (!expected) return res.status(400).send('no challenge');
if (Date.now() - req.session.challengeIssuedAt > 60_000) return res.status(400).send('expired');
// ... verifyAssertion へ
});

先に消してから検証する。 検証に失敗したときも challenge を使い切ることで、 同じ challenge に対する総当たりを防ぐ。

認証器を 1 つしか登録させないのが最大の運用リスク。

手段 注意
複数のパスキーを登録させる(別端末・セキュリティキー) 最も素直。登録画面への導線を用意する
リカバリコード TOTP と同じ。ハッシュして保存し、1 回で消す
パスワードを残す フィッシング耐性は落ちるが、締め出しよりはよい
本人確認を経た手動リセット ここが最も攻撃される。 強度が全体の上限になる

BE=0(device-bound)のときは特に重要。 仕様も 「追加の認証器の登録や回復手段の用意を RP は SHOULD」としている。

すぐには消さない。 BS の変化を見て段階的に進める。

状態 対応
パスキー登録直後(BE=1, BS=0) まだ同期されていない。パスワードは残す
BS が 0 → 1 になった 同期された合図。パスワード削除を促してよい(MAY)
BS が 1 → 0 になった 他の認証手段の確認を促す(SHOULD)
BE=0(device-bound のみ) パスワードを消さない。 端末を失うと復旧できない
扱い
challenge 使用中は残さない。使い終われば無害
clientDataJSON / authenticatorData 残してよい。 秘密を含まない
signature 残してよい。使い回せない
user.id(user handle) 利用者 ID と同じ扱い
公開鍵 残してよい。公開鍵なので

WebAuthn は「ログに残してはいけない値」がほとんど無い。 やりとりされるものに秘密が含まれないからで、これは Basic 認証TOTP との決定的な違い。

  • HTTPS が必須。 例外は localhosthttp のみ
  • iframe から使うには publickey-credentials-get / publickey-credentials-create の Permissions Policy が要る
  • crossOrigintopOrigin を検証して、意図しない埋め込みを弾く
  • パスキー = discoverable credential。生体認証のことではない
  • フィッシング耐性の源はオリジン束縛。生体認証でも「鍵が出せない」ことでもない
  • NIST の定義は verifier name binding — 検証者のドメイン名で秘密を選ぶこと
  • 署名対象は authenticatorData || SHA-256(clientDataJSON)
  • originブラウザが clientDataJSON に入れる。スクリプトから詐称できない
  • rp.id はホスト名のみ。スキームもポートも入らない
  • rp.id実効ドメインか、その登録可能な接尾辞。兄弟ドメインは不可
  • rp.id は後から変えられない。変えると登録済みパスキーが全部無効になる
  • authenticatorDataAT も ED も無ければちょうど 37 バイト
  • rpIdHash = 32 バイト、flags = 1 バイト、signCount = 4 バイト ビッグエンディアン
  • フラグは bit 0 = UP、bit 2 = UV、bit 3 = BE、bit 4 = BS、bit 6 = AT、bit 7 = ED
  • UPUV は別。UP は「その場にいる」、UV は「本人だと確かめた」
  • AT登録時のみ。認証の署名では MUST NOT be set
  • BE=0 かつ BS=1 はありえない。検証で弾く
  • BE は登録後に変わらないMUST NOT change)。BS は変わる
  • BS で受け入れ可否を決めない(NIST が SHOULD NOT)
  • synced passkey は AAL3 に使えない(NIST が SHALL NOT
  • signCount は必ず増えるわけではない。0 固定は正式に許容
  • signCount の不増加は**「兆候であって証拠ではない」**。失敗させるかは RP 次第
  • アテステーションの既定は none。一般向けサービスで必須にしない
  • android-safetynetLevel 3 で deprecated
  • user.id に個人を識別できる情報を入れないMUST NOT)。64 バイト以内
  • challenge16 バイト以上(SHOULD)。100 bit は credential ID のほうの話
  • credential ID は 16 バイト以上・100 bit 以上のエントロピー・1023 バイト以下
  • alg-7 / -8 / -257 を使う。IANA が Deprecated でも WebAuthn では現役
  • パスワードレスと 2 要素目は residentKeyuserVerification の設定が違う
  • HTTPS 必須。例外は localhosthttp のみ
パスキーとは何か。1 行で。

FIDO2 / WebAuthn の discoverable credential の、利用者向けの呼び名(passkeys.dev の用語集)。

生体認証のことではない。生体や PIN は UV(User Verification)を満たす手段であって、 パスキーの定義には含まれない。WebAuthn Level 3 で 「Passkey」が仕様の用語定義に正式に載った。

WebAuthn がフィッシングに強い理由を説明せよ。生体認証だからか。

生体認証だからではない。オリジン束縛による。

ブラウザが clientDataJSONアクセス先のオリジンを入れ、 認証器はそれを含むデータに署名する。偽サイトで署名させても origin が偽サイトのものになるので、本物のサーバーで検証に落ちる。

NIST SP 800-63B-4 §3.2.5 はこれを verifier name binding と呼び、 “choosing an authenticator secret based on the authenticated domain name of the verifier” と説明している。しかも認証器は RP ID が違えばそもそも署名を出さない

認証器が署名する対象は何か。
authenticatorData || SHA-256(clientDataJSON)

authenticatorData(37 バイト以上)と、clientDataJSON の SHA-256(32 バイト)を 連結したバイト列origin はハッシュ経由で署名に入る。

`authenticatorData` のバイトレイアウトを書け。
オフセット 長さ フィールド
0–31 32 rpIdHash(RP ID の SHA-256)
32 1 flags
33–36 4 signCount(32 bit unsigned ビッグエンディアン)
37– 可変 attestedCredentialData(AT=1 のときのみ)
可変 extensions(ED=1 のときのみ)

AT も ED も無ければちょうど 37 バイト。

`flags` のビット割り当てを答えよ。
  • bit 0 — UP(User Present)
  • bit 1 — RFU1(予約、ゼロ固定)
  • bit 2 — UV(User Verified)
  • bit 3 — BE(Backup Eligibility)
  • bit 4 — BS(Backup State)
  • bit 5 — RFU2(予約)
  • bit 6 — AT(Attested credential data included)
  • bit 7 — ED(Extension data included)

AT は登録時のみ。認証の署名では MUST NOT be set

`rp.id` に `https://login.example.com` と書いてよいか。

いけない。 RP ID はホスト名のみで、スキームもポートも含まない。

An RP ID is based on a host’s domain name. It does not itself include a scheme or port, as an origin does.

オリジンが https://login.example.com:1337 なら、有効なのは login.example.com(省略時の既定)と example.comm.login.example.comcom は無効。

BE と BS の 4 通りの組み合わせのうち、ありえないものはどれか。

BE=0 かつ BS=1 バックアップ対象でないのにバックアップされている、 という状態は成立しない。検証ステップで明示的に弾く(登録時・認証時とも)。

  • BE=0, BS=0 — single-device credential
  • BE=1, BS=0 — multi-device だが未同期
  • BE=1, BS=1 — multi-device で同期済み
BE と BS のうち、登録後に変わらないのはどちらか。

BE。 仕様が MUST NOT change と定めている。 記録した値と一致しなければ検証を失敗させる

BS は同期の状態なので変わる。ただし NIST は 「一般公開のアプリでは BS を受け入れ可否の条件にしない(SHOULD NOT)」としている。 利用者にはどうしようもないことが多いため。

synced passkey で AAL3 を満たせるか。

満たせない。 NIST SP 800-63B-4 の Appendix B(規範的):

Since syncable authenticators require the private key to be exportable, syncable authenticators SHALL NOT be used at AAL3.

同期できる = 秘密鍵をエクスポートできるなので、AAL3 の非エクスポート要件に反する。 AAL2 までは可。 AAL3 が要るなら device-bound(セキュリティキー)にする。

`signCount` が増えていなかったら認証を失敗させるべきか。

仕様は失敗を要求していない。

This is a signal, but not proof, that the authenticator may be cloned

クローンの兆候であって証拠ではなく、どう扱うかは RP のポリシー。 そもそも認証器の実装は SHOULD で、未実装なら 0 固定が正式に許容されている。 検証ステップも「どちらかが 0 でなければ」比較する、という条件付き。

synced passkey では実質的に機能しない(複数端末が同じクレデンシャルを使うため)。

アテステーションは必ず検証しなければならないか。

いいえ。attestation の既定値は "none"

一般公開のサービスでは必須にしないほうがよい。NIST の助言:

The unavailability of attestations SHOULD NOT block the use of syncable authenticators for broad public-facing applications.

必須にすると、出せない認証器の利用者がフィッシングに弱い代替手段へ押し戻される。 必要なのは、認証器の機種を制限したい企業内の用途。

`user.id` にメールアドレスを入れてよいか。

いけない。 規範文:

The user handle MUST NOT contain personally identifying information about the user, such as a username or e-mail address

最大 64 バイト、空は不可、アカウント間で同じ値にしない。 アカウントごとのランダムなバイト列を使い、DB で内部 ID に対応づける。 この値は認証器に保存されるので、端末から利用者が特定できてしまう。

`challenge` の長さの要件は。100 bit のエントロピーが要るのはどれか。
  • challenge16 バイト以上(SHOULD)(§13.4.3)
  • credential ID16 バイト以上かつ 100 bit 以上のエントロピー、1023 バイト以下(§4)

この 2 つは混同されやすい。 100 bit のほうは認証器が生成する credential ID の話で、 RP が作る challenge の規定ではない。

`pubKeyCredParams` に指定する `alg` は何を選ぶべきか。

-8(EdDSA)、-7(ES256)、-257(RS256) を並べる。

IANA の COSE レジストリでは RFC 9864 により -7 / -8Deprecated と マークされているが、WebAuthn Level 3 は逆に、新しい -9(ESP256)を NOT RECOMMENDED としている:

use -7 (ES256) instead or in addition

理由は相互運用性 — “many implementations support the latter identifiers but not the former ones”。レジストリの Deprecated に引きずられて -9 にすると動かない認証器が出る。

パスワードレスと 2 要素目では設定がどう変わるか。
パスワードレス 2 要素目
residentKey required(discoverable にする) discouraged でよい
userVerification required preferred(既定)
allowCredentials 空にできる 登録済みを並べる

UV が無ければ単要素の暗号認証器扱いになる(NIST SP 800-63B-4 Appendix B)。 「同じ設定で両方できる」は誤り。

WebAuthn と CTAP の違いは。
  • WebAuthnRP のサーバー ↔ ブラウザ。W3C の JavaScript API
  • CTAPブラウザ / OS ↔ 認証器。FIDO Alliance のプロトコル(現行 CTAP 2.3)

RP は CTAP を知らなくてよい。認証器が USB のセキュリティキーでも端末内蔵でも、 スマホを使う hybrid transport でも、RP から見える API は同じ

WebAuthn でログに残してはいけない値はどれか。

ほとんど無い。 clientDataJSONauthenticatorDatasignature・公開鍵は すべて残してよい(秘密を含まず、使い回せない)。

扱いに注意するのは **user.id(user handle)**で、これは利用者 ID と同じ扱いにする。

やりとりに秘密が含まれないのが WebAuthn の構造的な利点で、 Basic 認証TOTP との決定的な違い。