「パスワードが漏れた」「ユーザーがパスワードを忘れてサポートが大変」——認証まわりの悩みは個人開発者にとって永遠のテーマです。2026年現在、その答えのひとつとして急速に普及しているのが Passkeys(パスキー) です。
パスキーはGoogle・Apple・Microsoftが標準として推進するパスワードレス認証技術で、フィッシング詐欺に完全耐性があり、ユーザー体験も格段に良いという特性があります。iPhoneやAndroidでは「顔認証でログイン」がそのまま使えます。
この記事では、WebAuthn APIの仕組みからNode.js/Cloudflare Workersでの実際の実装コードまで、個人開発者がパスキー認証を自分のサービスに組み込む方法を徹底解説します。
Passkeys(パスキー)とは何か:10分で理解する基礎知識
パスワード認証の限界
まず、なぜパスキーが生まれたのかを理解しましょう。従来のパスワード認証には構造的な問題があります:
- フィッシング攻撃に無力 — ユーザーがニセサイトにパスワードを入力してしまうと、どんな対策も無意味になる
- リスト型攻撃 — 他サービスから漏れたID/パスワードがそのまま使われてしまう(パスワード使い回し問題)
- 管理コスト — 強力なパスワードを何十も覚えることはユーザーにとって現実的でない
- サーバー側のリスク — ハッシュ化しても、パスワードをサーバーに保存する時点でリスクが生じる
SMS認証(2FA)はフィッシング対策として広まりましたが、SIMスワップ攻撃や、そもそもSMSを受け取れない海外ユーザー問題など、課題が残ります。
パスキーが解決すること
パスキーは 公開鍵暗号方式 を使った認証仕組みです。概念をシンプルに説明すると:
- ユーザーのデバイス(スマホ・PC)に秘密鍵が生成され、安全なチップ(Secure Enclave/TPM)に保存される
- サーバーには対になる公開鍵だけが保存される
- ログイン時、サーバーが「チャレンジ」を送り、ユーザーのデバイスが秘密鍵でサインして返す
- サーバーは公開鍵でサインを検証するだけ — パスワードの送受信は一切ない
この仕組みにより:
- 秘密鍵はデバイスの外に出ないため、フィッシングサイトに入力する「もの」が存在しない
- サーバーが侵害されても公開鍵しか漏れない
- 生体認証(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ステップで完結します。
登録フロー(パスキーの作成)
- クライアント → サーバー:「パスキーを登録したい」とリクエスト
- サーバー → クライアント:チャレンジ(ランダムなバイト列)と登録オプションを返す
- クライアント(ブラウザ):
navigator.credentials.create()を呼び出し、ユーザーが生体認証を行う。デバイスが秘密鍵と公開鍵のペアを生成 - クライアント → サーバー:公開鍵・認証器情報・チャレンジへの署名を送信。サーバーが検証し、公開鍵を保存
認証フロー(パスキーでログイン)
- クライアント → サーバー:「ログインしたい」とリクエスト
- サーバー → クライアント:新しいチャレンジを返す
- クライアント(ブラウザ):
navigator.credentials.get()を呼び出し、ユーザーが生体認証でパスキーを選択。デバイスが秘密鍵でチャレンジにサインする - クライアント → サーバー:署名を送信。サーバーが保存した公開鍵で検証 → ログイン成功
ポイントはサーバーにはチャレンジへの署名(証明)が届くだけで、秘密情報は一切届かない点です。
ライブラリ選定: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.dev や myapp.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の厳密な検証
expectedOrigin と expectedRPID は環境変数から読み込み、ハードコーディングしないことを推奨します。本番・開発環境で分けやすくなります:
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のおすすめツール
個人開発をもっと効率化したいなら、これらのツールもチェックしてみてください。