WebAuthn とパスキー

3 行まとめ
Section titled “3 行まとめ”- パスキーは公開鍵暗号のクレデンシャル。 端末が鍵ペアを作り、公開鍵だけをサーバーに預ける。 サーバー側に「盗んで使える秘密」が存在しないので、DB が漏れてもログインされない。
- フィッシングが成立しないのは、署名の対象にアクセス先のオリジンが焼き込まれているから。 生体認証だからでも、端末から鍵が出せないからでもない。 TOTP が持てなかった性質はここから来る。
- 仕様は W3C WebAuthn Level 3(2026-08-25 に勧告)。 Level 3 で 「passkey」が仕様の正規用語表に載った。Level 2 までの解説とは前提が変わっている。
当たるべき文書
Section titled “当たるべき文書”| 文書 | 版・日付 | この記事で扱う範囲 |
|---|---|---|
| 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 のレジストリを定める |
パスキーとは何か
Section titled “パスキーとは何か”「パスキー = 生体認証」ではない。 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” |
WebAuthn と CTAP
Section titled “WebAuthn と CTAP”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 を見つけてくるので、利用者はユーザー名を入力しない。
データフローを追う
Section titled “データフローを追う”実際に署名して検証できる値で追う。 RP は example.com、鍵は P-256(ES256)。
すべて openssl で再現できる。
1. challenge を作る
Section titled “1. challenge を作る”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-BfKLbB9DcMnli9Jg2. 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") = a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947flags = 1dsignCount = 00000000
authenticatorData (37 バイト)= a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce19471d00000000printf '%s' 'example.com' | openssl dgst -sha256# a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947rpIdHash はドメイン名のハッシュであって、オリジンのハッシュではない。
スキームもポートも入らない(後述)。
4. flags の 8 ビット
Section titled “4. flags の 8 ビット”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 | 拡張データの有無 |
UP と UV は別物。 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.
5. 署名の対象
Section titled “5. 署名の対象”署名するのは 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 バイトでも書き換えると署名が合わなくなる。
6. 実際に検証する
Section titled “6. 実際に検証する”署名(DER, 70 バイト):
30440220 4716e68ac07a6adc969db3f2c50035998cb4bcacde78a41de95b11394e463273 0220 5924c594706da88ccca8279da335e863c1169c3c274604141dadcdb3459aa438公開鍵(この記事の鍵ペアの公開鍵):
-----BEGIN PUBLIC KEY-----MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYCDiZEn8tEwSwhhYhRJDrIR8YCzoKFNty0ORARpfmpf3XzBjg/deVF/Se4Ex5mNYtTCCF5lDzbP0q01RVE7V4A==-----END PUBLIC KEY-----# 署名対象と署名をファイルに落として検証するxxd -r -p <<'EOF' > signed.bina379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce19471d00000000b0bc9da54f2d0ac9e1158102288a4124be8f0e150177c5a047fccf29c9741804EOF
xxd -r -p <<'EOF' > sig.bin304402204716e68ac07a6adc969db3f2c50035998cb4bcacde78a41de95b11394e46327302205924c594706da88ccca8279da335e863c1169c3c274604141dadcdb3459aa438EOF
openssl dgst -sha256 -verify pub.pem -signature sig.bin signed.bin# Verified OK7. オリジンを 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 failuresequenceDiagram
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 はオリジンではない
Section titled “RP ID はオリジンではない”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.id を example.com にすると、app.example.com でも www.example.com でも
同じパスキーが使える。 逆に login.example.com にすると、そのホストでしか使えない。
後から広げられないので最初に決める。
- オリジンは
httpsであること。ただしlocalhostはhttpでもよい - ポートは制限されない
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 を返す。
アテステーション
Section titled “アテステーション”「必ず検証しなければならない」は誤り。 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 など)へ押し戻される。
必要なのは、認証器の機種を制限したい企業内の用途だけ。
pubKeyCredParams と COSE の二層問題
Section titled “pubKeyCredParams と COSE の二層問題”ここは実際に混乱を招く。 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.
user.id に何を入れるか
Section titled “user.id に何を入れるか”メールアドレスやユーザー名を入れてはいけない。 規範文:
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 側は autocomplete に webauthn を足す。
<input type="text" name="username" autocomplete="username webauthn" />使ってよいケースと使ってはいけないケース
Section titled “使ってよいケースと使ってはいけないケース”成立の前提条件
Section titled “成立の前提条件”-
rp.idを決めてある(後から広げられない。サブドメイン構成を先に固める) - HTTPS で提供している(
localhostを除きhttpでは動かない) -
user.idにランダムなバイト列を使う(メールアドレスを入れない) -
challengeをサーバー側セッションに保存し、使い捨てにしている - 複数の認証器を登録できる(1 つ壊れても締め出されない)
- 回復手段がある(BE=0 の device-bound では特に必要)
- 公開鍵と
credentialRecordを保存する設計になっている
ひとつでも欠けたら止まる。 特に 1 つ目は、後から rp.id を変えると
登録済みのパスキーが全部使えなくなる。

試験前に見返すならこの 1 枚。 コーラルで示した rp.id は、
後から変えると全パスキーが無効になる唯一の項目。
使うかどうかの判定
Section titled “使うかどうかの判定”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
使ってよいケース
Section titled “使ってよいケース”| ケース | 成立する理由 | 一緒にやること |
|---|---|---|
| 一般向けサービスのパスワード置き換え | 同期パスキーなら端末をまたげる | パスワードは残す。TOTP を代替に置く |
| 管理画面・特権アカウント | フィッシングが最大の脅威で、それを構造的に止められる | device-bound(セキュリティキー)を必須に |
| 社内 SSO の OIDC の OP 側 | OP に 1 回入れれば全 RP に効く | OP でのみ実装すればよい |
| 既存の 2 要素の強化 | パスワード + パスキーで、片方が漏れても通らない | residentKey: discouraged でよい |
| AAL3 が要件の政府系 | 非エクスポートのハードウェアで満たせる | synced passkey は使えない。アテステーションで機種を確認 |
使ってはいけないケース
Section titled “使ってはいけないケース”| ケース | できないこと |
|---|---|
| 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.io、passkeys.dev |
登録(クライアント)
Section titled “登録(クライアント)”// サーバーから 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 を渡す。 渡さないと、同じ認証器で二重に登録できてしまい、
どちらが有効か分からなくなる。
認証の検証(サーバー)
Section titled “認証の検証(サーバー)”ライブラリを使うのが正解だが、何を確かめているかは押さえておく。 下は §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 つ。
originの比較は完全一致。前方一致にするとhttps://example.com.evil.testが通るrpIdHashは RP ID のハッシュ。オリジンのハッシュではないBE !== record.backupEligibleは失敗(BE は変わらないと仕様が定めている)。BSは記録するだけでポリシー判断に使うsignCountは両方 0 なら何もしない。0 固定を弾かない
challenge の管理
Section titled “challenge の管理”// 発行: サーバー側セッションに保存する。クライアントに状態を持たせない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 に対する総当たりを防ぐ。
運用で押さえる点
Section titled “運用で押さえる点”複数登録と回復
Section titled “複数登録と回復”認証器を 1 つしか登録させないのが最大の運用リスク。
| 手段 | 注意 |
|---|---|
| 複数のパスキーを登録させる(別端末・セキュリティキー) | 最も素直。登録画面への導線を用意する |
| リカバリコード | TOTP と同じ。ハッシュして保存し、1 回で消す |
| パスワードを残す | フィッシング耐性は落ちるが、締め出しよりはよい |
| 本人確認を経た手動リセット | ここが最も攻撃される。 強度が全体の上限になる |
BE=0(device-bound)のときは特に重要。 仕様も 「追加の認証器の登録や回復手段の用意を RP は SHOULD」としている。
パスワードをいつ消すか
Section titled “パスワードをいつ消すか”すぐには消さない。 BS の変化を見て段階的に進める。
| 状態 | 対応 |
|---|---|
| パスキー登録直後(BE=1, BS=0) | まだ同期されていない。パスワードは残す |
| BS が 0 → 1 になった | 同期された合図。パスワード削除を促してよい(MAY) |
| BS が 1 → 0 になった | 他の認証手段の確認を促す(SHOULD) |
| BE=0(device-bound のみ) | パスワードを消さない。 端末を失うと復旧できない |
ログに残してはいけない値
Section titled “ログに残してはいけない値”| 値 | 扱い |
|---|---|
challenge |
使用中は残さない。使い終われば無害 |
clientDataJSON / authenticatorData |
残してよい。 秘密を含まない |
signature |
残してよい。使い回せない |
user.id(user handle) |
利用者 ID と同じ扱い |
| 公開鍵 | 残してよい。公開鍵なので |
WebAuthn は「ログに残してはいけない値」がほとんど無い。 やりとりされるものに秘密が含まれないからで、これは Basic 認証や TOTP との決定的な違い。
ブラウザと端末の前提
Section titled “ブラウザと端末の前提”- HTTPS が必須。 例外は
localhostのhttpのみ - iframe から使うには
publickey-credentials-get/publickey-credentials-createの Permissions Policy が要る crossOriginとtopOriginを検証して、意図しない埋め込みを弾く
引っかかりやすいポイント
Section titled “引っかかりやすいポイント”- パスキー = discoverable credential。生体認証のことではない
- フィッシング耐性の源はオリジン束縛。生体認証でも「鍵が出せない」ことでもない
- NIST の定義は verifier name binding — 検証者のドメイン名で秘密を選ぶこと
- 署名対象は
authenticatorData || SHA-256(clientDataJSON) -
originは ブラウザがclientDataJSONに入れる。スクリプトから詐称できない -
rp.idはホスト名のみ。スキームもポートも入らない -
rp.idは実効ドメインか、その登録可能な接尾辞。兄弟ドメインは不可 -
rp.idは後から変えられない。変えると登録済みパスキーが全部無効になる -
authenticatorDataは AT も 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
-
UPとUVは別。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-safetynetは Level 3 で deprecated -
user.idに個人を識別できる情報を入れない(MUST NOT)。64 バイト以内 -
challengeは 16 バイト以上(SHOULD)。100 bit は credential ID のほうの話 - credential ID は 16 バイト以上・100 bit 以上のエントロピー・1023 バイト以下
-
algは-7/-8/-257を使う。IANA が Deprecated でも WebAuthn では現役 - パスワードレスと 2 要素目は
residentKeyとuserVerificationの設定が違う - HTTPS 必須。例外は
localhostのhttpのみ
パスキーとは何か。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.com。
m.login.example.com と com は無効。
BE と BS の 4 通りの組み合わせのうち、ありえないものはどれか。
BE=0 かつ BS=1。 バックアップ対象でないのにバックアップされている、
という状態は成立しない。検証ステップで明示的に弾く(登録時・認証時とも)。
BE=0, BS=0— single-device credentialBE=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 のエントロピーが要るのはどれか。
challenge— 16 バイト以上(SHOULD)(§13.4.3)- credential ID — 16 バイト以上かつ 100 bit 以上のエントロピー、1023 バイト以下(§4)
この 2 つは混同されやすい。 100 bit のほうは認証器が生成する credential ID の話で、 RP が作る challenge の規定ではない。
`pubKeyCredParams` に指定する `alg` は何を選ぶべきか。
-8(EdDSA)、-7(ES256)、-257(RS256) を並べる。
IANA の COSE レジストリでは RFC 9864 により -7 / -8 が Deprecated と
マークされているが、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 の違いは。
- WebAuthn — RP のサーバー ↔ ブラウザ。W3C の JavaScript API
- CTAP — ブラウザ / OS ↔ 認証器。FIDO Alliance のプロトコル(現行 CTAP 2.3)
RP は CTAP を知らなくてよい。認証器が USB のセキュリティキーでも端末内蔵でも、 スマホを使う hybrid transport でも、RP から見える API は同じ。
WebAuthn でログに残してはいけない値はどれか。
ほとんど無い。 clientDataJSON・authenticatorData・signature・公開鍵は
すべて残してよい(秘密を含まず、使い回せない)。
扱いに注意するのは **user.id(user handle)**で、これは利用者 ID と同じ扱いにする。
- Web Authentication: An API for accessing Public Key Credentials — Level 3 (W3C Recommendation)
- FIDO Alliance Specifications (CTAP)
- passkeys.dev — Terms
- FIDO Alliance — Passkeys
- NIST SP 800-63B-4 — Digital Identity Guidelines: Authentication and Authenticator Management
- IANA COSE Algorithms Registry
- RFC 9864 — Fully-Specified Algorithms for JOSE and COSE
- RFC 8809 — Registries for Web Authentication (WebAuthn)
- webauthn.io — 動作確認用のデモ