パスワード認証の問題点は長年にわたり議論されてきました。フィッシング攻撃、パスワードリスト攻撃、ユーザーによる使い回し、そして運用コストの肥大化。FIDO2/WebAuthnの標準化とApple・Google・Microsoftによるパスキーのサポートにより、パスワードレス認証が現実的な選択肢となりました。
パスキーは公開鍵暗号方式を基盤としており、秘密鍵はユーザーのデバイスに安全に保管されます。サーバー側には公開鍵しか保存されないため、サーバー侵害時の影響が最小限に抑えられます。さらに、パスキーはフィッシング耐性を持っており、正規のドメインでのみ認証が成立する仕組みだ。本記事では、Webアプリケーションにパスキー認証を実装する具体的な手順を解説します。
パスキーの登録は、サーバーからチャレンジを取得し、ブラウザのWebAuthn APIを通じて新しい鍵ペアを生成するプロセスだ。ユーザーは生体認証やPINで本人確認を行い、生成された公開鍵がサーバーに送信されます。
// サーバー側:登録オプションの生成(Node.js + @simplewebauthn/server)
import {
generateRegistrationOptions,
verifyRegistrationResponse
} from '@simplewebauthn/server';
const rpName = 'My Application';
const rpID = 'example.com';
const origin = 'https://example.com';
async function getRegistrationOptions(user) {
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: user.id,
userName: user.email,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
supportedAlgorithmIDs: [-7, -257],
});
// チャレンジをセッションに保存
await saveChallenge(user.id, options.challenge);
return options;
}
ブラウザ側ではnavigator.credentials.create()を使って認証器と対話します。SimpleWebAuthnのクライアントライブラリを使用すると、ブラウザ互換性の差異を吸収できます。
// クライアント側:パスキーの登録
import { startRegistration } from '@simplewebauthn/browser';
async function registerPasskey() {
try {
const optionsResponse = await fetch('/api/auth/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const options = await optionsResponse.json();
const registration = await startRegistration(options);
const verifyResponse = await fetch('/api/auth/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(registration),
});
const result = await verifyResponse.json();
if (result.verified) {
alert('パスキーの登録が完了しました');
}
} catch (error) {
if (error.name === 'NotAllowedError') {
console.log('ユーザーが認証をキャンセルしました');
}
}
}
// サーバー側:認証オプションの生成
async function getAuthenticationOptions(userEmail) {
const user = await findUserByEmail(userEmail);
const userPasskeys = await getUserPasskeys(user.id);
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: userPasskeys.map(passkey => ({
id: passkey.credentialID,
type: 'public-key',
transports: passkey.transports,
})),
userVerification: 'preferred',
});
await saveChallenge(user.id, options.challenge);
return options;
}
// サーバー側:認証レスポンスの検証
async function verifyAuth(userId, response) {
const expectedChallenge = await getChallenge(userId);
const passkey = await getPasskeyById(response.id);
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
credentialPublicKey: passkey.publicKey,
credentialID: passkey.credentialID,
counter: passkey.counter,
},
});
if (verification.verified) {
await updatePasskeyCounter(passkey.id, verification.authenticationInfo.newCounter);
}
return verification.verified;
}
既存のパスワード認証からパスキーへの移行は段階的に行うべきだ。筆者が推奨する移行ステップは以下の通りです。
パスキーに対応していないブラウザや環境も考慮する必要があります。WebAuthnの対応状況を確認し、非対応の場合は従来の認証方式にフォールバックする設計が重要です。
// パスキー対応チェック
async function isPasskeySupported() {
if (!window.PublicKeyCredential) return false;
try {
const available = await PublicKeyCredential
.isUserVerifyingPlatformAuthenticatorAvailable();
return available;
} catch {
return false;
}
}
パスキー認証は、セキュリティとユーザー体験の両方を向上させる技術だ。実装にはWebAuthn APIの理解が必要だが、SimpleWebAuthnのようなライブラリを活用すれば開発工数を大幅に削減できます。まずは既存のパスワード認証と併用する形で導入を始め、ユーザーの反応を見ながら段階的にパスワードレスへ移行していくことを推奨します。