Passkeys・WebAuthn実装の基礎|FIDO2・Resident Key・@simplewebauthn/server

(更新: 2026年6月21日 ) Passkeys WebAuthn FIDO2 セキュリティ Node.js 認証
結論
  • 2026年の認証強化の定石は、WebAuthn/FIDO2ベースのPasskeyを既存ログインに追加することです。
  • サーバーは@simplewebauthn/server登録CeremonygenerateRegistrationOptionsverifyRegistrationResponse)と認証CeremonygenerateAuthenticationOptionsverifyAuthenticationResponse)を実装し、Resident Keyを有効にすればユーザー名なしログインが可能になります。
  • パスワードは即座に廃止せず段階的移行し、Ceremony開始APIにはCSRF対策を、OAuth併用時はPKCE + BFFを適用してください。

Passkey・WebAuthn・FIDO2の関係

認証の文脈で「Passkey」「WebAuthn」「FIDO2」が混在しますが、レイヤーが異なります。

用語レイヤー説明
FIDO2プロトコルWebAuthn(ブラウザ↔RP)とCTAP2(ブラウザ↔Authenticator)のセット
WebAuthnW3C APInavigator.credentials.create() / get() による公開鍵認証
Passkey製品概念同期可能なWebAuthn資格情報(Apple iCloud Keychain、Google Password Manager等)
Authenticatorハード/ソフトTouch ID、Windows Hello、YubiKey、スマホの生体認証

WebAuthnの核心は公開鍵暗号です。サーバーはパスワードの代わりに公開鍵だけを保存し、ログイン時はAuthenticatorが保持する秘密鍵でchallengeに署名します。秘密鍵はRP(あなたのサーバー)に送られません。フィッシングサイトは正しいrpIDで署名を要求できないため、従来のパスワード窃取型攻撃に強いのがPasskeyの最大の利点です。

RP(Relying Party)とは

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に署名を依頼 → サーバーが検証」という流れです。

1

ユーザーが「Passkeyを登録」または「Passkeyでログイン」をクリックする

2

サーバーが generateRegistrationOptions または generateAuthenticationOptions で challenge を生成し、セッションに保存する

3

クライアントが options を navigator.credentials.create() または get() に渡す

4

OS/ブラウザが Authenticator(Touch ID、Windows Hello等)を起動し、ユーザーが生体認証またはPINを入力する

5

Authenticator が challenge に署名した PublicKeyCredential を返す

6

クライアントが credential JSON をサーバーへ POST する

7

サーバーが verifyRegistrationResponse または verifyAuthenticationResponse で署名・origin・rpID・challenge・counter を検証する

8

登録成功時は公開鍵をDB保存、認証成功時はセッションCookieを発行する

challengeは使い捨てです。検証後はセッションから削除し、リプレイ攻撃を防ぎます。有効期限は通常数分以内(実務では60〜300秒)に設定します。

Resident Key(Discoverable Credential)の理解

WebAuthnには2種類の資格情報保存方式があります。

Non-Resident Key(非 Resident)

  • Authenticator内にユーザー情報を保存しない
  • サーバーがallowCredentialscredentialIDのリストを指定して認証を要求する
  • ユーザー名(メールアドレス)を先に入力し、そのユーザーに紐づく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 推奨。
userHandle(user.id)の設計

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/serveroptions生成・レスポンス検証(Node.js)
@simplewebauthn/browserPublicKeyCredentialのbase64url変換ヘルパー
express-sessionchallengeと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実装時に確認すべき項目を整理します。

1

challengeはセッションに保存し、検証後に即削除する(リプレイ防止)

2

expectedOrigin と expectedRPID を厳密に検証する(フィッシング防御の要)

3

credential counter が単調増加していることを確認する(クローン検出)

4

Ceremony開始API(options)に CSRF トークン検証を適用する

5

セッションCookieに HttpOnly + Secure + SameSite=Lax を設定する

6

excludeCredentials で同一Authenticatorの二重登録を防ぐ

7

userHandle と DB ユーザーの対応を認証時に再確認する(Discoverable Flow)

8

OAuth/IdP併用時はトークンをlocalStorageに置かず BFF + HttpOnly Cookie を使う

origin検証を省略しない

verifyRegistrationResponse / verifyAuthenticationResponseexpectedOrigin は必須です。ワイルドカードやリファラベースの緩い検証は、サブドメイン乗っ取り時に突破される可能性があります。許可する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 mismatchrpIDと実際のドメイン不一致本番ドメインとRP_IDを一致させる
Verification failed: Unexpected originexpectedOrigin不一致RP_ORIGINにスキーム・ポートまで含める
Credential not foundDBにcredentialID未登録登録Ceremonyが完了しているか確認
Possible cloned authenticatorcounterが増加していない該当credentialを無効化し再登録を要求

段階的移行ロードマップ(2026年版)

1

Phase 1:アカウント設定に「Passkeyを追加」を実装。パスワードログインは現状維持

2

Phase 2:ログイン画面にPasskeyボタンとConditional UI(Autofill)を追加

3

Phase 3:新規ユーザーにPasskey登録を促す(preferred)。パスワードは任意に

4

Phase 4:高リスク操作(送金・設定変更)でPasskey再認証(step-up)を要求

5

Phase 5:パスワードログインを非推奨化。リカバリフロー整備後にパスワードレス化

各PhaseでCSRF・セッション管理・XSS防御は継続して必要です。Passkeyはフィッシングには強いですが、XSSでセッションCookieを悪用されるリスクは残ります。

テスト戦略

WebAuthnは実Authenticatorが必要なため、CIではVirtual Authenticatorを使います。Chrome DevTools Protocolまたは@simplewebauthn/serverのテストヘルパーでchallenge/responseの往復を検証できます。

手動テストのチェックリスト:

  1. 新規ユーザーでPasskey登録 → DBにcredentialが保存されるか
  2. 登録済みユーザーでPasskeyログイン → セッションが発行されるか
  3. 別ブラウザ(Passkey未登録)でログイン → 適切なエラーになるか
  4. challenge再利用 → 拒否されるか
  5. CSRFトークンなしでoptions → 403になるか
  6. 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設計も併用すると、ブルートフォースの余剰防御になります。