「パスワードが漏れた」「ユーザーがパスワードを忘れてサポートが大変」——認証まわりの悩みは個人開発者にとって永遠のテーマです。2026年現在、その答えのひとつとして急速に普及しているのが Passkeys(パスキー) です。

パスキーはGoogle・Apple・Microsoftが標準として推進するパスワードレス認証技術で、フィッシング詐欺に完全耐性があり、ユーザー体験も格段に良いという特性があります。iPhoneやAndroidでは「顔認証でログイン」がそのまま使えます。

この記事では、WebAuthn APIの仕組みからNode.js/Cloudflare Workersでの実際の実装コードまで、個人開発者がパスキー認証を自分のサービスに組み込む方法を徹底解説します。

Passkeys(パスキー)とは何か:10分で理解する基礎知識

パスワード認証の限界

まず、なぜパスキーが生まれたのかを理解しましょう。従来のパスワード認証には構造的な問題があります:

  • フィッシング攻撃に無力 — ユーザーがニセサイトにパスワードを入力してしまうと、どんな対策も無意味になる
  • リスト型攻撃 — 他サービスから漏れたID/パスワードがそのまま使われてしまう(パスワード使い回し問題)
  • 管理コスト — 強力なパスワードを何十も覚えることはユーザーにとって現実的でない
  • サーバー側のリスク — ハッシュ化しても、パスワードをサーバーに保存する時点でリスクが生じる

SMS認証(2FA)はフィッシング対策として広まりましたが、SIMスワップ攻撃や、そもそもSMSを受け取れない海外ユーザー問題など、課題が残ります。

パスキーが解決すること

パスキーは 公開鍵暗号方式 を使った認証仕組みです。概念をシンプルに説明すると:

  1. ユーザーのデバイス(スマホ・PC)に秘密鍵が生成され、安全なチップ(Secure Enclave/TPM)に保存される
  2. サーバーには対になる公開鍵だけが保存される
  3. ログイン時、サーバーが「チャレンジ」を送り、ユーザーのデバイスが秘密鍵でサインして返す
  4. サーバーは公開鍵でサインを検証するだけ — パスワードの送受信は一切ない

この仕組みにより:

  • 秘密鍵はデバイスの外に出ないため、フィッシングサイトに入力する「もの」が存在しない
  • サーバーが侵害されても公開鍵しか漏れない
  • 生体認証(Face ID / 指紋)がそのままパスキーの「解錠」に使える

WebAuthn・FIDO2・パスキーの関係

用語が混在しているので整理します:

用語 何か
FIDO2 FIDOアライアンスが策定した認証規格の総称。WebAuthn + CTAPで構成される
WebAuthn W3Cが標準化したWeb APIの仕様。ブラウザがnavigator.credentialsを通じてFIDO2認証を行う
Passkeys Google・Apple・Microsoftが「マルチデバイス対応のFIDO2認証情報」に付けた名称。iCloud KeychainやGoogleパスワードマネージャーで同期できる

開発者的には「WebAuthn APIを実装すれば、自動的にパスキー対応になる」と理解すればOKです。

広告スペース

実装前に理解すべき:登録と認証の2つのフロー

WebAuthnには「登録(Registration)」と「認証(Authentication)」の2フローがあります。それぞれ4ステップで完結します。

登録フロー(パスキーの作成)

  1. クライアント → サーバー:「パスキーを登録したい」とリクエスト
  2. サーバー → クライアント:チャレンジ(ランダムなバイト列)と登録オプションを返す
  3. クライアント(ブラウザ)navigator.credentials.create()を呼び出し、ユーザーが生体認証を行う。デバイスが秘密鍵と公開鍵のペアを生成
  4. クライアント → サーバー:公開鍵・認証器情報・チャレンジへの署名を送信。サーバーが検証し、公開鍵を保存

認証フロー(パスキーでログイン)

  1. クライアント → サーバー:「ログインしたい」とリクエスト
  2. サーバー → クライアント:新しいチャレンジを返す
  3. クライアント(ブラウザ)navigator.credentials.get()を呼び出し、ユーザーが生体認証でパスキーを選択。デバイスが秘密鍵でチャレンジにサインする
  4. クライアント → サーバー:署名を送信。サーバーが保存した公開鍵で検証 → ログイン成功

ポイントはサーバーにはチャレンジへの署名(証明)が届くだけで、秘密情報は一切届かない点です。

ライブラリ選定:SimpleWebAuthnが個人開発の最適解

WebAuthn APIをゼロから実装するのは複雑です。署名検証・CBOR解析・認証器のメタデータ検証など、セキュリティクリティカルな処理が多く、独自実装はリスクが高い。

2026年時点でのおすすめライブラリを比較します:

ライブラリ 特徴 向いているケース
SimpleWebAuthn クライアント・サーバー両対応。ドキュメントが豊富。Deno/Cloudflare Workers対応 個人開発・最初の実装に最適
@github/webauthn-json GitHubが開発。クライアントサイドのみ。軽量 サーバー処理を別で実装する場合
webauthn4j(Java) Java/Spring向け。エンタープライズで実績あり Javaスタックの既存システム
Passkey Clerk / Auth.js 認証ライブラリとしてパスキーをサポート 認証全体を任せたい場合

個人開発なら SimpleWebAuthn 一択です。この記事もSimpleWebAuthnベースで解説します。

実装ステップ1:環境構築とインストール

必要なもの

  • Node.js 20以上(または Cloudflare Workers)
  • HTTPS環境(WebAuthnはHTTPS必須。localhostは例外的にHTTPも可)
  • フロントエンド:モダンブラウザ(Chrome 67+、Safari 14+、Firefox 60+)

パッケージインストール

# サーバーサイド
npm install @simplewebauthn/server

# クライアントサイド(バンドラーで使う場合)
npm install @simplewebauthn/browser

# TypeScript型定義(含まれているが明示的に)
npm install -D @types/node

SimpleWebAuthnはESM/CJSの両方に対応しており、Cloudflare WorkersやDenoでも動作します。

実装ステップ2:サーバーサイド(登録処理)

Node.js + Express での実装例です。Honoや他のフレームワークでも同様の考え方で実装できます。

登録オプションの生成(エンドポイント1)

import { generateRegistrationOptions } from '@simplewebauthn/server';
import type { Request, Response } from 'express';

// ユーザーごとにチャレンジを一時保存(本番ではRedis/KVを使う)
const challenges = new Map<string, string>();

export async function getRegistrationOptions(req: Request, res: Response) {
  const { userId, username } = req.body;

  const options = await generateRegistrationOptions({
    rpName: 'MyApp',              // サービス名
    rpID: 'myapp.example.com',   // ドメイン(localhost使用時は'localhost')
    userID: userId,               // ユーザーの一意ID(Uint8Array or string)
    userName: username,           // ユーザー名(表示用)
    // 既存のパスキーを除外(同じデバイスで複数登録を防ぐ)
    excludeCredentials: [],
    authenticatorSelection: {
      // 'platform' = デバイス内蔵認証器(Face ID/指紋)
      // 'cross-platform' = YubiKeyなどの外部デバイス
      authenticatorAttachment: 'platform',
      userVerification: 'preferred', // 生体認証を推奨するが必須にしない
      residentKey: 'preferred',      // パスキーとして保存を推奨
    },
  });

  // チャレンジをセッションに保存(重要!検証時に必要)
  challenges.set(userId, options.challenge);

  res.json(options);
}

登録検証(エンドポイント2)

import { verifyRegistrationResponse } from '@simplewebauthn/server';

export async function verifyRegistration(req: Request, res: Response) {
  const { userId, registrationResponse } = req.body;

  const expectedChallenge = challenges.get(userId);
  if (!expectedChallenge) {
    return res.status(400).json({ error: 'No challenge found. Please start again.' });
  }

  try {
    const verification = await verifyRegistrationResponse({
      response: registrationResponse,
      expectedChallenge,
      expectedOrigin: 'https://myapp.example.com', // アプリのオリジン
      expectedRPID: 'myapp.example.com',
      requireUserVerification: false,
    });

    const { verified, registrationInfo } = verification;

    if (verified && registrationInfo) {
      // 公開鍵情報をDBに保存
      await savePasskeyToDatabase({
        userId,
        credentialID: registrationInfo.credentialID,
        credentialPublicKey: registrationInfo.credentialPublicKey,
        counter: registrationInfo.counter,
        // デバイス情報(任意)
        deviceType: registrationInfo.credentialDeviceType,
        backedUp: registrationInfo.credentialBackedUp,
      });

      // 使用済みチャレンジを削除
      challenges.delete(userId);

      res.json({ verified: true });
    } else {
      res.status(400).json({ error: 'Verification failed' });
    }
  } catch (error) {
    console.error('Registration error:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
}
広告スペース

実装ステップ3:サーバーサイド(認証処理)

認証オプションの生成

import { generateAuthenticationOptions } from '@simplewebauthn/server';

export async function getAuthenticationOptions(req: Request, res: Response) {
  const { userId } = req.body;

  // ユーザーの登録済みパスキー一覧を取得
  const userPasskeys = await getPasskeysForUser(userId);

  const options = await generateAuthenticationOptions({
    rpID: 'myapp.example.com',
    // 登録済みのパスキーを指定(省略するとデバイスが自動選択)
    allowCredentials: userPasskeys.map(passkey => ({
      id: passkey.credentialID,
      type: 'public-key',
    })),
    userVerification: 'preferred',
  });

  // チャレンジを保存
  challenges.set(userId, options.challenge);

  res.json(options);
}

認証検証

import { verifyAuthenticationResponse } from '@simplewebauthn/server';

export async function verifyAuthentication(req: Request, res: Response) {
  const { userId, authenticationResponse } = req.body;

  const expectedChallenge = challenges.get(userId);
  if (!expectedChallenge) {
    return res.status(400).json({ error: 'No challenge found' });
  }

  // DBから対応するパスキーを取得
  const passkey = await getPasskeyByCredentialId(authenticationResponse.id);
  if (!passkey) {
    return res.status(400).json({ error: 'Passkey not found' });
  }

  try {
    const verification = await verifyAuthenticationResponse({
      response: authenticationResponse,
      expectedChallenge,
      expectedOrigin: 'https://myapp.example.com',
      expectedRPID: 'myapp.example.com',
      authenticator: {
        credentialID: passkey.credentialID,
        credentialPublicKey: passkey.credentialPublicKey,
        counter: passkey.counter, // リプレイ攻撃防止
      },
      requireUserVerification: false,
    });

    const { verified, authenticationInfo } = verification;

    if (verified) {
      // カウンターを更新(重要:リプレイ攻撃防止)
      await updatePasskeyCounter(passkey.id, authenticationInfo.newCounter);

      challenges.delete(userId);

      // セッショントークン発行
      const token = issueSessionToken(userId);
      res.json({ verified: true, token });
    } else {
      res.status(401).json({ error: 'Authentication failed' });
    }
  } catch (error) {
    console.error('Authentication error:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
}

実装ステップ4:クライアントサイド(ブラウザ)

フロントエンドは @simplewebauthn/browser を使うことで、ブラウザのWebAuthn APIを直接触らずに実装できます。

登録ボタンの実装

import {
  startRegistration,
  startAuthentication,
} from '@simplewebauthn/browser';

// パスキー登録
async function registerPasskey(userId: string, username: string) {
  try {
    // Step 1: サーバーから登録オプションを取得
    const optionsRes = await fetch('/api/passkey/register/options', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ userId, username }),
    });
    const options = await optionsRes.json();

    // Step 2: ブラウザのWebAuthn APIを呼び出す(生体認証ダイアログが表示される)
    const registrationResponse = await startRegistration(options);

    // Step 3: サーバーに送って検証
    const verifyRes = await fetch('/api/passkey/register/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ userId, registrationResponse }),
    });
    const result = await verifyRes.json();

    if (result.verified) {
      alert('パスキーの登録が完了しました!');
    }
  } catch (error) {
    if (error.name === 'NotAllowedError') {
      // ユーザーがキャンセルした
      console.log('User cancelled');
    } else {
      console.error('Registration failed:', error);
    }
  }
}

// パスキー認証
async function loginWithPasskey(userId: string) {
  try {
    // Step 1: サーバーから認証オプションを取得
    const optionsRes = await fetch('/api/passkey/auth/options', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ userId }),
    });
    const options = await optionsRes.json();

    // Step 2: ブラウザのWebAuthn APIを呼び出す
    const authenticationResponse = await startAuthentication(options);

    // Step 3: サーバーで検証
    const verifyRes = await fetch('/api/passkey/auth/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ userId, authenticationResponse }),
    });
    const result = await verifyRes.json();

    if (result.verified) {
      // ログイン成功!トークンをlocalStorageなどに保存
      localStorage.setItem('token', result.token);
      window.location.href = '/dashboard';
    }
  } catch (error) {
    console.error('Authentication failed:', error);
  }
}

たったこれだけです。startRegistration()startAuthentication() が複雑なWebAuthn APIを隠蔽してくれるので、開発者はフローのハンドリングに集中できます。

Cloudflare Workers への対応

個人開発でCloudflare Workersを使っている場合(MicroSaaSの構成など)、SimpleWebAuthnはWorkers環境でも動作します。ただしいくつかの注意点があります。

チャレンジの保存先

通常のNode.jsアプリではメモリやRedisにチャレンジを保存しますが、Workersはステートレスです。Cloudflare KV または Durable Objects を使いましょう:

// Cloudflare Workers + KV でチャレンジを保存
async function saveChallenge(env: Env, userId: string, challenge: string) {
  // チャレンジは5分で自動削除(TTLを設定)
  await env.CHALLENGES.put(`challenge:${userId}`, challenge, {
    expirationTtl: 300, // 5分
  });
}

async function getAndDeleteChallenge(env: Env, userId: string): Promise<string | null> {
  const challenge = await env.CHALLENGES.get(`challenge:${userId}`);
  if (challenge) {
    await env.CHALLENGES.delete(`challenge:${userId}`);
  }
  return challenge;
}

rpIDの設定

Cloudflare Pages/Workersのカスタムドメインを使う場合、rpID はそのドメイン(例: myapp.pages.devmyapp.com)を指定します。サブドメインも有効ですが、rpID はホスト全体への信頼を持つため、セキュリティを考慮した設計が必要です。

セキュリティ設計の重要ポイント

パスキー実装で特に注意すべきセキュリティポイントをまとめます。

1. チャレンジは必ず一回限りで使い捨て

チャレンジは「使ったら即削除」が絶対です。同じチャレンジが再利用されるとリプレイ攻撃が可能になります。KVやRedisのTTLで自動期限切れを設定し、検証後は明示的に削除しましょう。

2. カウンターの管理

認証器は使用するたびにカウンターを増やします。検証時に前回のカウンター値より小さい値が来た場合はクローン検出の可能性があります。必ずカウンターを更新し、逆転していたら認証を拒否する実装を忘れずに。

// カウンター検証(verifyAuthenticationResponseが内部でやっているが意識として)
if (authenticationInfo.newCounter <= passkey.counter) {
  // パスキーのクローンが疑われる
  throw new Error('Possible passkey clone detected');
}
// 更新
await updateCounter(passkey.id, authenticationInfo.newCounter);

3. originとrpIDの厳密な検証

expectedOriginexpectedRPID は環境変数から読み込み、ハードコーディングしないことを推奨します。本番・開発環境で分けやすくなります:

const ORIGIN = process.env.APP_ORIGIN!; // 'https://myapp.com'
const RP_ID = new URL(ORIGIN).hostname;  // 'myapp.com'

4. パスキーが利用できない場合のフォールバック

2026年現在、主要ブラウザはすべてWebAuthnに対応していますが、一部の古い環境や特殊なブラウザでは動作しないケースがあります。PublicKeyCredential の有無でチェックし、代替認証(メールOTP等)を用意しましょう:

// パスキーのブラウザ対応チェック
const isPasskeySupported = () =>
  window.PublicKeyCredential !== undefined &&
  typeof window.PublicKeyCredential === 'function';

UX設計:ユーザーに分かりやすいパスキー体験

技術的な実装が完成しても、ユーザーが「パスキーって何?」と戸惑ってしまっては意味がありません。

登録時の言葉の選び方

「WebAuthn認証情報を登録する」ではなく、ユーザーに伝わる言葉を使いましょう:

  • ✅「顔認証・指紋でログインを設定する」
  • ✅「このデバイスで素早くログインできるようにする」
  • ✅「パスワード不要でログインできます」
  • ❌「パスキーを登録する」(知らないユーザーには意味不明)

エラーハンドリングとメッセージ

主なエラーパターンと対処:

  • NotAllowedError:ユーザーがキャンセル or タイムアウト → 「キャンセルされました。再度お試しください」
  • InvalidStateError:すでに登録済みのデバイス → 「このデバイスはすでに登録されています」
  • NotSupportedError:デバイスが非対応 → 代替認証へ誘導

データベース設計:パスキーをどう保存するか

最低限必要なカラムは以下です(PostgreSQL / SQLiteの例):

CREATE TABLE passkeys (
  id            TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id       TEXT NOT NULL,
  -- WebAuthnの認証情報
  credential_id TEXT NOT NULL UNIQUE,
  public_key    BYTEA NOT NULL,      -- 公開鍵(バイナリ)
  counter       INTEGER NOT NULL DEFAULT 0,
  -- デバイス情報(UI表示用)
  device_type   TEXT,               -- 'singleDevice' or 'multiDevice'
  backed_up     BOOLEAN DEFAULT false, -- iCloud等にバックアップされているか
  -- 管理用
  name          TEXT,               -- ユーザーが設定する名前(「iPhone 15」など)
  created_at    TIMESTAMPTZ DEFAULT now(),
  last_used_at  TIMESTAMPTZ,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

backed_up フラグは重要で、true(multiDevice passkey)はiCloud/Googleアカウントで同期されるため「デバイスを失っても復元できる」、false(singleDevice)は特定のデバイスにのみ存在します。セキュリティ要件に応じて使い分けましょう。

既存のパスワード認証と共存させる方法

既存サービスにパスキーを追加する場合、すべてのユーザーが即座に移行するわけではありません。段階的な移行戦略を取りましょう:

Phase 1:任意のパスキー登録(今すぐできる)

設定画面に「パスキーを追加」ボタンを置き、登録したユーザーはパスキーでもパスワードでもログインできる状態にする。

Phase 2:ログイン時にパスキーを提案

パスワードでログインしたユーザーに対し、「次回からもっと簡単にログインできます」とパスキー登録を促すバナーを表示。

Phase 3:パスワードレス化(将来)

十分なユーザーがパスキーに移行したら、パスワードログインを廃止または非推奨に。

Webサイトの監視にPagePulseを使っている場合、認証エラーやAPIの変化をアラートで検知しながら安全に移行を進めることができます。

🔐 パスキー実装チェックリスト

  • チャレンジの一回限り使用(使用後即削除)
  • カウンター更新と逆転チェック
  • HTTPS必須(rpID = ドメイン名)
  • フォールバック認証の用意
  • エラーメッセージのユーザーフレンドリー化
  • パスキーの名前・管理UI(ユーザーが削除できること)
  • チャレンジのTTL設定(推奨:5分)

よくある質問(FAQ)

Q. localhostで開発・テストできますか?

A. できます。WebAuthnは localhost をHTTPSと同等に扱うため、rpID: 'localhost'expectedOrigin: 'http://localhost:3000' で動作します。

Q. パスキーを失くしたらどうなりますか?

A. iCloud Keychain / Googleパスワードマネージャーで同期されているmultiDevice passkeyは、デバイス紛失後もアカウント復元で戻ってきます。singleDeviceの場合はフォールバック認証(メールOTP等)が必要です。サービス側で複数パスキーの登録を推奨するUIにするのが良い設計です。

Q. biometricsを持たないデバイスは?

A. PINコードでも認証できます(Windows Hello、Androidのスクリーンロックなど)。生体認証が必須なわけではなく「デバイスのローカル認証」が行われれば動作します。

Q. SimpleWebAuthnのCloud Workers対応は?

A. v9以降はWinterCG準拠のランタイム(Cloudflare Workers、Deno等)に対応しています。import { ... } from '@simplewebauthn/server' がそのまま使えます。

まとめ:パスキー導入は今が最良のタイミング

2026年現在、パスキー対応ブラウザのカバレッジはほぼ100%に達し、iOSとAndroidのサポートも成熟しています。「パスワードレス化は大企業だけがやること」という時代は終わりました。

SimpleWebAuthnを使えば、本記事のコードベースで数時間〜1日程度で個人開発サービスにパスキーを追加できます。ユーザー体験は劇的に向上し、パスワード漏洩リスクもゼロになります。

認証実装後は、サービス全体の健全性をモニタリングしましょう。PagePulseのようなWebサイト監視ツールで認証エンドポイントの稼働状況を確認したり、StatusCraftでサービスのステータスページを公開したりすることで、ユーザーへの信頼性をさらに高めることができます。

🛠️ TechTools Labのおすすめツール

個人開発をもっと効率化したいなら、これらのツールもチェックしてみてください。