Passkeys・WebAuthn実装の基礎|FIDO2・Resident Key・@simplewebauthn/server
- 2026年の認証強化の定石は、WebAuthn/FIDO2ベースのPasskeyを既存ログインに追加することです。
- サーバーは
@simplewebauthn/serverで登録Ceremony(generateRegistrationOptions→verifyRegistrationResponse)と認証Ceremony(generateAuthenticationOptions→verifyAuthenticationResponse)を実装し、Resident Keyを有効にすればユーザー名なしログインが可能になります。 - パスワードは即座に廃止せず段階的移行し、Ceremony開始APIにはCSRF対策を、OAuth併用時はPKCE + BFFを適用してください。
Passkey・WebAuthn・FIDO2の関係
認証の文脈で「Passkey」「WebAuthn」「FIDO2」が混在しますが、レイヤーが異なります。
| 用語 | レイヤー | 説明 |
|---|---|---|
| FIDO2 | プロトコル | WebAuthn(ブラウザ↔RP)とCTAP2(ブラウザ↔Authenticator)のセット |
| WebAuthn | W3C API | navigator.credentials.create() / get() による公開鍵認証 |
| Passkey | 製品概念 | 同期可能なWebAuthn資格情報(Apple iCloud Keychain、Google Password Manager等) |
| Authenticator | ハード/ソフト | Touch ID、Windows Hello、YubiKey、スマホの生体認証 |
WebAuthnの核心は公開鍵暗号です。サーバーはパスワードの代わりに公開鍵だけを保存し、ログイン時はAuthenticatorが保持する秘密鍵でchallengeに署名します。秘密鍵はRP(あなたのサーバー)に送られません。フィッシングサイトは正しいrpIDで署名を要求できないため、従来のパスワード窃取型攻撃に強いのがPasskeyの最大の利点です。
WebAuthn用語でRPは「あなたのWebアプリケーション」を指します。rpIDは通常ドメイン名(example.com)で、originはスキーム込み(https://example.com)です。開発時はlocalhostをrpIDにできますが、本番と開発でoptionsの設定を分ける必要があります。
2026年:Passkey vs パスワード
パスワード認証は50年以上の歴史があり、実装も単純です。しかし2026年の脅威モデルでは、漏洩・再利用・フィッシング・ブルートフォースのリスクが構造的に残ります。Passkeyはこれらの多くを設計段階で排除します。
| 観点 | パスワード vs Passkey(2026) |
|---|---|
| フィッシング耐性 | パスワード:偽サイトに入力させれば窃取可能。Passkey:rpIDが一致しないAuthenticatorは署名を拒否するため、ドメイン偽装に強い。 |
| サーバー漏洩 | パスワード:ハッシュ漏洩→オフライン辞書攻撃。Passkey:公開鍵のみ保存。秘密鍵は端末内に留まる。 |
| UX | パスワード:覚える・入力する・リセットする手間。Passkey:生体認証または端末PINでワンタップ。Resident Keyならメールアドレス入力も省略可能。 |
| 端末依存 | パスワード:どの端末からでも(知っていれば)ログイン可能。Passkey:登録端末または同期エコシステム内の端末が必要。共有PCでは注意。 |
| 移行コスト | パスワード:既存DBそのまま。Passkey:Ceremony実装・DBスキーマ追加・CSRF/session設計・リカバリフローが必要。 |
| OAuth/IdP連携 | パスワード:IdPに委任可能。Passkey:自前RPとして実装するか、Apple/Google等のPasskey対応IdPを利用。SPA+BFF構成は[OAuth2 PKCEガイド](/blog/oauth2-pkce-spa-実装-完全ガイド/)参照。 |
Passkey非対応ブラウザ(古いAndroid WebView等)、ユーザーが端末を失った場合、企業ポリシーで生体認証が禁止されている環境では、パスワードまたは別のリカバリ手段が必要です。2026年の推奨はPasskey優先 + パスワードフォールバック + ステップアップ認証のハイブリッドです。
WebAuthn Ceremonyの全体像
WebAuthnには登録(Registration)と認証(Authentication)の2つのCeremonyがあります。どちらも「サーバーがchallengeを発行 → クライアントがAuthenticatorに署名を依頼 → サーバーが検証」という流れです。
ユーザーが「Passkeyを登録」または「Passkeyでログイン」をクリックする
サーバーが generateRegistrationOptions または generateAuthenticationOptions で challenge を生成し、セッションに保存する
クライアントが options を navigator.credentials.create() または get() に渡す
OS/ブラウザが Authenticator(Touch ID、Windows Hello等)を起動し、ユーザーが生体認証またはPINを入力する
Authenticator が challenge に署名した PublicKeyCredential を返す
クライアントが credential JSON をサーバーへ POST する
サーバーが verifyRegistrationResponse または verifyAuthenticationResponse で署名・origin・rpID・challenge・counter を検証する
登録成功時は公開鍵をDB保存、認証成功時はセッションCookieを発行する
challengeは使い捨てです。検証後はセッションから削除し、リプレイ攻撃を防ぎます。有効期限は通常数分以内(実務では60〜300秒)に設定します。
Resident Key(Discoverable Credential)の理解
WebAuthnには2種類の資格情報保存方式があります。
Non-Resident Key(非 Resident)
- Authenticator内にユーザー情報を保存しない
- サーバーが
allowCredentialsにcredentialIDのリストを指定して認証を要求する - ユーザー名(メールアドレス)を先に入力し、そのユーザーに紐づくcredentialIDをDBから引いてoptionsに載せる2段階ログイン
- セキュリティキー(YubiKey)のスロット制限(だいたい25〜100件)を気にする場合に有利
Resident Key(Discoverable Credential)
- Authenticator内にuser.id(userHandle)と秘密鍵を保存する
- 認証optionsで
allowCredentialsを省略(空)でき、Authenticatorが「このサイト用のPasskey」を自分で提示する - ユーザー名入力なしログイン(Apple/GoogleのPasskey UX)の前提
residentKey: 'required'または'preferred'で登録optionsに指定
| 方式 | 特徴・2026年の使い分け |
|---|---|
| Non-Resident | メール入力 → Passkey選択。DBからcredentialIDを特定。YubiKey等ハードウェアキー向き。allowCredentials必須。 |
| Resident Key | ワンクリックログイン。userHandleでサーバー側ユーザーを特定。Apple/Google/Microsoft Passkey Syncと相性が良い。2026年の消費者向けUXのデフォルト。 |
| requireResidentKey | 登録optionsの authenticatorSelection.residentKey。'required' | 'preferred' | 'discouraged'。Passkey UXなら 'preferred' 以上。 |
| userVerification | required で生体/PIN必須。preferred は可能なら実施。金融・管理画面は required 推奨。 |
user.idはAuthenticatorに保存される不透明なバイト列です。メールアドレスそのものを入れるとプライバシーリスクがあるため、ランダムなUUIDをBase64URLエンコードした値を使うのが一般的です。認証時にverifyAuthenticationResponseの戻り値authenticationInfo.userIDでDB検索します。
プロジェクト構成と依存関係
本記事ではExpress + TypeScript + @simplewebauthn/server + フロントエンド(Fetch API)構成を想定します。
npm install @simplewebauthn/server @simplewebauthn/browser express express-session
npm install -D typescript @types/express @types/express-session
| パッケージ | 役割 |
|---|---|
@simplewebauthn/server | options生成・レスポンス検証(Node.js) |
@simplewebauthn/browser | PublicKeyCredentialのbase64url変換ヘルパー |
express-session | challengeとCSRF stateの一時保存 |
環境変数の例:
RP_ID=localhost
RP_NAME=My App
RP_ORIGIN=http://localhost:3000
SESSION_SECRET=change-me-in-production-use-long-random-string
データベーススキーマ
Passkey(WebAuthn Credential)を保存するテーブル設計です。RDBを想定していますが、KVストアでも同じフィールドが必要です。
// src/db/schema.ts
export interface User {
id: string;
email: string;
webauthnUserId: string;
createdAt: Date;
}
export interface WebAuthnCredential {
id: string;
userId: string;
credentialId: string;
credentialPublicKey: Uint8Array;
counter: number;
credentialDeviceType: 'singleDevice' | 'multiDevice';
credentialBackedUp: boolean;
transports?: AuthenticatorTransport[];
createdAt: Date;
lastUsedAt: Date | null;
}
export interface PasskeyStore {
findUserByEmail(email: string): Promise<User | null>;
findUserByWebAuthnId(webauthnUserId: string): Promise<User | null>;
findUserById(userId: string): Promise<User | null>;
findCredentialsByUserId(userId: string): Promise<WebAuthnCredential[]>;
findCredentialById(credentialId: string): Promise<WebAuthnCredential | null>;
saveCredential(credential: WebAuthnCredential): Promise<void>;
updateCredentialCounter(credentialId: string, counter: number): Promise<void>;
createUser(user: User): Promise<void>;
}
counterはリプレイ検出に使います。Authenticatorは認証のたびにcounterを増やし、サーバー側で前回値より大きいことを確認します。cloneされたキーの検出にも役立ちます。
WebAuthn設定モジュール
RP設定を一箇所に集約します。
// src/auth/webauthn-config.ts
export const rpName = process.env.RP_NAME ?? 'My App';
export const rpID = process.env.RP_ID ?? 'localhost';
export const origin = process.env.RP_ORIGIN ?? 'http://localhost:3000';
export const expectedOrigins = [origin];
export const authenticatorSelection = {
residentKey: 'preferred' as const,
userVerification: 'preferred' as const,
authenticatorAttachment: 'platform' as const,
};
export const registrationTimeout = 300_000;
export const authenticationTimeout = 300_000;
authenticatorAttachment: 'platform'は内蔵Authenticator(Touch ID等)に限定します。YubiKey等のクロスプラットフォームキーも許可する場合は'cross-platform'または省略(両方許可)にします。
Express:登録Ceremony(サーバー側)
登録は2エンドポイント構成です。/webauthn/register/optionsでchallengeを発行し、/webauthn/register/verifyで検証します。
// src/routes/webauthn-register.ts
import { Router, Request, Response } from 'express';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
VerifiedRegistrationResponse,
} from '@simplewebauthn/server';
import { isoUint8Array } from '@simplewebauthn/server/helpers';
import { randomUUID } from 'node:crypto';
import {
rpName,
rpID,
origin,
expectedOrigins,
authenticatorSelection,
registrationTimeout,
} from '../auth/webauthn-config';
import { passkeyStore } from '../db/store';
export const webauthnRegisterRouter = Router();
interface SessionData {
registrationChallenge?: string;
pendingUserId?: string;
}
function getSession(req: Request): SessionData {
return req.session as SessionData;
}
webauthnRegisterRouter.post('/options', async (req: Request, res: Response) => {
const csrfToken = req.headers['x-csrf-token'];
if (csrfToken !== req.session.csrfToken) {
res.status(403).json({ error: 'Invalid CSRF token' });
return;
}
const { email } = req.body as { email?: string };
if (!email || typeof email !== 'string') {
res.status(400).json({ error: 'email is required' });
return;
}
let user = await passkeyStore.findUserByEmail(email);
if (!user) {
const webauthnUserId = randomUUID();
user = {
id: randomUUID(),
email,
webauthnUserId,
createdAt: new Date(),
};
await passkeyStore.createUser(user);
}
const existingCredentials = await passkeyStore.findCredentialsByUserId(user.id);
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: user.email,
userDisplayName: user.email,
userID: isoUint8Array.fromUTF8String(user.webauthnUserId),
timeout: registrationTimeout,
attestationType: 'none',
excludeCredentials: existingCredentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
})),
authenticatorSelection,
});
const session = getSession(req);
session.registrationChallenge = options.challenge;
session.pendingUserId = user.id;
res.json(options);
});
webauthnRegisterRouter.post('/verify', async (req: Request, res: Response) => {
const session = getSession(req);
const expectedChallenge = session.registrationChallenge;
const pendingUserId = session.pendingUserId;
if (!expectedChallenge || !pendingUserId) {
res.status(400).json({ error: 'Registration ceremony not started' });
return;
}
const user = await passkeyStore.findUserById(pendingUserId);
if (!user) {
res.status(400).json({ error: 'User not found' });
return;
}
let verification: VerifiedRegistrationResponse;
try {
verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Verification failed';
res.status(400).json({ error: message });
return;
}
const { verified, registrationInfo } = verification;
if (!verified || !registrationInfo) {
res.status(400).json({ error: 'Registration verification failed' });
return;
}
const {
credential,
credentialDeviceType,
credentialBackedUp,
} = registrationInfo;
await passkeyStore.saveCredential({
id: randomUUID(),
userId: user.id,
credentialId: credential.id,
credentialPublicKey: credential.publicKey,
counter: credential.counter,
credentialDeviceType,
credentialBackedUp,
transports: credential.transports,
createdAt: new Date(),
lastUsedAt: null,
});
delete session.registrationChallenge;
delete session.pendingUserId;
req.session.userId = user.id;
res.json({ verified: true, userId: user.id });
});
CSRFトークン検証はCSRF対策ガイドと同様、Ceremony開始リクエストに必須です。Passkey検証自体はPOST bodyの署名で保護されますが、他人のセッションで勝手にPasskey登録を開始されるのを防ぐためにもCSRFは必要です。
Express:認証Ceremony(サーバー側)
Resident Key(Discoverable Credential)を前提に、allowCredentialsなしのoptionsもサポートします。Non-Resident運用時はemailからcredentialIDリストを載せます。
// src/routes/webauthn-authenticate.ts
import { Router, Request, Response } from 'express';
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
VerifiedAuthenticationResponse,
} from '@simplewebauthn/server';
import {
rpID,
origin,
authenticationTimeout,
} from '../auth/webauthn-config';
import { passkeyStore } from '../db/store';
export const webauthnAuthenticateRouter = Router();
interface SessionData {
authenticationChallenge?: string;
discoverableFlow?: boolean;
}
function getSession(req: Request): SessionData {
return req.session as SessionData;
}
webauthnAuthenticateRouter.post('/options', async (req: Request, res: Response) => {
const csrfToken = req.headers['x-csrf-token'];
if (csrfToken !== req.session.csrfToken) {
res.status(403).json({ error: 'Invalid CSRF token' });
return;
}
const { email } = req.body as { email?: string };
let allowCredentials: { id: string; transports?: AuthenticatorTransport[] }[] | undefined;
if (email && typeof email === 'string') {
const user = await passkeyStore.findUserByEmail(email);
if (user) {
const creds = await passkeyStore.findCredentialsByUserId(user.id);
allowCredentials = creds.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
}));
}
}
const options = await generateAuthenticationOptions({
rpID,
timeout: authenticationTimeout,
allowCredentials,
userVerification: 'preferred',
});
const session = getSession(req);
session.authenticationChallenge = options.challenge;
session.discoverableFlow = !email;
res.json(options);
});
webauthnAuthenticateRouter.post('/verify', async (req: Request, res: Response) => {
const session = getSession(req);
const expectedChallenge = session.authenticationChallenge;
if (!expectedChallenge) {
res.status(400).json({ error: 'Authentication ceremony not started' });
return;
}
const body = req.body;
const credentialId = body.id as string;
const storedCredential = await passkeyStore.findCredentialById(credentialId);
if (!storedCredential) {
res.status(400).json({ error: 'Credential not found' });
return;
}
let verification: VerifiedAuthenticationResponse;
try {
verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: storedCredential.credentialId,
publicKey: storedCredential.credentialPublicKey,
counter: storedCredential.counter,
transports: storedCredential.transports,
},
requireUserVerification: false,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Verification failed';
res.status(400).json({ error: message });
return;
}
const { verified, authenticationInfo } = verification;
if (!verified || !authenticationInfo) {
res.status(400).json({ error: 'Authentication verification failed' });
return;
}
if (authenticationInfo.newCounter <= storedCredential.counter) {
res.status(400).json({ error: 'Possible cloned authenticator detected' });
return;
}
await passkeyStore.updateCredentialCounter(
storedCredential.credentialId,
authenticationInfo.newCounter,
);
const user = await passkeyStore.findUserById(storedCredential.userId);
if (!user) {
res.status(400).json({ error: 'User not found' });
return;
}
if (session.discoverableFlow && authenticationInfo.userID) {
const handleFromAuth = Buffer.from(authenticationInfo.userID).toString('utf8');
if (handleFromAuth !== user.webauthnUserId) {
res.status(400).json({ error: 'userHandle mismatch' });
return;
}
}
delete session.authenticationChallenge;
delete session.discoverableFlow;
req.session.userId = user.id;
res.json({ verified: true, userId: user.id });
});
フロントエンド:登録・認証クライアント
@simplewebauthn/browserのヘルパーでArrayBufferとBase64URLの変換を行います。
// src/client/passkey-client.ts
import {
startRegistration,
startAuthentication,
} from '@simplewebauthn/browser';
import type {
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
} from '@simplewebauthn/server';
async function fetchCsrfToken(): Promise<string> {
const res = await fetch('/api/csrf-token', { credentials: 'include' });
const data = (await res.json()) as { csrfToken: string };
return data.csrfToken;
}
export async function registerPasskey(email: string): Promise<{ verified: boolean }> {
const csrfToken = await fetchCsrfToken();
const optionsRes = await fetch('/webauthn/register/options', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({ email }),
});
if (!optionsRes.ok) {
const err = await optionsRes.json();
throw new Error(err.error ?? 'Failed to get registration options');
}
const options = (await optionsRes.json()) as PublicKeyCredentialCreationOptionsJSON;
const attestationResponse = await startRegistration({ optionsJSON: options });
const verifyRes = await fetch('/webauthn/register/verify', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attestationResponse),
});
if (!verifyRes.ok) {
const err = await verifyRes.json();
throw new Error(err.error ?? 'Registration verification failed');
}
return verifyRes.json();
}
export async function loginWithPasskey(email?: string): Promise<{ verified: boolean }> {
const csrfToken = await fetchCsrfToken();
const optionsRes = await fetch('/webauthn/authenticate/options', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify(email ? { email } : {}),
});
if (!optionsRes.ok) {
const err = await optionsRes.json();
throw new Error(err.error ?? 'Failed to get authentication options');
}
const options = (await optionsRes.json()) as PublicKeyCredentialRequestOptionsJSON;
const assertionResponse = await startAuthentication({ optionsJSON: options });
const verifyRes = await fetch('/webauthn/authenticate/verify', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(assertionResponse),
});
if (!verifyRes.ok) {
const err = await verifyRes.json();
throw new Error(err.error ?? 'Authentication verification failed');
}
return verifyRes.json();
}
startRegistration / startAuthenticationは内部でnavigator.credentials.create() / get()を呼び、ブラウザ非対応時は分かりやすいエラーを投げます。Feature Detectionは次のとおりです。
// src/client/webauthn-support.ts
export function isWebAuthnSupported(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.PublicKeyCredential !== 'undefined' &&
typeof navigator.credentials !== 'undefined' &&
typeof navigator.credentials.create === 'function'
);
}
export async function isConditionalMediationAvailable(): Promise<boolean> {
if (!isWebAuthnSupported()) {
return false;
}
return PublicKeyCredential.isConditionalMediationAvailable?.() ?? Promise.resolve(false);
}
Conditional UI(Autofill)によるパスキー提示
2026年のChrome/Edge/SafariはConditional Mediation(通称:Passkey Autofill)をサポートしています。ログインフォームのメール欄にPasskey候補がオートフィル表示され、UXが大幅に向上します。
// src/client/passkey-autofill.ts
import { startAuthentication } from '@simplewebauthn/browser';
import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/server';
export async function setupPasskeyAutofill(
inputElement: HTMLInputElement,
): Promise<void> {
const available = await PublicKeyCredential.isConditionalMediationAvailable?.();
if (!available) {
return;
}
inputElement.autocomplete = 'username webauthn';
const csrfRes = await fetch('/api/csrf-token', { credentials: 'include' });
const { csrfToken } = (await csrfRes.json()) as { csrfToken: string };
const optionsRes = await fetch('/webauthn/authenticate/options', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({}),
});
const options = (await optionsRes.json()) as PublicKeyCredentialRequestOptionsJSON;
const assertionResponse = await startAuthentication({
optionsJSON: options,
useBrowserAutofill: true,
});
const verifyRes = await fetch('/webauthn/authenticate/verify', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(assertionResponse),
});
if (verifyRes.ok) {
window.location.href = '/dashboard';
}
}
ページ読み込み時にsetupPasskeyAutofillを呼ぶと、メール入力欄にPasskeyが候補表示されます。Resident Keyが前提です。
Expressアプリのエントリポイント
セッション設定とルーター登録をまとめます。
// src/server.ts
import express from 'express';
import session from 'express-session';
import { randomBytes } from 'node:crypto';
import { webauthnRegisterRouter } from './routes/webauthn-register';
import { webauthnAuthenticateRouter } from './routes/webauthn-authenticate';
const app = express();
const PORT = process.env.PORT ?? 3000;
app.use(express.json());
app.use(
session({
secret: process.env.SESSION_SECRET ?? 'dev-secret-change-in-production',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000,
},
}),
);
declare module 'express-session' {
interface SessionData {
csrfToken?: string;
userId?: string;
registrationChallenge?: string;
pendingUserId?: string;
authenticationChallenge?: string;
discoverableFlow?: boolean;
}
}
app.get('/api/csrf-token', (req, res) => {
if (!req.session.csrfToken) {
req.session.csrfToken = randomBytes(32).toString('hex');
}
res.json({ csrfToken: req.session.csrfToken });
});
app.use('/webauthn/register', webauthnRegisterRouter);
app.use('/webauthn/authenticate', webauthnAuthenticateRouter);
app.get('/api/me', (req, res) => {
if (!req.session.userId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
res.json({ userId: req.session.userId });
});
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
本番ではsecure: true(HTTPS必須)と、十分長いSESSION_SECRETを設定してください。セッションCookieの属性詳細はセキュアCookie設定も参照してください。
Attestation(証明書)の扱い
attestationType: 'none'はAuthenticatorの製造元証明を要求しません。大多数のPasskey(プラットフォームAuthenticator)実装ではこれで十分です。
| attestationType | 用途 |
|---|---|
none | 一般消費者向けPasskey。プライバシーに配慮。 |
direct / indirect | 企業が許可デバイスを制限したい場合。YubiKeyのモデル検証等。 |
enterprise | 管理下デバイスのみ許可(Enterprise Attestation)。 |
過度なAttestation要求はユーザー体験を損ない、プラットフォームAuthenticatorが拒否する場合があります。2026年の一般Webアプリではnoneがデフォルトです。
セキュリティチェックリスト
Passkey実装時に確認すべき項目を整理します。
challengeはセッションに保存し、検証後に即削除する(リプレイ防止)
expectedOrigin と expectedRPID を厳密に検証する(フィッシング防御の要)
credential counter が単調増加していることを確認する(クローン検出)
Ceremony開始API(options)に CSRF トークン検証を適用する
セッションCookieに HttpOnly + Secure + SameSite=Lax を設定する
excludeCredentials で同一Authenticatorの二重登録を防ぐ
userHandle と DB ユーザーの対応を認証時に再確認する(Discoverable Flow)
OAuth/IdP併用時はトークンをlocalStorageに置かず BFF + HttpOnly Cookie を使う
verifyRegistrationResponse / verifyAuthenticationResponse の expectedOrigin は必須です。ワイルドカードやリファラベースの緩い検証は、サブドメイン乗っ取り時に突破される可能性があります。許可するoriginは配列で明示的に列挙してください。
OAuth2・既存ログインとの統合
Passkeyは第一因子として単独ログインにも、第二因子としてパスワードログイン後のステップアップにも使えます。
パターン1:Passkeyを追加ログイン手段として提供
既存のメール+パスワードログインは維持し、アカウント設定画面からPasskeyを登録します。ログイン画面に「Passkeyでログイン」ボタンを追加するだけなので移行コストが低いです。
パターン2:OAuth2 IdPのPasskeyを利用
Google/Apple/MicrosoftがPasskeyログインを提供している場合、自前実装よりIdP委任の方が早い場合があります。SPAからOAuth2する際はOAuth2 PKCE SPA実装ガイドのAuthorization Code + PKCE + BFF構成を使い、アクセストークンをブラウザに露出させないでください。
パターン3:Passkeyのみ(パスワードレス)
Resident Key + Conditional UIで完全パスワードレスを目指す場合、アカウントリカバリ(別端末のPasskey、メールリンク、管理者復旧)を必ず設計します。Passkeyを失ったユーザーが永久ロックアウトしないフローが必要です。
よくあるエラーと対処
| エラー | 原因 | 対処 |
|---|---|---|
The operation either timed out or was not allowed | ユーザーがキャンセル、またはUIがブロックされた | UXで再試行を促す。In-app BrowserではPasskey非対応のことが多い |
SecurityError: rpId mismatch | rpIDと実際のドメイン不一致 | 本番ドメインとRP_IDを一致させる |
Verification failed: Unexpected origin | expectedOrigin不一致 | RP_ORIGINにスキーム・ポートまで含める |
Credential not found | DBにcredentialID未登録 | 登録Ceremonyが完了しているか確認 |
Possible cloned authenticator | counterが増加していない | 該当credentialを無効化し再登録を要求 |
段階的移行ロードマップ(2026年版)
Phase 1:アカウント設定に「Passkeyを追加」を実装。パスワードログインは現状維持
Phase 2:ログイン画面にPasskeyボタンとConditional UI(Autofill)を追加
Phase 3:新規ユーザーにPasskey登録を促す(preferred)。パスワードは任意に
Phase 4:高リスク操作(送金・設定変更)でPasskey再認証(step-up)を要求
Phase 5:パスワードログインを非推奨化。リカバリフロー整備後にパスワードレス化
各PhaseでCSRF・セッション管理・XSS防御は継続して必要です。Passkeyはフィッシングには強いですが、XSSでセッションCookieを悪用されるリスクは残ります。
テスト戦略
WebAuthnは実Authenticatorが必要なため、CIではVirtual Authenticatorを使います。Chrome DevTools Protocolまたは@simplewebauthn/serverのテストヘルパーでchallenge/responseの往復を検証できます。
手動テストのチェックリスト:
- 新規ユーザーでPasskey登録 → DBにcredentialが保存されるか
- 登録済みユーザーでPasskeyログイン → セッションが発行されるか
- 別ブラウザ(Passkey未登録)でログイン → 適切なエラーになるか
- challenge再利用 → 拒否されるか
- CSRFトークンなしでoptions → 403になるか
localhostと本番ドメインでそれぞれrpID/originが正しいか
関連記事
この記事は セキュリティ テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| Double Submit Cookie完全実装 | Double Submit Cookie完全実装|Synchronizer Token比較・SPA+JWT・サブドメイン攻撃 |
| CSP Report-Only 段階導入ガイド | CSP Report-Only 段階導入ガイド|本番を壊さずポリシーを検証する |
| セキュリティヘッダー一覧と設定ガイド | セキュリティヘッダー一覧と設定ガイド|CSP・HSTS・Referrer-Policy・Permissions-Policy |
| CORSエラーの対処法 | CORSエラーの対処法|「Access-Control-Allow-Origin」で解決する |
| CORSプリフライトリクエスト | CORSプリフライトリクエスト|OPTIONSが飛ぶ条件と正しい応答 |
| ローカル開発でCORSエラーが出る原因と3つの回避策 | ローカル開発でCORSエラーが出る原因と3つの回避策 |
| JWTのデバッグ方法 | JWTのデバッグ方法|トークンの中身を安全に確認する |
| JWTの中身を確認する方法 | JWTの中身を確認する方法|改ざんが検知される仕組み |
| JWKSとJWT鍵ローテーション運用ガイド | JWKSとJWT鍵ローテーション運用ガイド|kid検証・ゼロダウンタイム・Node.js実装 |
| HSTS Preload設定手順 | HSTS Preload設定手順|nginx・Cloudflare実装とhstspreload.org申請ガイド |
| X-Content-Type-Options: nosniff 解説 | X-Content-Type-Options: nosniff 解説|MIMEスニッフィング対策 |
| Subresource Integrity (SRI) 完全ガイド | Subresource Integrity (SRI) 完全ガイド|CDN スクリプトの integrity・crossorigin・フォールバック |
まとめ
Passkey(WebAuthn/FIDO2)は2026年のWeb認証において、フィッシング耐性とUXの両立を可能にする最重要技術です。実装の骨格は次の4関数に集約されます。
| 関数 | Ceremony |
|---|---|
generateRegistrationOptions | 登録開始 |
verifyRegistrationResponse | 登録完了 |
generateAuthenticationOptions | 認証開始 |
verifyAuthenticationResponse | 認証完了 |
@simplewebauthn/serverを使えば、暗号検証の詳細を自前実装せずに済みます。Resident Keyを有効にし、Conditional UIを組み合わせれば、Apple/Googleが推進する「パスワードレス体験」に近いUXを自社サービスでも提供できます。
同時に、CSRF対策(実装ガイド)、セキュアなセッションCookie、OAuth連携時のPKCE(SPAガイド)はPasskey導入後も必須の多層防御です。パスワードを一夜にして廃止するのではなく、Phase 1から段階的に移行し、リカバリプランを先に整える——これが2026年の現実的なPasskey導入戦略です。
Passkey実装後は、CSP(CSP設定ガイド)でXSSリスクを下げ、管理操作にはPasskey再認証(step-up)を検討してください。API保護にはRate Limit設計も併用すると、ブルートフォースの余剰防御になります。