WebAuthn & Passkeys 実践ガイド:本番環境向けパスワードレス認証の構築 2026
パスワードがついに終焉を迎える理由
あるユーザーが "Summer2024!" を47番目のサービスに入力する。データベースには $2b$10$... が保存されている——bcryptでハッシュ化され、ソルト付きで、安全だ。そこにフィッシングメールが届き、ユーザーが偽ドメインにそのパスワードを入力し、攻撃者がユーザーとして認証されてしまう。どんなに強力なハッシュでも、認証情報の使い回しとフィッシングからは守れない。
WebAuthnはこの問題をプロトコルレベルで解決する。共有シークレットの代わりに、ブラウザが公開鍵と秘密鍵のペアを生成する。サーバーは公開鍵のみを保存する。秘密鍵はユーザーのデバイスから決して離れない。認証は暗号学的証明となる:ユーザーはサーバーからのチャレンジに署名することで秘密鍵を制御していることを証明する。
2026年、すべての主要プラットフォームがWebAuthnをサポートしている。Passkeys(AppleとGoogleの消費者向けブランディング)により、UXはシームレスになった。このガイドでは、暗号学的な基盤から本番デプロイメントまでの完全な実装をカバーする。
WebAuthnの実際の動作
二つのセレモニー
WebAuthnには正確に二つの操作がある:
| セレモニー | 目的 | 何が起こるか |
|---|---|---|
| 登録(navigator.credentials.create) | 新しい認証情報を作成 | ブラウザが鍵ペアを生成 → 公開鍵をサーバーに送信 |
| 認証(navigator.credentials.get) | 身元を証明 | サーバーがチャレンジを送信 → ブラウザが秘密鍵で署名 → サーバーが検証 |
暗号学的フロー
登録:
クライアント サーバー
| |
|--- GET /register/options ---->|
| |--- チャレンジを生成(ランダム32バイト)
|<-- challenge + ユーザー情報 ---|
| |
ブラウザが生成: |
- ECDSA P-256 鍵ペア |
- 認証情報ID |
- アテステーション署名 |
| |
|--- publicKey + signature ---->|
| |--- アテステーションを検証
| |--- publicKey + credentialID を保存
|<-- 200 OK --------------------|
認証:
クライアント サーバー
| |
|--- GET /login/options ------->|
| |--- チャレンジ + 許可されたcredential IDのリストを生成
|<-- challenge + allowList -----|
| |
ブラウザ: |
- ユーザー検証(生体認証/PIN) |
- 秘密鍵でチャレンジに署名 |
| |
|--- signature + credentialID ->|
| |--- 保存された公開鍵を取得
| |--- チャレンジに対する署名を検証
| |--- signCountを更新
|<-- session token -------------|
重要な洞察:秘密鍵は認証器から決して離れない。登録時も認証時も。サーバーは決してそれを見ることがない。データベースが侵害されても、攻撃者は秘密鍵を持っていないためユーザーになりすますことはできない。
サーバーサイド実装(Node.js)
依存関係
npm install @simplewebauthn/server @simplewebauthn/browser
登録:オプションの生成
// server/webauthn.ts
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";
import type {
AuthenticatorTransportFuture,
CredentialDeviceType,
} from "@simplewebauthn/server";
// 設定:デプロイメント環境に必ず合わせること
const RP_NAME = "MyApp";
const RP_ID = "myapp.com"; // ドメイン — プロトコルなし、ポートなし
const RP_ORIGIN = "https://myapp.com";
// POST /api/webauthn/register/options
export async function generateRegisterOptions(userId: string, userName: string) {
// 既存の認証情報を取得し、重複登録を防止
const existingCredentials = await db.credentials.findMany({
where: { userId },
select: { credentialId: true },
});
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userName,
userDisplayName: userName,
// アテステーション:認証器に対する保証レベルを決定
attestationType: "none", // "none" | "indirect" | "direct"
excludeCredentials: existingCredentials.map((cred) => ({
id: cred.credentialId,
type: "public-key" as const,
})),
authenticatorSelection: {
// residentKey: "required" でPasskeys(発見可能な認証情報)を有効化
residentKey: "required",
// ユーザー検証:"required" は生体認証またはPINを要求
userVerification: "preferred",
},
// サポートされるアルゴリズム:ES256 と RS256 は普遍的にサポート
supportedAlgorithmIDs: [-7, -257], // ES256, RS256
});
// チャレンジを一時保存 — 検証時に必要
await db.challenges.create({
data: {
userId,
challenge: options.challenge,
expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5分で期限切れ
},
});
return options;
}
residentKey: "required"の決定ポイント:これによりPasskeysが有効になる — 認証情報が「発見可能」になり、サーバーが事前にcredential IDを提供しなくてもブラウザが利用可能な認証情報を一覧表示できる。これがない場合、認証時にallowCredentialsを送信する必要があり、ユーザーが最初にユーザー名を入力する必要がある(従来の二段階ログイン)。
登録:レスポンスの検証
// POST /api/webauthn/register/verify
export async function verifyRegistration(
userId: string,
response: AuthenticatorAttestationResponse
) {
// オプション生成時に保存したチャレンジを取得
const storedChallenge = await db.challenges.findFirst({
where: { userId, expiresAt: { gt: new Date() } },
orderBy: { createdAt: "desc" },
});
if (!storedChallenge) {
throw new Error("チャレンジの期限が切れているか、見つかりません。登録を再開してください。");
}
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: storedChallenge.challenge,
expectedOrigin: RP_ORIGIN,
expectedRPID: RP_ID,
requireUserVerification: false,
});
const { verified, registrationInfo } = verification;
if (!verified || !registrationInfo) {
throw new Error("登録検証に失敗しました");
}
const {
credentialID,
credentialPublicKey,
counter,
credentialDeviceType,
credentialBackedUp,
} = registrationInfo;
// 認証情報を保存
await db.credentials.create({
data: {
userId,
credentialId: Buffer.from(credentialID).toString("base64url"),
publicKey: Buffer.from(credentialPublicKey).toString("base64url"),
counter,
deviceType: credentialDeviceType as CredentialDeviceType,
backedUp: credentialBackedUp,
transports: response.response.getTransports()?.join(",") || "",
},
});
// 使用済みチャレンジを削除
await db.challenges.delete({ where: { id: storedChallenge.id } });
return { verified: true };
}
認証:オプションの生成
// POST /api/webauthn/login/options
export async function generateAuthOptions(userId?: string) {
let allowCredentials;
if (userId) {
// 条件付きUI / オートフィル:ユーザーが特定されている
const credentials = await db.credentials.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
allowCredentials = credentials.map((cred) => ({
id: cred.credentialId,
type: "public-key" as const,
transports: cred.transports?.split(",") as AuthenticatorTransport[],
}));
}
// userIdがない場合、発見可能な認証情報フロー(Passkeys)
// ブラウザがこのRPで利用可能な認証情報のリストを表示する
const options = await generateAuthenticationOptions({
rpID: RP_ID,
allowCredentials,
userVerification: "preferred",
});
// チャレンジを保存
await db.challenges.create({
data: {
challenge: options.challenge,
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
},
});
return options;
}
認証:レスポンスの検証
// POST /api/webauthn/login/verify
export async function verifyAuthentication(
response: AuthenticatorAssertionResponse
) {
const credentialId = response.id;
// 保存された認証情報を取得
const credential = await db.credentials.findFirst({
where: { credentialId },
});
if (!credential) {
throw new Error("認証情報が見つかりません");
}
// 最新のチャレンジを取得
const storedChallenge = await db.challenges.findFirst({
where: { expiresAt: { gt: new Date() } },
orderBy: { createdAt: "desc" },
});
if (!storedChallenge) {
throw new Error("チャレンジの期限が切れています。再試行してください。");
}
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: storedChallenge.challenge,
expectedOrigin: RP_ORIGIN,
expectedRPID: RP_ID,
authenticator: {
credentialID: credential.credentialId,
credentialPublicKey: new Uint8Array(
Buffer.from(credential.publicKey, "base64url")
),
counter: credential.counter,
transports: credential.transports?.split(",") as AuthenticatorTransportFuture[],
},
requireUserVerification: false,
});
const { verified, authenticationInfo } = verification;
if (!verified) {
throw new Error("認証検証に失敗しました");
}
// カウンターを更新(リプレイ攻撃を防止)
await db.credentials.update({
where: { id: credential.id },
data: {
counter: authenticationInfo.newCounter,
lastUsedAt: new Date(),
},
});
// チャレンジを削除
await db.challenges.delete({ where: { id: storedChallenge.id } });
return { verified: true, userId: credential.userId };
}
クライアントサイド実装
// lib/webauthn.ts
import {
startRegistration,
startAuthentication,
} from "@simplewebauthn/browser";
export async function registerPasskey(userId: string, userName: string) {
// ステップ1:サーバーから登録オプションを取得
const optionsResp = await fetch("/api/webauthn/register/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, userName }),
});
const options = await optionsResp.json();
// ステップ2:ブラウザで認証情報を作成
let attResp: Awaited<ReturnType<typeof startRegistration>>;
try {
attResp = await startRegistration(options);
} catch (error: any) {
if (error.name === "InvalidStateError") {
// ユーザーが操作をキャンセル — 正常
return { cancelled: true };
}
throw error;
}
// ステップ3:サーバーにアテステーションを送信して検証
const verifyResp = await fetch("/api/webauthn/register/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, response: attResp }),
});
const { verified } = await verifyResp.json();
return { verified };
}
export async function authenticateWithPasskey() {
// ステップ1:認証オプションを取得(userIdなし — 発見可能な認証情報)
const optionsResp = await fetch("/api/webauthn/login/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const options = await optionsResp.json();
// ステップ2:認証器からアサーションを取得
let authResp: Awaited<ReturnType<typeof startAuthentication>>;
try {
authResp = await startAuthentication(options, true); // true = ブラウザのオートフィルを使用
} catch (error: any) {
if (error.name === "NotAllowedError") {
return { cancelled: true };
}
throw error;
}
// ステップ3:サーバーで検証
const verifyResp = await fetch("/api/webauthn/login/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ response: authResp }),
});
const result = await verifyResp.json();
return result;
}
Reactコンポーネント
// components/PasskeyButton.tsx
"use client";
import { startAuthentication } from "@simplewebauthn/browser";
import { useState } from "react";
export function PasskeyLoginButton() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handlePasskeyLogin = async () => {
setLoading(true);
setError(null);
try {
const autoLoginResp = await startAuthentication({ challenge: "..." }, true);
if (!autoLoginResp) return; // ユーザーがセレクターを閉じた
const verifyResp = await fetch("/api/webauthn/login/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ response: autoLoginResp }),
});
if (verifyResp.ok) {
window.location.href = "/dashboard";
}
} catch (err: any) {
setError(err.message || "認証に失敗しました");
} finally {
setLoading(false);
}
};
return (
<div>
<button
onClick={handlePasskeyLogin}
disabled={loading}
className="passkey-btn"
>
<svg width="20" height="20" viewBox="0 0 24 24">
<path d="M12 2C9.243 2 7 4.243 7 7v3H6c-1.103 0-2 .897-2 2v8c0 1.103.897 2 2 2h12c1.103 0 2-.897 2-2v-8c0-1.103-.897-2-2-2h-1V7c0-2.757-2.243-5-5-5z"/>
</svg>
{loading ? "検証中..." : "Passkeyでログイン"}
</button>
{error && <p className="error">{error}</p>}
</div>
);
}
アテステーション:認証器の真正性検証
アテステーションは、鍵ペアが本物の認証器で生成されたことを証明する。3つのレベルがある:
| アテステーションタイプ | 証明する内容 | プライバシーへの影響 | 使用すべき場合 |
|---|---|---|---|
none |
認証器について何も提供しない | 最高のプライバシー | ほとんどのコンシューマーアプリ |
indirect |
認証器は本物だが匿名化 | 良好なプライバシー | ポリシー要件のあるエンタープライズ |
direct |
正確な認証器のモデルと製造元 | 低プライバシー | 高セキュリティの政府/軍事 |
90%のアプリケーションでは、noneで十分。検証しているのはユーザーが秘密鍵を制御していることであり、ハードウェアの認証ではない。
// 厳格なアテステーションが必要な場合
const options = await generateRegistrationOptions({
// ...
attestationType: "direct",
attestationFormats: ["packed", "tpm", "android-safetynet"],
});
プラットフォーム認証器 vs クロスプラットフォーム認証器
| タイプ | 例 | 長所 | 短所 |
|---|---|---|---|
| プラットフォーム | Touch ID、Face ID、Windows Hello、Android生体認証 | シームレスなUX、追加デバイス不要 | 1台のデバイスに紐付き、デバイス消去で認証情報を失う |
| クロスプラットフォーム | YubiKey、Titanセキュリティキー、スマートフォン as Passkey | 携帯可能、バックアップ可能 | 追加ハードウェアが必要、紛失の可能性 |
Passkeysが可搬性問題を解決
PasskeysはWebAuthnを拡張し、プラットフォームアカウント(iCloudキーチェーン、Googleパスワードマネージャー)を通じてデバイス間で認証情報を同期する。これにより消費者向けに実用的になる:
従来のWebAuthn:
- 秘密鍵は1台のデバイスに留まる
- デバイスを失う = アカウントアクセスを失う
Passkeys(同期):
- 秘密鍵はクラウドでエンドツーエンド暗号化
- 同じプラットフォームアカウントにログインしたすべてのデバイスで利用可能
- バックアップとリカバリはOSに組み込み済み
保存されたcredentialBackedUpフラグは、認証情報が同期されるかどうかを示す。これはリカバリフローにとって重要:同期された認証情報はデバイス紛失に耐えられる。
セキュリティ考慮事項
リプレイ攻撃防止
チャレンジ+署名カウンターパターンがリプレイ攻撃を防止する:
// 検証時にカウンターをチェック
if (authenticationInfo.newCounter <= credential.counter) {
throw new Error("リプレイ攻撃の可能性を検出");
}
各認証でカウンターが増加する。署名がリプレイされるとカウンターが進んでおらず、検証が失敗する。
フィッシング耐性
WebAuthnのフィッシング耐性はオリジンバインディングに由来する。ブラウザは認証情報作成時にrpIdを含める。認証時に、リクエスト元のオリジンが一致することを検証する。goog1e.comのフィッシングサイトはgoogle.com用に作成された認証情報を使用できない。
// サーバーがオリジンチェックを強制
const verification = await verifyAuthenticationResponse({
// ...
expectedOrigin: RP_ORIGIN, // "https://myapp.com"
expectedRPID: RP_ID, // "myapp.com"
});
ユーザー検証レベル
| レベル | 説明 | UX |
|---|---|---|
discouraged |
ユーザージェスチャー不要 | 即時、安全性低 |
preferred |
利用可能なら要求、なければスキップ | 最良のバランス |
required |
検証必須(生体認証/PIN) | 最も安全、一部デバイスで失敗の可能性 |
// 機密操作(支払い、管理者)には検証を要求
const options = await generateAuthenticationOptions({
userVerification: "required",
});
// 一般ログインには preferred が適切なデフォルト
const options = await generateAuthenticationOptions({
userVerification: "preferred",
});
条件付きUI(オートフィル)パターン
最高のPasskeys UXは「Passkeyでログイン」ボタンをまったく必要としない。条件付きUI(旧autofill)は、ユーザーがユーザー名フィールドにフォーカスしたときにpasskeyセレクターを表示する:
// ページ読み込み時に条件付きメディエーションを開始
useEffect(() => {
const abortController = new AbortController();
const options = await fetch("/api/webauthn/login/options", {
method: "POST",
body: JSON.stringify({}),
}).then((r) => r.json());
// これはプロンプトを表示しない — 入力フィールドとのユーザー操作を待つ
startAuthentication(options, true) // true = useBrowserAutofill
.then((result) => {
// ユーザーがpasskeyを選択した
verifyAndLogin(result);
})
.catch((err) => {
if (err.name !== "AbortError") {
console.error("条件付きUIエラー:", err);
}
});
return () => abortController.abort();
}, []);
ユーザーがユーザー名入力をクリックすると、ブラウザは利用可能なpasskeysをオートフィルの候補として表示する。ワンタップ、顔認証、ログイン完了。これこそがPasskeysをパスワードより真に優れたものにする「魔法の」UXである。
リカバリフロー:すべてのデバイスを失ったら?
WebAuthnの強みは同時に弱みでもある:秘密鍵はデバイスに紐付いている。リカバリには事前計画が必要:
戦略1:複数認証情報
ユーザーに複数の認証情報の登録を促す:
// UI:「別のpasskeyを追加」
// 各認証情報は独立して使用可能
// プラットフォームpasskey(iCloud/Google同期)+ クロスプラットフォームキー(YubiKey)を登録
戦略2:リカバリコード
// 登録時に10個のワンタイムリカバリコードを生成
const recoveryCodes = Array.from({ length: 10 }, () =>
randomBytes(8).toString("hex")
);
const hashedCodes = await Promise.all(
recoveryCodes.map((code) => bcrypt.hash(code, 12))
);
await db.recoveryCodes.createMany({
data: hashedCodes.map((hash) => ({ userId, hash })),
});
// 一度だけ表示、ユーザーが保存
return { recoveryCodes };
戦略3:確認済みメール/電話へのフォールバック
// 利用可能なpasskeyがない場合:
// 1. 確認済みメールにマジックリンクを送信
// 2. クリック時に現在のデバイス用に新しいpasskeyを作成
// 3. すべての古い認証情報を失効(オプション、セキュリティのため)
本番環境チェックリスト
| # | 項目 | 理由 |
|---|---|---|
| 1 | RP_IDをドメインに一致させる |
不一致 = すべての操作が失敗 |
| 2 | HTTPSを全サイトで(開発時のlocalhost例外あり) | WebAuthnはセキュアコンテキスト必須 |
| 3 | チャレンジTTL ≤ 5分 | チャレンジの再利用を防止 |
| 4 | signCountを追跡・検証 |
リプレイ攻撃を防止 |
| 5 | @simplewebauthnでパース |
CBOR/ASN.1パースはエラーが発生しやすい |
| 6 | publicKeyをbase64urlで保存(hex不可) | SimpleWebAuthnがこの形式を期待 |
| 7 | residentKey: "required"でPasskeysを有効化 |
発見可能な認証情報に必須 |
| 8 | InvalidStateErrorを適切に処理 |
ユーザーキャンセル — エラーではない |
| 9 | 実際のハードウェアでテスト | エミュレーターにはプラットフォーム認証器がない |
| 10 | 移行中はパスワードログインのフォールバックを維持 | 「既存ユーザーを壊さない」 |
| 11 | アテステーション失敗を監視用にログ記録 | 攻撃試行を検出 |
| 12 | チャレンジエンドポイントにレート制限を実装 | DoSを防止 |
一般的なエラーと解決策
| エラー | 原因 | 解決策 |
|---|---|---|
NotAllowedError: The operation timed out |
ユーザーがキャンセル、またはプラットフォーム認証器が利用不可 | 「再試行」ボタンを表示;PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()を確認 |
SecurityError: The operation is insecure |
HTTPS未使用、またはlocalhostの設定不備 | HTTPS証明書を検証;開発環境でlocalhost例外を追加 |
UnknownError: An unknown error occurred |
オプションと検証間でRP IDが不一致 | generate呼び出しとverify呼び出しでrpIDが完全に同一であることを確認 |
InvalidStateError: The authenticator was previously registered |
認証情報の重複登録 | 登録オプションにexcludeCredentialsを含める |
ConstraintError |
既存の認証情報を登録しようとしている | キャッチして適切に処理 — 既存のpasskeyの使用を提案 |
| ブラウザがpasskeyオートフィルを表示しない | allowCredentialsが空または誤り、条件付きメディエーション未サポート |
発見可能フローには{}(空のオプション、allowCredentialsなし)を使用 |
データベーススキーマ
CREATE TABLE credentials (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
credential_id TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
device_type TEXT NOT NULL, -- 'singleDevice' | 'multiDevice'
backed_up BOOLEAN NOT NULL DEFAULT false,
transports TEXT, -- 'usb,ble,nfc,internal'
created_at TIMESTAMP DEFAULT NOW(),
last_used_at TIMESTAMP,
name TEXT -- ユーザー命名:「自分のiPhone」/「YubiKey 5」
);
CREATE TABLE challenges (
id TEXT PRIMARY KEY,
user_id TEXT,
challenge TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_credentials_user_id ON credentials(user_id);
CREATE INDEX idx_credentials_credential_id ON credentials(credential_id);
CREATE INDEX idx_challenges_expires_at ON challenges(expires_at);
まとめ:WebAuthnとPasskeysはプロトコルレベルでフィッシングとクレデンシャルスタッフィングを排除する。コアパターンはシンプルだ:ブラウザが鍵を生成し、サーバーは公開鍵のみを保存し、認証は暗号学的な署名操作である。複雑さは詳細にある — アテステーション検証、カウンターチェック、チャレンジ管理、リカバリフロー。新規プロジェクトにはPasskeys-only認証とリカバリコードを推奨する。既存プロジェクトにはパスワードと並行してPasskeysを追加し、徐々に採用率を高める。セキュリティの向上は質的なものだ:認証情報の使い回しとフィッシングが、ポリシーではなく設計上不可能になる。
オンラインツール
- ハッシュ計算機 — WebAuthnフローのデバッグ用にチャレンジハッシュを生成・検証
- Base64エンコード/デコード — base64urlエンコードされたcredential IDと公開鍵を処理
- JSONフォーマッター — WebAuthnオプションとレスポンスペイロードのフォーマットと検査
ブラウザローカルツールを無料で試す →