API Security 2025完全ガイド - OWASP API Top 10の実践的対策
OWASP API Security Top 10 2023年版の最新リストに基づく、APIセキュリティの脅威と実践的な対策方法を徹底解説。95%の組織が経験するAPI攻撃への対策を、本番環境で使える具体的な実装例と共に紹介します。
WebAuthnとPasskeysを使用したパスワードレス認証の完全実装ガイド。セキュリティベストプラクティス、実装手順、データベース設計、UI/UX考慮事項まで、本番環境で使える実践的なノウハウを徹底解説します。
2025 年、パスワードの時代は終わりを迎えようとしています。Amazon では 1 億 7500 万人以上のユーザーがパスキーを使用し、ログイン速度が 6 倍に向上。Microsoft では 987%のパスキー利用増加を記録し、パスワードベースのログインが 10%減少しました。
本記事では、WebAuthn/Passkeys の実装方法を解説します。セキュリティベストプラクティスや実際の企業での成功事例も紹介し、パスワードレス認証を本番環境で実現するための完全ガイドを提供します。
チャートを読み込み中...
項目 | WebAuthn | Passkeys |
---|---|---|
定義 | W3C標準の認証API仕様 | WebAuthnを使用した認証資格情報 |
役割 | 技術仕様・プロトコル | ユーザー向けのブランド・実装 |
範囲 | ブラウザAPIの定義 | クロスプラットフォーム同期を含む |
同期 | 仕様では定義されない | iCloud/Google等で自動同期 |
ユーザー視点 | 技術的な概念 | パスワードの代替手段 |
パスキーにより、お客様は従来の 6 倍の速さでサインインできるようになりました。 1 億 7500 万人以上のお客様がすでにパスキーを利用しています。
パスキーの使用は 987%増加し、パスワードベースのログインは 10%減少しました。 パスキーでのサインインは、従来のパスワードより 3 倍高速です。
✅ フィッシング耐性: 共有秘密に依存しないため、フィッシング攻撃が不可能 ✅ ユーザー体験向上: ログイン時間が最大 6 倍高速化 ✅ サポートコスト削減: Branch Insurance では認証関連のチケットが 50%減少 ✅ セキュリティ向上: データベースが侵害されても公開鍵は悪用不可能 ✅ コンプライアンス: FIDO2 認証による規制要件への対応
チャートを読み込み中...
// 推奨技術スタック
const techStack = {
frontend: {
library: '@simplewebauthn/browser',
framework: 'React/Vue/Angular',
bundler: 'Vite/Webpack'
},
backend: {
node: '@simplewebauthn/server',
python: 'webauthn',
go: 'go-webauthn/webauthn',
php: 'lbuchs/WebAuthn'
},
database: {
primary: 'PostgreSQL/MySQL',
cache: 'Redis',
schema: 'JSON columns for credential data'
}
}
# SimpleWebAuthnのインストール
npm install @simplewebauthn/browser
npm install --save-dev @simplewebauthn/types
// PasskeyRegistration.tsx
import { startRegistration } from '@simplewebauthn/browser';
import { useState } from 'react';
export function PasskeyRegistration() {
const [isRegistering, setIsRegistering] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleRegister = async () => {
try {
setIsRegistering(true);
setError(null);
// 1. サーバーから登録オプションを取得
const response = await fetch('/api/webauthn/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
const options = await response.json();
// 2. ブラウザのWebAuthn APIを呼び出し
const credential = await startRegistration(options);
// 3. 登録データをサーバーに送信
const verifyResponse = await fetch('/api/webauthn/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential),
credentials: 'include'
});
const result = await verifyResponse.json();
if (result.verified) {
alert('パスキーの登録が完了しました!');
} else {
throw new Error('登録の検証に失敗しました');
}
} catch (err) {
if (err.name === 'NotAllowedError') {
setError('ユーザーがキャンセルしました');
} else if (err.name === 'NotSupportedError') {
setError('このブラウザはWebAuthnをサポートしていません');
} else {
setError(`エラー: ${err.message}`);
}
} finally {
setIsRegistering(false);
}
};
return (
<div className="passkey-registration">
<h2>パスキーの設定</h2>
<p>パスキーを使用すると、パスワードなしで安全にログインできます。</p>
<button
onClick={handleRegister}
disabled={isRegistering}
className="register-button"
>
{isRegistering ? '登録中...' : 'パスキーを登録'}
</button>
{error && (
<div className="error-message">{error}</div>
)}
<div className="supported-devices">
<h3>対応デバイス</h3>
<ul>
<li>✅ Touch ID / Face ID (Mac/iPhone)</li>
<li>✅ Windows Hello</li>
<li>✅ Android指紋認証</li>
<li>✅ セキュリティキー(YubiKey等)</li>
</ul>
</div>
</div>
);
}
<!-- PasskeyRegistration.vue -->
<template>
<div class="passkey-registration">
<h2>パスキーの設定</h2>
<p>パスキーを使用すると、パスワードなしで安全にログインできます。</p>
<button
@click="handleRegister"
:disabled="isRegistering"
class="register-button"
>
{{ isRegistering ? '登録中...' : 'パスキーを登録' }}
</button>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div class="supported-devices">
<h3>対応デバイス</h3>
<ul>
<li>✅ Touch ID / Face ID (Mac/iPhone)</li>
<li>✅ Windows Hello</li>
<li>✅ Android指紋認証</li>
<li>✅ セキュリティキー(YubiKey等)</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { startRegistration } from '@simplewebauthn/browser'
const isRegistering = ref(false)
const error = ref<string | null>(null)
const handleRegister = async () => {
try {
isRegistering.value = true
error.value = null
// 1. サーバーから登録オプションを取得
const response = await fetch('/api/webauthn/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
})
const options = await response.json()
// 2. ブラウザのWebAuthn APIを呼び出し
const credential = await startRegistration(options)
// 3. 登録データをサーバーに送信
const verifyResponse = await fetch('/api/webauthn/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential),
credentials: 'include'
})
const result = await verifyResponse.json()
if (result.verified) {
alert('パスキーの登録が完了しました!')
} else {
throw new Error('登録の検証に失敗しました')
}
} catch (err: any) {
if (err.name === 'NotAllowedError') {
error.value = 'ユーザーがキャンセルしました'
} else if (err.name === 'NotSupportedError') {
error.value = 'このブラウザはWebAuthnをサポートしていません'
} else {
error.value = `エラー: ${err.message}`
}
} finally {
isRegistering.value = false
}
}
</script>
// passkey-registration.js
import { startRegistration } from '@simplewebauthn/browser';
class PasskeyRegistration {
constructor(containerElement) {
this.container = containerElement;
this.isRegistering = false;
this.init();
}
init() {
this.render();
this.attachEventListeners();
}
render() {
this.container.innerHTML = `
<div class="passkey-registration">
<h2>パスキーの設定</h2>
<p>パスキーを使用すると、パスワードなしで安全にログインできます。</p>
<button id="register-btn" class="register-button">
パスキーを登録
</button>
<div id="error-message" class="error-message" style="display: none;"></div>
<div class="supported-devices">
<h3>対応デバイス</h3>
<ul>
<li>✅ Touch ID / Face ID (Mac/iPhone)</li>
<li>✅ Windows Hello</li>
<li>✅ Android指紋認証</li>
<li>✅ セキュリティキー(YubiKey等)</li>
</ul>
</div>
</div>
`;
}
attachEventListeners() {
const registerBtn = this.container.querySelector('#register-btn');
registerBtn.addEventListener('click', () => this.handleRegister());
}
async handleRegister() {
const registerBtn = this.container.querySelector('#register-btn');
const errorDiv = this.container.querySelector('#error-message');
try {
this.isRegistering = true;
registerBtn.disabled = true;
registerBtn.textContent = '登録中...';
errorDiv.style.display = 'none';
// 1. サーバーから登録オプションを取得
const response = await fetch('/api/webauthn/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
const options = await response.json();
// 2. ブラウザのWebAuthn APIを呼び出し
const credential = await startRegistration(options);
// 3. 登録データをサーバーに送信
const verifyResponse = await fetch('/api/webauthn/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential),
credentials: 'include'
});
const result = await verifyResponse.json();
if (result.verified) {
alert('パスキーの登録が完了しました!');
} else {
throw new Error('登録の検証に失敗しました');
}
} catch (err) {
errorDiv.style.display = 'block';
if (err.name === 'NotAllowedError') {
errorDiv.textContent = 'ユーザーがキャンセルしました';
} else if (err.name === 'NotSupportedError') {
errorDiv.textContent = 'このブラウザはWebAuthnをサポートしていません';
} else {
errorDiv.textContent = `エラー: ${err.message}`;
}
} finally {
this.isRegistering = false;
registerBtn.disabled = false;
registerBtn.textContent = 'パスキーを登録';
}
}
}
// 使用例
const container = document.getElementById('passkey-container');
new PasskeyRegistration(container);
// PasskeyLogin.tsx
import { startAuthentication } from '@simplewebauthn/browser';
export function PasskeyLogin() {
const handleLogin = async () => {
try {
// 1. 認証オプションを取得
const optionsResponse = await fetch('/api/webauthn/authenticate/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const options = await optionsResponse.json();
// 2. 認証を実行
const credential = await startAuthentication(options);
// 3. サーバーで検証
const verifyResponse = await fetch('/api/webauthn/authenticate/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential)
});
const result = await verifyResponse.json();
if (result.verified) {
// ログイン成功
window.location.href = '/dashboard';
}
} catch (error) {
console.error('認証エラー:', error);
}
};
return (
<button onClick={handleLogin} className="login-button">
パスキーでログイン
</button>
);
}
-- ユーザーテーブル
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- WebAuthn資格情報テーブル
CREATE TABLE webauthn_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id TEXT UNIQUE NOT NULL,
public_key JSONB NOT NULL,
counter BIGINT NOT NULL DEFAULT 0,
aaguid VARCHAR(36),
transports JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP,
device_name VARCHAR(255),
backup_eligible BOOLEAN DEFAULT FALSE,
backup_state BOOLEAN DEFAULT FALSE,
INDEX idx_user_credentials (user_id),
INDEX idx_credential_id (credential_id)
);
-- 認証チャレンジテーブル(一時的なチャレンジ保存)
CREATE TABLE webauthn_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
challenge TEXT NOT NULL,
type VARCHAR(20) NOT NULL, -- 'registration' or 'authentication'
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
INDEX idx_user_challenges (user_id),
INDEX idx_expires (expires_at)
);
-- 監査ログテーブル
CREATE TABLE authentication_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
credential_id TEXT,
event_type VARCHAR(50) NOT NULL,
success BOOLEAN NOT NULL,
ip_address INET,
user_agent TEXT,
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_logs (user_id),
INDEX idx_created_at (created_at)
);
// webauthn.controller.ts
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
// 登録オプションの生成
export async function generateRegisterOptions(req: Request, res: Response) {
const user = req.user;
// 既存の資格情報を取得
const existingCredentials = await db.query(
'SELECT credential_id, transports FROM webauthn_credentials WHERE user_id = $1',
[user.id]
);
const options = await generateRegistrationOptions({
rpName: 'My Awesome App',
rpID: 'example.com',
userID: user.id,
userName: user.username,
userDisplayName: user.email,
attestationType: 'none',
excludeCredentials: existingCredentials.rows.map(cred => ({
id: isoBase64URL.toBuffer(cred.credential_id),
transports: cred.transports || []
})),
authenticatorSelection: {
authenticatorAttachment: 'platform',
requireResidentKey: true,
residentKey: 'required',
userVerification: 'required'
}
});
// チャレンジを保存
await db.query(
`INSERT INTO webauthn_challenges (user_id, challenge, type, expires_at)
VALUES ($1, $2, 'registration', NOW() + INTERVAL '5 minutes')`,
[user.id, options.challenge]
);
res.json(options);
}
// 登録の検証
export async function verifyRegister(req: Request, res: Response) {
const user = req.user;
const credential = req.body;
// チャレンジを取得
const challengeResult = await db.query(
`SELECT challenge FROM webauthn_challenges
WHERE user_id = $1 AND type = 'registration'
AND expires_at > NOW()
ORDER BY created_at DESC LIMIT 1`,
[user.id]
);
if (!challengeResult.rows.length) {
return res.status(400).json({ error: 'チャレンジが無効です' });
}
const expectedChallenge = challengeResult.rows[0].challenge;
try {
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge,
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
requireUserVerification: true
});
if (verification.verified) {
const { credentialPublicKey, credentialID, counter, credentialBackedUp } =
verification.registrationInfo!;
// 資格情報を保存
await db.query(
`INSERT INTO webauthn_credentials
(user_id, credential_id, public_key, counter, aaguid, transports,
backup_eligible, backup_state, device_name)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
user.id,
isoBase64URL.fromBuffer(credentialID),
JSON.stringify(credentialPublicKey),
counter,
verification.registrationInfo!.aaguid,
JSON.stringify(credential.response.transports || []),
credentialBackedUp,
credentialBackedUp,
req.body.deviceName || 'Unknown Device'
]
);
// 監査ログ
await logAuthEvent(user.id, 'passkey_registered', true);
res.json({ verified: true });
} else {
throw new Error('検証に失敗しました');
}
} catch (error) {
await logAuthEvent(user.id, 'passkey_registration_failed', false, error.message);
res.status(400).json({ error: error.message });
}
}
// チャレンジの安全な管理
class ChallengeManager {
private readonly EXPIRY_MINUTES = 5;
async createChallenge(userId: string, type: 'registration' | 'authentication') {
const challenge = crypto.randomBytes(32).toString('base64url');
await db.query(
`INSERT INTO webauthn_challenges (user_id, challenge, type, expires_at)
VALUES ($1, $2, $3, NOW() + INTERVAL '${this.EXPIRY_MINUTES} minutes')`,
[userId, challenge, type]
);
// 古いチャレンジを削除
await this.cleanupExpiredChallenges();
return challenge;
}
async verifyAndConsumeChallenge(
userId: string,
challenge: string,
type: string
): Promise<boolean> {
const result = await db.query(
`DELETE FROM webauthn_challenges
WHERE user_id = $1 AND challenge = $2 AND type = $3
AND expires_at > NOW()
RETURNING id`,
[userId, challenge, type]
);
return result.rowCount > 0;
}
private async cleanupExpiredChallenges() {
await db.query('DELETE FROM webauthn_challenges WHERE expires_at < NOW()');
}
}
// リプレイ攻撃の防止
async function verifyCounter(
credentialId: string,
newCounter: number
): Promise<boolean> {
const result = await db.query(
'SELECT counter FROM webauthn_credentials WHERE credential_id = $1',
[credentialId]
);
const storedCounter = result.rows[0].counter;
if (newCounter <= storedCounter) {
// カウンターが後退または同じ = リプレイ攻撃の可能性
await logSecurityEvent('counter_rollback_detected', { credentialId });
return false;
}
// カウンターを更新
await db.query(
'UPDATE webauthn_credentials SET counter = $1 WHERE credential_id = $2',
[newCounter, credentialId]
);
return true;
}
// 包括的な監査ログ
interface AuditLog {
userId: string;
event: AuthEvent;
success: boolean;
metadata?: Record<string, any>;
}
enum AuthEvent {
PASSKEY_REGISTERED = 'passkey_registered',
PASSKEY_REMOVED = 'passkey_removed',
LOGIN_SUCCESS = 'login_success',
LOGIN_FAILED = 'login_failed',
COUNTER_MISMATCH = 'counter_mismatch',
INVALID_ORIGIN = 'invalid_origin'
}
async function auditLog(log: AuditLog) {
const { userId, event, success, metadata } = log;
await db.query(
`INSERT INTO authentication_logs
(user_id, event_type, success, ip_address, user_agent, metadata)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
userId,
event,
success,
req.ip,
req.get('user-agent'),
JSON.stringify(metadata)
]
);
// 異常なパターンを検出
if (!success) {
await checkForAnomalies(userId);
}
}
// PasskeyOnboarding.tsx
function PasskeyOnboarding() {
const [step, setStep] = useState(0);
const steps = [
{
title: "パスキーとは?",
content: "パスワードの代わりに顔認証や指紋認証でログインできる新しい方法です",
icon: "🔐"
},
{
title: "なぜ安全?",
content: "パスキーはデバイスに保存され、フィッシング攻撃から完全に保護されます",
icon: "🛡️"
},
{
title: "簡単セットアップ",
content: "わずか30秒で設定完了。今すぐ始めましょう!",
icon: "⚡"
}
];
return (
<div className="onboarding-modal">
<div className="step-indicator">
{steps.map((_, i) => (
<div
key={i}
className={`dot ${i === step ? 'active' : ''}`}
/>
))}
</div>
<div className="step-content">
<div className="icon">{steps[step].icon}</div>
<h3>{steps[step].title}</h3>
<p>{steps[step].content}</p>
</div>
<div className="actions">
{step > 0 && (
<button onClick={() => setStep(step - 1)}>戻る</button>
)}
{step < steps.length - 1 ? (
<button onClick={() => setStep(step + 1)}>次へ</button>
) : (
<button onClick={startPasskeySetup}>設定を開始</button>
)}
</div>
</div>
);
}
指標 | 目標値 | 測定方法 |
---|---|---|
パスキー採用率 | 90日で25% | 登録ユーザー数/全ユーザー数 |
認証成功率 | 99%以上 | 成功ログイン/全ログイン試行 |
平均認証時間 | 3秒以内 | 認証開始から完了までの時間 |
サポートチケット削減 | 50%削減 | パスワード関連チケット数 |
フォールバック率 | 5%以下 | パスワード使用/全認証 |
// 監視用のメトリクス収集
class PasskeyMetrics {
async collectMetrics(): Promise<DashboardData> {
const [adoption, performance, errors] = await Promise.all([
this.getAdoptionMetrics(),
this.getPerformanceMetrics(),
this.getErrorMetrics()
]);
return {
adoption: {
totalUsers: adoption.total,
passkeyUsers: adoption.withPasskey,
adoptionRate: (adoption.withPasskey / adoption.total) * 100,
trend: adoption.trend
},
performance: {
avgLoginTime: performance.avgTime,
p95LoginTime: performance.p95Time,
successRate: performance.successRate
},
errors: {
byType: errors.byType,
byBrowser: errors.byBrowser,
trending: errors.trending
}
};
}
}
1. “SecurityError: The operation is insecure”
2. “NotAllowedError: The request is not allowed”
3. “InvalidStateError: The authenticator is already registered”
4. ブラウザ互換性の問題
if (!window.PublicKeyCredential) {
// WebAuthn非対応ブラウザ
showFallbackLogin();
}
クロスプラットフォーム同期の統一仕様
自動入力UIでのパスキー選択
Fortune 500の50%がパスワードレス化
金融・医療でパスキー必須化
WebAuthn/Passkeys の実装は、セキュリティとユーザー体験の両方を劇的に改善する技術です。実装の複雑さはありますが、適切なライブラリとベストプラクティスに従うことで、確実に導入できます。
重要なのは、段階的な移行戦略です。既存のパスワード認証と併用しながら、ユーザーを徐々にパスキーへ誘導することで、スムーズな移行が可能になります。