webauthn について
Web アプリに認証機能を実装する開発者向けの、WebAuthn とパスキーの実践ガイドです。SimpleWebAuthn による登録・ログイン、ブラウザーからの呼び出し、本番環境での注意点を扱います。
主な機能
- 01登録オプションの生成と検証
- 02認証オプションの生成と検証
- 03ブラウザーでの登録・ログイン例
- 04チャレンジとカウンターの保護策
- 05認証情報管理のチェックリスト
活用シーン
- 01Web アプリへのパスキー登録追加
- 02サーバー検証を伴うパスキーログインの実装
- 03WebAuthn フローのリプレイリスク確認
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ claude-dev-suite/ claude-dev-suite --skill webauthnインストール後、Claude Code で/webauthnを入力して読み込まれたか確認します。
詳細
WebAuthn / パスキー
サーバーのセットアップ(SimpleWebAuthn)
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
const rpName = 'My App';
const rpID = 'myapp.com';
const origin = 'https://myapp.com';
登録フロー
サーバー:オプションの生成
app.post('/api/auth/register/options', auth, async (req, res) => {
const user = req.user;
const existingCredentials = await db.credential.findMany({
where: { userId: user.id },
});
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: user.email,
userDisplayName: user.name,
excludeCredentials: existingCredentials.map((c) => ({
id: c.credentialId,
transports: c.transports,
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
// Store challenge for verification
await db.challenge.upsert({
where: { userId: user.id },
create: { userId: user.id, challenge: options.challenge },
update: { challenge: options.challenge },
});
res.json(options);
});
サーバー:登録の検証
app.post('/api/auth/register/verify', auth, async (req, res) => {
const user = req.user;
const { challenge } = await db.challenge.findUnique({ where: { userId: user.id } });
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: challenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
if (verification.verified && verification.registrationInfo) {
const { credential } = verification.registrationInfo;
await db.credential.create({
data: {
userId: user.id,
credentialId: Buffer.from(credential.id),
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: req.body.response.transports,
},
});
}
res.json({ verified: verification.verified });
});
認証フロー
サーバー:オプションの生成
app.post('/api/auth/login/options', async (req, res) => {
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'preferred',
// For passkeys (discoverable credentials), omit allowCredentials
});
// Store challenge in session
req.session.challenge = options.challenge;
res.json(options);
});
サーバー:認証の検証
app.post('/api/auth/login/verify', async (req, res) => {
const credential = await db.credential.findUnique({
where: { credentialId: Buffer.from(req.body.id, 'base64url') },
});
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: req.session.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: credential.credentialId,
publicKey: credential.publicKey,
counter: credential.counter,
},
});
if (verification.verified) {
// Update counter (replay protection)
await db.credential.update({
where: { id: credential.id },
data: { counter: verification.authenticationInfo.newCounter },
});
const token = generateJWT(credential.userId);
res.json({ verified: true, token });
}
});
ブラウザクライアント
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
// Register
async function registerPasskey() {
const optionsRes = await fetch('/api/auth/register/options', { method: 'POST' });
const options = await optionsRes.json();
const credential = await startRegistration({ optionsJSON: options });
const verifyRes = await fetch('/api/auth/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential),
});
return (await verifyRes.json()).verified;
}
// Login
async function loginWithPasskey() {
const optionsRes = await fetch('/api/auth/login/options', { method: 'POST' });
const options = await optionsRes.json();
const credential = await startAuthentication({ optionsJSON: options });
const verifyRes = await fetch('/api/auth/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential),
});
return (await verifyRes.json()).token;
}
アンチパターン
| アンチパターン | 修正方法 |
|---|---|
| カウンターを保存しない | リプレイ攻撃を防ぐため、カウンターを保存して検証する |
| チャレンジを再利用する | セレモニーごとに新しいチャレンジを生成する |
| フォールバック認証方法がない | フォールバックとしてパスワード/マジックリンクを提供する |
| rpID をハードコードする | 環境変数から設定し、ドメインと一致させる |
| exclude に transports がない | 再登録時の UX を向上させるため、transports を保存する |
本番環境チェックリスト
- HTTPS が必須(WebAuthn ではセキュアコンテキストが必須です)
- セレモニーごとにチャレンジを生成し、有効期限を設定する
- カウンターを保存して検証する
- ユーザーごとに複数の認証情報をサポートする
- 認証情報の管理 UI(表示、取り消し)
- フォールバック認証方法を利用できる
コメント
Markdown 対応