WebAuthn FIDO2実装実践:登録からパスワードレス認証まで6つのプロダクションパターン
パスワードは終わった、Passkeyが未来だ
2026年、パスワード漏洩事件は毎月数億件のペースで増え続けている。フィッシング攻撃は従来のMFAを回避し、SMS OTPはSIMスワップ攻撃で傍受され、TOTPトークンユーザーは「MFA疲労攻撃」で盲目的に承認ボタンを押す。Passkey(パスキー) はFIDO2/WebAuthn標準に基づき、公開鍵暗号+デバイス生体認証でパスワードを完全に排除——Apple、Google、Microsoftの3大エコシステムが完全に相互運用可能、ブラウザカバレッジは96%を超える。
本記事では6つのプロダクションレベルパターンを紹介する:Passkey登録から認証フロー、クレデンシャル管理からマルチデバイスサポート、フォールバック戦略から完全なPython+JavaScriptプロダクションサービスまで、すべてのコードはそのまま実行可能。
WebAuthn/FIDO2コア概念
| 概念 | 説明 |
|---|---|
| WebAuthn | W3C標準、ブラウザが提供するnavigator.credentials API、登録と認証のJavaScriptインターフェースを定義 |
| FIDO2 | FIDO Alliance標準、CTAP2プロトコルを含み、オーセンティケータとサーバーの通信を規定 |
| Passkey | クロスデバイス同期可能なFIDO2クレデンシャル、iCloud Keychain/Google Password Managerに保存 |
| Authenticator | 認証デバイス、プラットフォーム認証器(TouchID/FaceID/Windows Hello)とローミング認証器(YubiKey)に分類 |
| Relying Party (RP) | リライングパーティ、あなたのウェブサイト/サーバー、rpIDで識別 |
| Credential | クレデンシャル、公開鍵暗号の鍵ペア、秘密鍵はオーセンティケータのセキュアチップから絶対に出ない |
| Attestation | アテステーション、登録時にオーセンティケータがRPに正当性を証明する方法(noneも選択可能) |
| Assertion | アサーション、認証時にオーセンティケータが秘密鍵でchallengeに署名して返す署名証明 |
問題分析:WebAuthnプロダクション導入の5つの課題
- 登録フローの複雑さ:サーバーは正しい
PublicKeyCredentialCreationOptionsを生成する必要がある。COSEアルゴリズム識別子、authenticatorSelectionパラメータの設定ミスが頻発 - 認証フローのセキュリティ:challengeは真の乱数で使い捨て必須、署名検証にはCBOR/COSEエンコーディング処理が必要、一つでもチェックを漏らすとセキュリティホールに
- クレデンシャルライフサイクル管理:ユーザーは複数のPasskeyを登録する可能性、一覧表示・削除・リネームのサポート、全デバイス紛失時のリカバリ機構が必要
- クロスプラットフォームとマルチデバイス:iCloud PasskeyとGoogle Passkeyは相互運用不可、エンタープライズシナリオではセキュリティキー(YubiKey)対応が必要、Conditional UI統合が複雑
- フォールバックとダウングレード:レガシーデバイスはWebAuthn非対応、パスワードフォールバックは必須、ハイブリッド認証フローのUX設計が困難
6つのプロダクションパターン実践
Pattern 1:Passkey登録フロー
サーバー(Python + py_webauthn):
from py_webauthn import WebAuthnRP, WebAuthnUser, WebAuthnCredential
from py_webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
UserVerificationRequirement,
AuthenticatorAttachment,
ResidentKeyRequirement,
PublicKeyCredentialDescriptor,
AttestationConveyancePreference,
)
import secrets
import base64
class PasskeyRegistrationService:
def __init__(self, rp_id: str, rp_name: str, origin: str):
self.rp_id = rp_id
self.rp_name = rp_name
self.origin = origin
def generate_registration_options(
self,
user_id: str,
username: str,
display_name: str,
exclude_credentials: list[dict] | None = None,
) -> dict:
challenge = secrets.token_bytes(32)
challenge_b64 = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode()
exclude_list = []
if exclude_credentials:
for cred in exclude_credentials:
exclude_list.append(
PublicKeyCredentialDescriptor(
id=base64.urlsafe_b64decode(cred["id"] + "=="),
type="public-key",
)
)
options = {
"challenge": challenge_b64,
"rp": {"id": self.rp_id, "name": self.rp_name},
"user": {
"id": base64.urlsafe_b64encode(user_id.encode()).rstrip(b"=").decode(),
"name": username,
"displayName": display_name,
},
"pubKeyCredParams": [
{"type": "public-key", "alg": -7},
{"type": "public-key", "alg": -257},
],
"timeout": 60000,
"excludeCredentials": [
{
"type": ec.type,
"id": base64.urlsafe_b64encode(ec.id).rstrip(b"=").decode(),
}
for ec in exclude_list
],
"authenticatorSelection": {
"authenticatorAttachment": "platform",
"requireResidentKey": True,
"residentKey": "required",
"userVerification": "required",
},
"attestation": "none",
}
return options, challenge_b64
def verify_registration(
self,
challenge_b64: str,
credential_response: dict,
expected_origin: str,
) -> dict:
from py_webauthn import verify_registration_response
verification = verify_registration_response(
credential=credential_response,
expected_challenge=base64.urlsafe_b64encode(
base64.urlsafe_b64decode(challenge_b64 + "==")
).rstrip(b"=").decode(),
expected_origin=expected_origin,
expected_rp_id=self.rp_id,
)
return {
"credential_id": base64.urlsafe_b64encode(
verification.credential_id
).rstrip(b"=").decode(),
"public_key": base64.urlsafe_b64encode(
verification.credential_public_key
).rstrip(b"=").decode(),
"sign_count": verification.sign_count,
}
クライアント(JavaScript):
async function registerPasskey(userId, username, displayName) {
const excludeCredentials = await getExistingCredentials(userId);
const optionsResponse = await fetch("/api/webauthn/registration/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId,
username,
displayName,
excludeCredentials,
}),
});
const { options, challenge } = await optionsResponse.json();
const publicKeyCredentialCreationOptions = {
...options,
challenge: base64urlDecode(options.challenge),
user: {
...options.user,
id: base64urlDecode(options.user.id),
},
excludeCredentials: options.excludeCredentials.map((ec) => ({
...ec,
id: base64urlDecode(ec.id),
})),
};
try {
const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions,
});
const verificationPayload = {
id: credential.id,
rawId: base64urlEncode(credential.rawId),
type: credential.type,
response: {
attestationObject: base64urlEncode(
credential.response.attestationObject
),
clientDataJSON: base64urlEncode(credential.response.clientDataJSON),
},
};
const verifyResponse = await fetch("/api/webauthn/registration/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
challenge,
credential: verificationPayload,
}),
});
const result = await verifyResponse.json();
if (result.success) {
showToast("Passkeyの登録に成功しました!");
}
} catch (error) {
if (error.name === "InvalidStateError") {
showToast("このデバイスには既にPasskeyが登録されています");
} else if (error.name === "NotAllowedError") {
showToast("登録操作がキャンセルされました");
} else {
showToast(`登録失敗:${error.message}`);
}
}
}
function base64urlDecode(str) {
const padding = "=".repeat((4 - (str.length % 4)) % 4);
const base64 = str.replace(/-/g, "+").replace(/_/g, "/") + padding;
const binaryStr = atob(base64);
return Uint8Array.from(binaryStr, (c) => c.charCodeAt(0));
}
function base64urlEncode(buffer) {
const bytes = new Uint8Array(buffer);
let binary = "";
bytes.forEach((b) => (binary += String.fromCharCode(b)));
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
Pattern 2:認証フロー(WebAuthn API)
サーバー:
class PasskeyAuthService:
def __init__(self, rp_id: str, origin: str):
self.rp_id = rp_id
self.origin = origin
def generate_authentication_options(
self,
allow_credentials: list[dict] | None = None,
) -> dict:
challenge = secrets.token_bytes(32)
challenge_b64 = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode()
allow_list = []
if allow_credentials:
for cred in allow_credentials:
allow_list.append(
PublicKeyCredentialDescriptor(
id=base64.urlsafe_b64decode(cred["id"] + "=="),
type="public-key",
)
)
options = {
"challenge": challenge_b64,
"rpId": self.rp_id,
"timeout": 60000,
"allowCredentials": [
{
"type": ac.type,
"id": base64.urlsafe_b64encode(ac.id).rstrip(b"=").decode(),
}
for ac in allow_list
],
"userVerification": "required",
}
return options, challenge_b64
def verify_authentication(
self,
challenge_b64: str,
credential_response: dict,
stored_credential: dict,
) -> bool:
from py_webauthn import verify_authentication_response
verification = verify_authentication_response(
credential=credential_response,
expected_challenge=base64.urlsafe_b64encode(
base64.urlsafe_b64decode(challenge_b64 + "==")
).rstrip(b"=").decode(),
expected_origin=self.origin,
expected_rp_id=self.rp_id,
credential_public_key=base64.urlsafe_b64decode(
stored_credential["public_key"] + "=="
),
credential_current_sign_count=stored_credential["sign_count"],
)
return verification.new_sign_count
クライアント:
async function authenticateWithPasskey() {
const optionsResponse = await fetch("/api/webauthn/authentication/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
const { options, challenge } = await optionsResponse.json();
const publicKeyCredentialRequestOptions = {
...options,
challenge: base64urlDecode(options.challenge),
allowCredentials: options.allowCredentials?.map((ac) => ({
...ac,
id: base64urlDecode(ac.id),
})),
};
try {
const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions,
});
const verificationPayload = {
id: assertion.id,
rawId: base64urlEncode(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: base64urlEncode(
assertion.response.authenticatorData
),
clientDataJSON: base64urlEncode(assertion.response.clientDataJSON),
signature: base64urlEncode(assertion.response.signature),
userHandle: assertion.response.userHandle
? base64urlEncode(assertion.response.userHandle)
: null,
},
};
const verifyResponse = await fetch(
"/api/webauthn/authentication/verify",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
challenge,
credential: verificationPayload,
}),
}
);
const result = await verifyResponse.json();
if (result.success) {
window.location.href = "/dashboard";
}
} catch (error) {
if (error.name === "NotAllowedError") {
showToast("認証がキャンセルされました");
} else {
showToast(`認証失敗:${error.message}`);
}
}
}
Conditional UI(オートフィル)モード:
async function setupConditionalUI() {
if (!window.PublicKeyCredential) return;
const available = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
if (!available) return;
const optionsResponse = await fetch(
"/api/webauthn/authentication/options",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ conditionalUI: true }),
}
);
const { options, challenge } = await optionsResponse.json();
const publicKeyCredentialRequestOptions = {
...options,
challenge: base64urlDecode(options.challenge),
mediation: "conditional",
};
try {
const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions,
});
// 認証結果の処理、上記と同じ
} catch (error) {
// Conditional UIモードでユーザーがPasskeyを選択しないのは正常な動作、サイレントに処理
}
}
// ページ読み込み時に自動セットアップ
document.addEventListener("DOMContentLoaded", () => {
const usernameInput = document.getElementById("username");
if (usernameInput) {
usernameInput.setAttribute("autocomplete", "username webauthn");
setupConditionalUI();
}
});
Pattern 3:クレデンシャル管理(一覧、削除、更新)
サーバー:
from dataclasses import dataclass
from datetime import datetime
@dataclass
class PasskeyCredential:
credential_id: str
user_id: str
public_key: str
sign_count: int
name: str
device_type: str
created_at: datetime
last_used_at: datetime | None
transports: list[str]
class CredentialManagementService:
def __init__(self, db_session):
self.db = db_session
def list_credentials(self, user_id: str) -> list[dict]:
credentials = self.db.query(PasskeyCredential).filter(
PasskeyCredential.user_id == user_id
).order_by(PasskeyCredential.created_at.desc()).all()
return [
{
"id": cred.credential_id,
"name": cred.name or f"Passkey #{idx + 1}",
"deviceType": cred.device_type,
"createdAt": cred.created_at.isoformat(),
"lastUsedAt": cred.last_used_at.isoformat() if cred.last_used_at else None,
"transports": cred.transports,
}
for idx, cred in enumerate(credentials)
]
def delete_credential(self, user_id: str, credential_id: str) -> bool:
cred = self.db.query(PasskeyCredential).filter(
PasskeyCredential.user_id == user_id,
PasskeyCredential.credential_id == credential_id,
).first()
if not cred:
return False
remaining_count = self.db.query(PasskeyCredential).filter(
PasskeyCredential.user_id == user_id
).count()
if remaining_count <= 1:
raise ValueError("最後のPasskeyは削除できません。先にバックアップクレデンシャルを登録してください")
self.db.delete(cred)
self.db.commit()
return True
def rename_credential(self, user_id: str, credential_id: str, new_name: str) -> bool:
cred = self.db.query(PasskeyCredential).filter(
PasskeyCredential.user_id == user_id,
PasskeyCredential.credential_id == credential_id,
).first()
if not cred:
return False
cred.name = new_name[:64]
self.db.commit()
return True
def update_sign_count(self, credential_id: str, new_sign_count: int) -> None:
cred = self.db.query(PasskeyCredential).filter(
PasskeyCredential.credential_id == credential_id
).first()
if cred:
cred.sign_count = new_sign_count
cred.last_used_at = datetime.utcnow()
self.db.commit()
クライアント:
class PasskeyManager {
constructor(userId) {
this.userId = userId;
}
async listCredentials() {
const response = await fetch(`/api/webauthn/credentials?userId=${this.userId}`);
return response.json();
}
async deleteCredential(credentialId) {
const confirmed = confirm("このPasskeyを削除しますか?元に戻せません。");
if (!confirmed) return false;
const response = await fetch("/api/webauthn/credentials/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId: this.userId,
credentialId,
}),
});
const result = await response.json();
if (result.success) {
this.renderCredentialList();
}
return result.success;
}
async renameCredential(credentialId, newName) {
const response = await fetch("/api/webauthn/credentials/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId: this.userId,
credentialId,
newName,
}),
});
return (await response.json()).success;
}
async renderCredentialList() {
const credentials = await this.listCredentials();
const container = document.getElementById("passkey-list");
container.innerHTML = credentials
.map(
(cred) => `
<div class="passkey-item" data-id="${cred.id}">
<div class="passkey-info">
<span class="passkey-name">${cred.name}</span>
<span class="passkey-device">${cred.deviceType}</span>
<span class="passkey-date">
作成日 ${new Date(cred.createdAt).toLocaleDateString()}
${cred.lastUsedAt ? `· 最終使用 ${new Date(cred.lastUsedAt).toLocaleDateString()}` : ""}
</span>
</div>
<div class="passkey-actions">
<button onclick="passkeyManager.renameCredential('${cred.id}', prompt('新しい名前:', '${cred.name}'))">
名前変更
</button>
<button onclick="passkeyManager.deleteCredential('${cred.id}')">
削除
</button>
</div>
</div>
`
)
.join("");
}
}
Pattern 4:マルチデバイス・クロスプラットフォームサポート
サーバー:
class MultiDevicePasskeyService:
SUPPORTED_ALGORITHMS = [-7, -257, -37]
def generate_inclusive_registration_options(
self,
user_id: str,
username: str,
display_name: str,
existing_credentials: list[dict],
) -> dict:
challenge = secrets.token_bytes(32)
challenge_b64 = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode()
exclude_list = [
{
"type": "public-key",
"id": cred["credential_id"],
}
for cred in existing_credentials
]
options = {
"challenge": challenge_b64,
"rp": {"id": self.rp_id, "name": self.rp_name},
"user": {
"id": base64.urlsafe_b64encode(user_id.encode()).rstrip(b"=").decode(),
"name": username,
"displayName": display_name,
},
"pubKeyCredParams": [
{"type": "public-key", "alg": alg}
for alg in self.SUPPORTED_ALGORITHMS
],
"timeout": 120000,
"excludeCredentials": exclude_list,
"authenticatorSelection": {
"authenticatorAttachment": "platform",
"requireResidentKey": True,
"residentKey": "required",
"userVerification": "preferred",
},
"attestation": "none",
}
return options, challenge_b64
def generate_security_key_options(
self,
user_id: str,
username: str,
display_name: str,
) -> dict:
challenge = secrets.token_bytes(32)
challenge_b64 = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode()
options = {
"challenge": challenge_b64,
"rp": {"id": self.rp_id, "name": self.rp_name},
"user": {
"id": base64.urlsafe_b64encode(user_id.encode()).rstrip(b"=").decode(),
"name": username,
"displayName": display_name,
},
"pubKeyCredParams": [
{"type": "public-key", "alg": -7},
{"type": "public-key", "alg": -257},
],
"timeout": 120000,
"authenticatorSelection": {
"authenticatorAttachment": "cross-platform",
"requireResidentKey": False,
"residentKey": "discouraged",
"userVerification": "preferred",
},
"attestation": "direct",
}
return options, challenge_b64
クライアント:
async function registerPlatformPasskey(userId, username, displayName) {
const existingCreds = await getExistingCredentials(userId);
const { options, challenge } = await fetchRegistrationOptions({
userId,
username,
displayName,
existingCredentials: existingCreds,
type: "platform",
});
return executeRegistration(options, challenge);
}
async function registerSecurityKey(userId, username, displayName) {
const { options, challenge } = await fetchRegistrationOptions({
userId,
username,
displayName,
type: "cross-platform",
});
return executeRegistration(options, challenge);
}
async function detectPasskeySupport() {
if (!window.PublicKeyCredential) {
return {
supported: false,
platformAuthenticator: false,
conditionalUI: false,
};
}
const platformAuth = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
let conditionalUI = false;
if (platformAuth && PublicKeyCredential.isConditionalMediationAvailable) {
conditionalUI = await PublicKeyCredential.isConditionalMediationAvailable();
}
return {
supported: true,
platformAuthenticator: platformAuth,
conditionalUI,
};
}
async function renderRegistrationUI(userId, username, displayName) {
const support = await detectPasskeySupport();
const container = document.getElementById("passkey-registration");
if (support.platformAuthenticator) {
container.innerHTML += `
<button onclick="registerPlatformPasskey('${userId}', '${username}', '${displayName}')">
Passkeyを登録(デバイス生体認証)
</button>
`;
}
container.innerHTML += `
<button onclick="registerSecurityKey('${userId}', '${username}', '${displayName}')">
セキュリティキーを登録(YubiKey等)
</button>
`;
if (!support.supported) {
container.innerHTML = `
<p>お使いのブラウザはWebAuthnに対応していません。パスワードでログインしてください。</p>
`;
}
}
Pattern 5:フォールバック戦略(パスワード+Passkeyハイブリッド)
サーバー:
class HybridAuthService:
def __init__(self, db_session, rp_id: str, origin: str):
self.db = db_session
self.rp_id = rp_id
self.origin = origin
def get_login_options(self, user_id: str | None = None) -> dict:
if user_id:
credentials = self._get_user_credentials(user_id)
has_passkey = len(credentials) > 0
if has_passkey:
auth_options, challenge = PasskeyAuthService(
self.rp_id, self.origin
).generate_authentication_options(
allow_credentials=[
{"id": c.credential_id} for c in credentials
]
)
return {
"mode": "passkey_preferred",
"passkeyOptions": auth_options,
"challenge": challenge,
"passwordFallback": True,
}
return {
"mode": "password_only",
"passkeyOptions": None,
"challenge": None,
"passwordFallback": True,
}
def promote_to_passkey(self, user_id: str) -> dict:
user = self._get_user(user_id)
existing_creds = self._get_user_credentials(user_id)
reg_options, challenge = PasskeyRegistrationService(
self.rp_id, "MyApp", self.origin
).generate_registration_options(
user_id=user_id,
username=user.username,
display_name=user.display_name,
exclude_credentials=[
{"id": c.credential_id} for c in existing_creds
],
)
return {
"registrationOptions": reg_options,
"challenge": challenge,
"message": "アカウントをアップグレード:より安全なパスワードレスログインのためにPasskeyを登録しましょう",
}
クライアント:
class HybridLoginFlow {
constructor() {
this.step = "initial";
}
async init() {
const support = await detectPasskeySupport();
if (support.conditionalUI) {
this.setupAutofillPasskey();
}
this.renderLoginForm(support);
}
renderLoginForm(support) {
const form = document.getElementById("login-form");
form.innerHTML = `
<input type="text" id="username" name="username"
autocomplete="username webauthn"
placeholder="ユーザー名" required />
<div id="passkey-section" style="display:none">
<button type="button" id="passkey-login-btn">
Passkeyでログイン
</button>
<p class="divider-text">またはパスワードを使用</p>
</div>
<div id="password-section">
<input type="password" id="password" name="password"
autocomplete="current-password"
placeholder="パスワード" />
<button type="submit">ログイン</button>
</div>
`;
document.getElementById("username").addEventListener("blur", async () => {
const username = document.getElementById("username").value;
if (!username) return;
const check = await fetch("/api/auth/check", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});
const { hasPasskey } = await check.json();
if (hasPasskey && support.platformAuthenticator) {
document.getElementById("passkey-section").style.display = "block";
}
});
document.getElementById("passkey-login-btn").addEventListener(
"click",
() => this.authenticateWithPasskey()
);
form.addEventListener("submit", (e) => {
e.preventDefault();
this.authenticateWithPassword();
});
}
async setupAutofillPasskey() {
const { options, challenge } = await fetch(
"/api/webauthn/authentication/options",
{
method: "POST",
headers: { "Content-Type": "application/json" },
}
).then((r) => r.json());
try {
const assertion = await navigator.credentials.get({
publicKey: {
...options,
challenge: base64urlDecode(options.challenge),
mediation: "conditional",
},
});
await this.verifyAssertion(assertion, challenge);
} catch {
// ユーザーがPasskeyを選択しなかった、サイレントに処理
}
}
async authenticateWithPassword() {
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const result = await response.json();
if (result.success) {
if (result.suggestPasskeyUpgrade) {
this.showPasskeyUpgradePrompt(result.userId);
} else {
window.location.href = "/dashboard";
}
}
}
showPasskeyUpgradePrompt(userId) {
const upgrade = confirm(
"お使いのデバイスはPasskeyに対応しています。より安全なパスワードレスログインのために今すぐ登録しますか?"
);
if (upgrade) {
registerPlatformPasskey(userId);
} else {
window.location.href = "/dashboard";
}
}
}
Pattern 6:プロダクション認証サービス(Python + JavaScript)
サーバー(FastAPI + py_webauthn):
from fastapi import FastAPI, HTTPException, Depends, Request
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import secrets
import base64
from typing import Optional
app = FastAPI(title="WebAuthn Production Auth Service")
app.add_middleware(
CORSMiddleware,
allow_origins=["https://yourapp.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
challenge_store: dict[str, dict] = {}
session_store: dict[str, dict] = {}
class RegistrationOptionsRequest(BaseModel):
userId: str
username: str
displayName: str
excludeCredentials: Optional[list[dict]] = None
class RegistrationVerifyRequest(BaseModel):
challenge: str
credential: dict
class AuthenticationOptionsRequest(BaseModel):
userId: Optional[str] = None
conditionalUI: Optional[bool] = False
class AuthenticationVerifyRequest(BaseModel):
challenge: str
credential: dict
RP_ID = "yourapp.com"
RP_NAME = "MyApp"
ORIGIN = "https://yourapp.com"
registration_service = PasskeyRegistrationService(RP_ID, RP_NAME, ORIGIN)
auth_service = PasskeyAuthService(RP_ID, ORIGIN)
@app.post("/api/webauthn/registration/options")
async def create_registration_options(req: RegistrationOptionsRequest):
options, challenge = registration_service.generate_registration_options(
user_id=req.userId,
username=req.username,
display_name=req.displayName,
exclude_credentials=req.excludeCredentials,
)
challenge_store[challenge] = {
"userId": req.userId,
"type": "registration",
"expiresAt": secrets.token_hex(8),
}
return {"options": options, "challenge": challenge}
@app.post("/api/webauthn/registration/verify")
async def verify_registration_endpoint(req: RegistrationVerifyRequest):
stored = challenge_store.get(req.challenge)
if not stored:
raise HTTPException(status_code=400, detail="無効または期限切れのchallenge")
del challenge_store[req.challenge]
try:
result = registration_service.verify_registration(
challenge_b64=req.challenge,
credential_response=req.credential,
expected_origin=ORIGIN,
)
return {"success": True, "credentialId": result["credential_id"]}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/webauthn/authentication/options")
async def create_authentication_options(req: AuthenticationOptionsRequest):
allow_credentials = None
if req.userId:
pass
options, challenge = auth_service.generate_authentication_options(
allow_credentials=allow_credentials,
)
challenge_store[challenge] = {
"userId": req.userId,
"type": "authentication",
}
return {"options": options, "challenge": challenge}
@app.post("/api/webauthn/authentication/verify")
async def verify_authentication_endpoint(req: AuthenticationVerifyRequest):
stored = challenge_store.get(req.challenge)
if not stored:
raise HTTPException(status_code=400, detail="無効または期限切れのchallenge")
del challenge_store[req.challenge]
try:
new_sign_count = auth_service.verify_authentication(
challenge_b64=req.challenge,
credential_response=req.credential,
stored_credential={},
)
session_token = secrets.token_urlsafe(32)
session_store[session_token] = {
"userId": stored["userId"],
"authenticatedAt": __import__("datetime").datetime.utcnow().isoformat(),
}
return {
"success": True,
"sessionToken": session_token,
}
except Exception as e:
raise HTTPException(status_code=401, detail=str(e))
@app.get("/api/webauthn/credentials")
async def list_credentials(userId: str):
return {"credentials": []}
@app.post("/api/webauthn/credentials/delete")
async def delete_credential(request: Request):
data = await request.json()
return {"success": True}
@app.post("/api/webauthn/credentials/rename")
async def rename_credential(request: Request):
data = await request.json()
return {"success": True}
クライアント完全統合:
class WebAuthnClient {
constructor(baseUrl = "") {
this.baseUrl = baseUrl;
}
async isSupported() {
if (!window.PublicKeyCredential) return false;
return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
}
async register(userId, username, displayName) {
const existingCreds = await this._getExistingCredentials(userId);
const { options, challenge } = await this._fetch("/api/webauthn/registration/options", {
userId,
username,
displayName,
excludeCredentials: existingCreds,
});
try {
const credential = await navigator.credentials.create({
publicKey: this._decodeRegistrationOptions(options),
});
const result = await this._fetch("/api/webauthn/registration/verify", {
challenge,
credential: this._encodeCredential(credential),
});
return { success: true, credentialId: result.credentialId };
} catch (error) {
return { success: false, error: error.name, message: error.message };
}
}
async authenticate(userId = null) {
const { options, challenge } = await this._fetch(
"/api/webauthn/authentication/options",
{ userId, conditionalUI: false }
);
try {
const assertion = await navigator.credentials.get({
publicKey: this._decodeAuthenticationOptions(options),
});
const result = await this._fetch("/api/webauthn/authentication/verify", {
challenge,
credential: this._encodeAssertion(assertion),
});
return { success: true, sessionToken: result.sessionToken };
} catch (error) {
return { success: false, error: error.name, message: error.message };
}
}
async listCredentials(userId) {
const response = await fetch(
`${this.baseUrl}/api/webauthn/credentials?userId=${userId}`
);
return response.json();
}
async deleteCredential(userId, credentialId) {
return this._fetch("/api/webauthn/credentials/delete", {
userId,
credentialId,
});
}
async renameCredential(userId, credentialId, newName) {
return this._fetch("/api/webauthn/credentials/rename", {
userId,
credentialId,
newName,
});
}
_decodeRegistrationOptions(options) {
return {
...options,
challenge: base64urlDecode(options.challenge),
user: {
...options.user,
id: base64urlDecode(options.user.id),
},
excludeCredentials: (options.excludeCredentials || []).map((ec) => ({
...ec,
id: base64urlDecode(ec.id),
})),
};
}
_decodeAuthenticationOptions(options) {
return {
...options,
challenge: base64urlDecode(options.challenge),
allowCredentials: (options.allowCredentials || []).map((ac) => ({
...ac,
id: base64urlDecode(ac.id),
})),
};
}
_encodeCredential(credential) {
return {
id: credential.id,
rawId: base64urlEncode(credential.rawId),
type: credential.type,
response: {
attestationObject: base64urlEncode(credential.response.attestationObject),
clientDataJSON: base64urlEncode(credential.response.clientDataJSON),
},
};
}
_encodeAssertion(assertion) {
return {
id: assertion.id,
rawId: base64urlEncode(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: base64urlEncode(assertion.response.authenticatorData),
clientDataJSON: base64urlEncode(assertion.response.clientDataJSON),
signature: base64urlEncode(assertion.response.signature),
userHandle: assertion.response.userHandle
? base64urlEncode(assertion.response.userHandle)
: null,
},
};
}
async _fetch(path, body) {
const response = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || "リクエスト失敗");
}
return response.json();
}
async _getExistingCredentials(userId) {
try {
const { credentials } = await this.listCredentials(userId);
return credentials.map((c) => ({ id: c.id }));
} catch {
return [];
}
}
}
const webauthn = new WebAuthnClient();
5つの落とし穴ガイド
落とし穴1:challengeの検証なし・再利用
❌ 誤り:
CHALLENGE = "fixed-challenge-value-12345"
def verify_registration(credential_response):
verification = verify_registration_response(
credential=credential_response,
expected_challenge=CHALLENGE,
expected_origin=ORIGIN,
expected_rp_id=RP_ID,
)
const CHALLENGE = "fixed-challenge-value-12345";
const options = { challenge: CHALLENGE, ... };
✅ 正解:
import secrets
def generate_challenge() -> str:
challenge = secrets.token_bytes(32)
challenge_b64 = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode()
challenge_store[challenge_b64] = {
"createdAt": datetime.utcnow(),
"used": False,
}
return challenge_b64
def verify_challenge(challenge_b64: str) -> bool:
stored = challenge_store.get(challenge_b64)
if not stored or stored["used"]:
return False
age = (datetime.utcnow() - stored["createdAt"]).total_seconds()
if age > 300:
del challenge_store[challenge_b64]
return False
stored["used"] = True
return True
// クライアントは常にサーバーからchallengeを取得し、クライアント側で生成しない
const { options, challenge } = await fetch("/api/webauthn/registration/options", {
method: "POST",
body: JSON.stringify({ userId, username, displayName }),
}).then(r => r.json());
落とし穴2:origin検証の欠落
❌ 誤り:
def verify_registration(credential_response, challenge):
verification = verify_registration_response(
credential=credential_response,
expected_challenge=challenge,
expected_origin="*", # originを検証しない
expected_rp_id=RP_ID,
)
// クライアントにorigin検証の意識がない
✅ 正解:
ALLOWED_ORIGINS = {
"https://yourapp.com",
"https://www.yourapp.com",
}
def verify_registration(credential_response, challenge):
client_data = json.loads(
base64.urlsafe_b64decode(
credential_response["response"]["clientDataJSON"] + "=="
)
)
origin = client_data.get("origin")
if origin not in ALLOWED_ORIGINS:
raise ValueError(f"許可されていないorigin: {origin}")
verification = verify_registration_response(
credential=credential_response,
expected_challenge=challenge,
expected_origin=origin,
expected_rp_id=RP_ID,
)
// ページが正しいoriginで実行されていることを確認
if (window.location.origin !== "https://yourapp.com") {
console.error("WebAuthnは正しいoriginでのみ使用できます");
}
落とし穴3:sign_count未検証によるクローン攻撃
❌ 誤り:
def verify_authentication(credential_response, stored_credential):
verification = verify_authentication_response(
credential=credential_response,
expected_challenge=challenge,
expected_origin=ORIGIN,
expected_rp_id=RP_ID,
credential_public_key=stored_credential["public_key"],
credential_current_sign_count=0, # 常に0を渡す
)
# 返されたsign_countを無視
✅ 正解:
def verify_authentication(credential_response, stored_credential):
verification = verify_authentication_response(
credential=credential_response,
expected_challenge=challenge,
expected_origin=ORIGIN,
expected_rp_id=RP_ID,
credential_public_key=stored_credential["public_key"],
credential_current_sign_count=stored_credential["sign_count"],
)
if verification.new_sign_count <= stored_credential["sign_count"]:
# クローンクレデンシャルの可能性、セキュリティイベントを記録
log_security_event(
"possible_credential_clone",
credential_id=stored_credential["credential_id"],
stored_count=stored_credential["sign_count"],
received_count=verification.new_sign_count,
)
raise ValueError("クレデンシャルのクローン攻撃の可能性を検出")
stored_credential["sign_count"] = verification.new_sign_count
落とし穴4:residentKey設定ミスによるPasskey同期不可
❌ 誤り:
options = {
"authenticatorSelection": {
"authenticatorAttachment": "platform",
"requireResidentKey": False, # 誤り:Passkeyにはresident keyが必要
"residentKey": "discouraged",
"userVerification": "discouraged",
},
}
const options = {
authenticatorSelection: {
authenticatorAttachment: "platform",
requireResidentKey: false,
residentKey: "discouraged",
userVerification: "discouraged",
},
};
✅ 正解:
options = {
"authenticatorSelection": {
"authenticatorAttachment": "platform",
"requireResidentKey": True,
"residentKey": "required", # Passkeyはrequiredに設定必須
"userVerification": "required", # 生体認証を確実に実行
},
}
const options = {
authenticatorSelection: {
authenticatorAttachment: "platform",
requireResidentKey: true,
residentKey: "required",
userVerification: "required",
},
};
落とし穴5:最後のクレデンシャル削除でアカウントロック
❌ 誤り:
def delete_credential(user_id: str, credential_id: str):
cred = db.query(Credential).filter_by(id=credential_id).first()
db.delete(cred)
db.commit()
return {"success": True}
✅ 正解:
def delete_credential(user_id: str, credential_id: str):
remaining = db.query(Credential).filter_by(user_id=user_id).count()
if remaining <= 1:
has_password = db.query(User).filter_by(id=user_id).first().password_hash
if not has_password:
raise ValueError(
"これが最後のPasskeyでパスワードも設定されていません。"
"削除するとログインできなくなります。先にバックアップPasskeyを登録するかパスワードを設定してください。"
)
cred = db.query(Credential).filter_by(id=credential_id).first()
db.delete(cred)
db.commit()
return {"success": True}
エラートラブルシューティング表
| エラーメッセージ | 原因 | 解決策 |
|---|---|---|
DOMException: The operation is insecure |
ページがHTTPSまたはlocalhostではない | HTTPSまたはlocalhost開発環境を使用 |
DOMException: The operation was cancelled |
ユーザーがキャンセルまたはタイムアウト | NotAllowedErrorをキャッチし、リトライオプションを提供 |
InvalidStateError |
デバイスが既にこのRPのクレデンシャルを登録済み | excludeCredentialsで登録済みクレデンシャルを除外 |
SecurityError: The rpId is not valid |
rpIdが現在のドメインと一致しない | rpIdが有効なドメインでページのoriginと一致することを確認 |
TypeError: Credentials container is empty |
一致するallowCredentialsがない | クレデンシャルIDのエンコーディングが正しいか確認 |
DOMException: A requested feature is not supported |
ブラウザがresidentKeyをサポートしていない | residentKey: "preferred"にダウングレード |
Verification failed: signature mismatch |
公開鍵または署名データが破損 | base64urlエンコード/デコードを確認、COSE key形式を確認 |
Verification failed: challenge mismatch |
challengeエンコーディングの不一致 | サーバーとクライアントが同じbase64urlエンコーディングを使用することを確認 |
NetworkError: authentication cancelled |
オーセンティケータとの通信が中断 | リトライ、USB/NFC接続を確認(セキュリティキーシナリオ) |
ConstraintError: request has too many params |
excludeCredentialsが多すぎる | 除外リストを最大10個のクレデンシャルに制限 |
高度な最適化
1. Challengeストレージ最適化(Redis + TTL)
import redis
import json
redis_client = redis.Redis(host="localhost", port=6379, db=0)
class RedisChallengeStore:
CHALLENGE_TTL = 300 # 5分で期限切れ
def store(self, challenge: str, data: dict) -> None:
key = f"webauthn:challenge:{challenge}"
redis_client.setex(key, self.CHALLENGE_TTL, json.dumps(data))
def retrieve(self, challenge: str) -> dict | None:
key = f"webauthn:challenge:{challenge}"
data = redis_client.get(key)
if data:
redis_client.delete(key)
return json.loads(data)
return None
def exists(self, challenge: str) -> bool:
return redis_client.exists(f"webauthn:challenge:{challenge}") > 0
2. クレデンシャルトランスポート層最適化
def store_credential_with_transports(
user_id: str,
credential_id: str,
public_key: str,
sign_count: int,
transports: list[str],
) -> None:
credential = PasskeyCredential(
credential_id=credential_id,
user_id=user_id,
public_key=public_key,
sign_count=sign_count,
transports=transports,
device_type=_classify_device(transports),
created_at=datetime.utcnow(),
)
db.add(credential)
db.commit()
def _classify_device(transports: list[str]) -> str:
if "internal" in transports:
return "platform"
if "usb" in transports and "nfc" in transports:
return "security_key_multi"
if "usb" in transports:
return "security_key_usb"
if "nfc" in transports:
return "security_key_nfc"
if "ble" in transports:
return "security_key_ble"
return "unknown"
3. 認証監査ログ
from dataclasses import dataclass
from datetime import datetime
@dataclass
class AuthAuditLog:
id: str
user_id: str
event_type: str
credential_id: str
ip_address: str
user_agent: str
success: bool
error_message: str | None
created_at: datetime
class AuthAuditService:
def log_registration(
self,
user_id: str,
credential_id: str,
success: bool,
request,
error: str | None = None,
) -> None:
log = AuthAuditLog(
id=secrets.token_urlsafe(16),
user_id=user_id,
event_type="passkey_registration",
credential_id=credential_id,
ip_address=request.client.host,
user_agent=request.headers.get("user-agent", ""),
success=success,
error_message=error,
created_at=datetime.utcnow(),
)
db.add(log)
db.commit()
def log_authentication(
self,
user_id: str,
credential_id: str,
success: bool,
request,
error: str | None = None,
) -> None:
log = AuthAuditLog(
id=secrets.token_urlsafe(16),
user_id=user_id,
event_type="passkey_authentication",
credential_id=credential_id,
ip_address=request.client.host,
user_agent=request.headers.get("user-agent", ""),
success=success,
error_message=error,
created_at=datetime.utcnow(),
)
db.add(log)
db.commit()
def detect_anomaly(self, user_id: str, window_minutes: int = 15) -> bool:
cutoff = datetime.utcnow() - timedelta(minutes=window_minutes)
recent_failures = (
db.query(AuthAuditLog)
.filter(
AuthAuditLog.user_id == user_id,
AuthAuditLog.success == False,
AuthAuditLog.created_at >= cutoff,
)
.count()
)
return recent_failures >= 5
認証方式比較
| 特徴 | WebAuthn/Passkey | TOTP | SMS OTP | Magic Link | OAuth2 |
|---|---|---|---|---|---|
| フィッシング対策 | ✅ ドメインバインド | ❌ フィッシング可能 | ❌ フィッシング可能 | ⚠️ 部分的 | ⚠️ IdP依存 |
| ユーザー体験 | ✅ ワンタップ生体認証 | ⚠️ 6桁コード入力 | ⚠️ SMS待ち | ⚠️ メール確認 | ✅ ワンクリックリダイレクト |
| デバイス要件 | ⚠️ WebAuthn対応必要 | ✅ 任意のスマホ | ✅ 任意のスマホ | ✅ 任意のメール | ✅ 任意のブラウザ |
| オフライン対応 | ✅ ローカル検証 | ✅ オフライン生成 | ❌ ネットワーク必要 | ❌ ネットワーク必要 | ❌ ネットワーク必要 |
| マルチデバイス同期 | ✅ クラウド同期 | ❌ 手動移行 | ✅ 電話番号変更なし | ✅ メールでログイン可 | ✅ アカウント共通 |
| 実装コスト | ⚠️ 中程度 | ✅ 低い | ⚠️ SMS費用 | ✅ 低い | ⚠️ IdP統合必要 |
| セキュリティレベル | 🏆 最高 | ⭐ 高 | ⭐ 中 | ⭐ 中 | ⭐ 高 |
| 標準化 | ✅ W3C/FIDO | ✅ RFC 6238 | ❌ 統一規格なし | ❌ 統一規格なし | ✅ RFC 6749 |
まとめ:WebAuthn/FIDO2は「あれば便利」なオプション機能ではなく、2026年の認証における必須要件だ。6つのプロダクションパターンは、登録から認証、クレデンシャル管理からマルチデバイスサポート、フォールバック戦略から完全なサービスまで、フルパイプラインをカバーする。3つの原則を忘れないこと:challengeは真の乱数で使い捨て必須、residentKeyはrequiredに設定必須、常に少なくとも1つのリカバリチャネルを確保。Passkeyはパスワードの終焉をもたらす——ただし正しく実装した場合に限る。
おすすめツール
- Hashツール - challengeのSHA-256ハッシュを生成、WebAuthn challengeのデバッグに
- Base64エンコード/デコード - WebAuthnのbase64urlエンコードデータをデコード、クレデンシャルデータのデバッグに
- JWTデコード - 認証後のセッショントークンをデコード、WebAuthn認証結果の検証に
ブラウザローカルツールを無料で試す →