mcpField
S
secure-auth

secure-auth

github.com/jamditisセキュリティ438195 回インストール4 日前に更新
リポジトリ

secure-auth について

パスワード、セッション、JWT、パスキーを扱う、本番向け認証パターンのガイドです。方式選択とセキュリティ要件を示し、例を使う前に最新の標準や脅威を調べるよう求めています。

主な機能

  1. 01セッションとJWTの選択指針
  2. 02パスキー優先の設計指針
  3. 03パスワードハッシュとポリシー
  4. 04セッションとリフレッシュトークンのパターン
  5. 05JWTクレームの検証要件

活用シーン

  1. 01Webアプリの認証方式の選定
  2. 02パスワード保存とポリシーの実装
  3. 03パスワードログインへのパスキー追加
  4. 04JWTとリフレッシュトークンの検証

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/jamditis/claude-skills-journalism --skill secure-auth

インストール後、Claude Code で/secure-authを入力して読み込まれたか確認します。

同梱ファイルagents/openai.yaml

詳細

セキュアな認証

ステップ 0:現在のセキュリティ状況を調査する(最初に実施)

セキュリティに関する知識の半減期は6〜12か月です。以下のレシピは2026-05-08に最後に検証されましたが、読む頃には古くなっている可能性があります。このスキルのパターンを適用する前に、実装する認証プリミティブ(パスワード、セッション、JWT、OAuth、MFA、パスキー)に絞った調査を広く実施し、ファイル内のスナップショットではなく、最新の権威ある情報に照らしてレシピを解釈してください。

デフォルトで実施し、スキップする場合は記録する

原則として、以下の4つの観点から調査を実施してください。次のすべてを満たす場合に限り、スキップできます。

  • (a) 現在のセッションで、同じプリミティブについて同じスキルを過去4時間以内に実行している。
  • (b) その調査で、実装する認証プリミティブ(パスワード、セッション、JWT、OAuth、MFA、パスキー)に関する緊急の勧告が見つからなかった。
  • (c) 回答に Research skipped because <reason> という1行のメモを記載する。

「知っていると思う」「急いで進めている」「ユーザーが早く終わらせたがっている」「すでによく知っている」は、スキップする理由として認められません。この前置きの目的は、最新状況を確認するまでは、このスキル本文にある既定の内容を将来の自分が信用しないようにすることです。

サブエージェントを4つ、並行して起動する

各サブエージェントは、出典付きの箇条書きを最大300語で返します。4つすべてを1回のメッセージで指示し、並行して実行してください。

観点1:権威ある標準。 NIST、OWASP、IETF(RFCおよびInternet-Draft)、W3C、CISAは、実装する認証プリミティブ(パスワード、セッション、JWT、OAuth、MFA、パスキー)について、過去6〜12か月で何か新しい情報を公開しましたか?仕様の最終化、非推奨化、置き換えとなる仕様、RFCの公開、ドラフトの改訂、NIST SPの更新、OWASPプロジェクトのバージョン更新を調べてください。文書番号と公開日を引用してください。

観点2:現在進行中の悪用。 実装する認証プリミティブ(パスワード、セッション、JWT、OAuth、MFA、パスキー)を標的とした、現在悪用されている脅威は何ですか?CISAの既知の悪用脆弱性(KEV)カタログ(過去6〜12か月で絞り込み)、最近のCVSSスコアが高い、または実環境で悪用されているCVE/GHSAの登録情報、侵害の事後分析やインシデント報告(CSRB、ベンダーの根本原因分析、セキュリティベンダーの調査)を参照してください。最近のKEVへの追加で多く見られるCWEのパターンも示してください。CVE番号と勧告のURLを引用してください。

観点3:ツールとライブラリの状況。 このスキルが推奨するライブラリは、今も最新ですか?関連するパッケージレジストリ(npm/PyPI/RubyGems/crates.io)における最新のメジャーバージョンは何ですか?非推奨になった、別のプロジェクトに置き換えられた、または統合されたものはありますか?セキュアな既定値が変更されたものはありますか?現在のバージョンを、以下で調べてください:registry.npmjs.org、pypi.org、rubygems.org、crates.io、pkg.go.dev。パッケージ名、バージョン、リリース日を引用してください。

観点4:実務者の議論。 過去6か月に、実務者やセキュリティチームの間でどのような話題が取り上げられていますか?OWASP Cheat Sheet Series(最終更新日が重要です)、GitHub Security Labの記事、ベンダーのセキュリティブログ(Cloudflare、Fastly、Snyk、Datadog、Wiz、GitGuardian)、カンファレンスの講演(Black Hat、DEF CON、OWASP Global AppSec、USENIX Security)、SANS ISC、Krebs、最近再公開されたOWASPプロジェクトを参照してください。採用が進んでいるパターンと、アンチパターンとして指摘されているものを示してください。記事のURL、著者、日付を引用してください。

レシピを適用する前に統合する

4 件の調査結果が揃ったら、「実装対象の認証プリミティブ(パスワード、セッション、JWT、OAuth、MFA、パスキー)の、<今日の日付>時点での現状」を1段落でまとめ、次の内容を示します。

  • 現在の規範上の上限(仕様では2026年に何をデフォルトにすべきとしているか)。
  • 実装対象の認証プリミティブ(パスワード、セッション、JWT、OAuth、MFA、パスキー)に固有の、過去6~12か月間のアクティブな脅威を1~2件。
  • ツールの変化(非推奨になったライブラリ、フレームワークの新しいデフォルト、統合または置き換えられたパッケージ)。
  • 最近のチートシートやブログの更新から見える、実務者のコンセンサスの変化。

この統合で、このスキル本文のレシピに変化があると判明した場合(例:2026-05-08以降に仕様が最終化された、ライブラリが非推奨になった、デフォルトが変更されたなど)、その点を回答で明示し、食い違う部分はスキル本文の内容を上書きしてください。統合結果を優先します。スキル本文は出発点であり、絶対的な基準ではありません。

サブエージェントを実行できない場合

実行環境でサブエージェントを利用できない場合は、同じ形式でインラインに4件の対象を絞った検索を順に行います(標準規格のウェブ検索、KEV カタログの参照、パッケージレジストリのバージョン確認、最近のチートシートの差分確認)。同じ1段落の統合結果を作成してください。コストは上がりますが、保護の水準は変わりません。

本番環境に対応した認証パターンです。最も単純な実装ではなく、訴訟を招かない実装を紹介します。

認証アーキテクチャの選択

2020年代初頭の「セッションかJWTか」という枠組みだけが、もはや選択の軸ではありません。2026年における問いは、「パスキーと有効期間の短いバインド済みトークン」か「セッション Cookie」かに近いものです。チュートリアルで使われていた方法ではなく、デプロイ形態に応じて選択してください。

セッション

次の場合はセッションを使用します。

  • サーバーレンダリング型アプリケーション
  • 即時のログアウト/失効が必要
  • 単一ドメイン
  • 正しく実装しやすい方法を優先する
JWT(リフレッシュトークン併用)

次の場合はJWTを使用します。

  • 複数のサービスで認証を検証する必要がある
  • ステートレスな検証が望ましい(失効戦略を用意する)
  • モバイルアプリとAPIを併用する
  • サードパーティーとの連携がある
  • 高価値のAPIで、送信者制約付きトークンが有益である(RFC 9449 に基づく DPoP、RFC 8705 に基づく mTLS)
パスキー優先

次の場合は、主要な認証要素としてパスキー(WebAuthn/FIDO2)を使用します。

  • ユーザーエージェントが WebAuthn に対応している(現在の Chromium、Firefox、Safari、および主要なモバイルブラウザーはすべて対応しています)
  • 移行期間中にパスワードと併用できる(初回ログイン後にパスキーの登録を案内し、ユーザーが設定している間はパスワードをフォールバックとして残す)
  • フィッシング耐性が求められる(NIST AAL3、政府、金融、医療)

パスキー優先の方針は、2026年のコンセンサスを反映しています。WebAuthn L3 は W3C Candidate Recommendation Snapshot に2026-01-13に到達し(https://www.w3.org/TR/webauthn-3/)、CTAP 2.3 は FIDO Alliance Proposed Standard として2026-02-26に承認されました。以下の「パスキー/WebAuthn」セクションを参照してください。

よくある間違い: チュートリアルで使われていたという理由でJWTを使い、それを localStorage に保存する(XSSに脆弱です)一方で、失効戦略を用意しないことです。リフレッシュトークンの再利用検出と、発行者、対象者、スコープ、署名鍵のテナントを含む完全なトークン検証は、2026年には必須です。Storm-0558 の教訓を参照してください。

パスワードの保存

このスキル全体でパスワードハッシュの唯一の情報源となるセクションです。以下のセッションとJWTの例では、ここに記載するデフォルトを前提とします。

デフォルト:argon2id

OWASP Password Storage Cheat Sheet(最終更新日 2026-05-07、https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)には、同等の argon2id プロファイルが5種類記載されています。サーバーのメモリ容量に合うものを選んでください。いずれも同程度の計算コストになるよう調整されています。

  • m=47104 KiB(46 MiB)、t=1、p=1
  • m=19456 KiB(19 MiB)、t=2、p=1
  • m=12288 KiB(12 MiB)、t=3、p=1
  • m=9216 KiB(9 MiB)、t=4、p=1
  • m=7168 KiB(7 MiB)、t=5、p=1
// Node, argon2 package (current 0.44.0; pre-1.0, pin by minor)
const argon2 = require('argon2');

const hash = await argon2.hash(password, {
  type: argon2.argon2id,
  memoryCost: 19456, // KiB, one of the 5 OWASP-equivalent profiles
  timeCost: 2,
  parallelism: 1
});

// Verify
const valid = await argon2.verify(hash, password);
# Python, argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher(
    memory_cost=19456,  # KiB
    time_cost=2,
    parallelism=1,
)

hashed = ph.hash(password)

try:
    ph.verify(hashed, password)
except VerifyMismatchError:
    # invalid password
    pass

RFC 9106(https://datatracker.ietf.org/doc/rfc9106/)では、より負荷の高い「FIRST RECOMMENDED」プロファイル(t=1、p=4、m=2 GiB)が定義されています。これは、そのメモリを利用できるサーバー環境を想定したものです。OWASP の5つのプロファイルは、実用上の最低基準です。

代替案:bcrypt

引き続き許容されます。OWASPはコストを最低10とし、「パフォーマンスが許す限り大きく」としています。現在のコードではコスト12を使用しており問題ありませんが、最低値が引き上げられたことを把握しておいてください。

// Node, bcrypt (current 6.0.0)
const bcrypt = require('bcrypt');

// bcrypt has a 72-byte input limit. Pre-hash with SHA-256
// when accepting longer passphrases, OR reject inputs over 72 bytes.
const crypto = require('crypto');

function safeBcryptInput(password) {
  const bytes = Buffer.byteLength(password, 'utf8');
  if (bytes > 72) {
    // Pre-hash to a fixed 32-byte digest, base64-encoded (44 ASCII bytes)
    return crypto.createHash('sha256').update(password).digest('base64');
  }
  return password;
}

const hashed = await bcrypt.hash(safeBcryptInput(password), 12);
const ok = await bcrypt.compare(safeBcryptInput(password), hashed);
代替案:scrypt

許容されます。OWASPの最低値は N=2^17、r=8、p=1 です。

PBKDF2:FIPS-140が必須の場合のみ

FIPS-140への準拠が厳格な要件である場合に使うアルゴリズムはPBKDF2です。それ以外の場合はargon2idを優先してください。OWASPの最低値は、PBKDF2-HMAC-SHA256で600,000回、またはPBKDF2-HMAC-SHA512で210,000回です。

絶対に避けること
  • パスワードをコマンドラインに組み込むOSのシェル実行プリミティブ。stdinまたはargvで渡してください。
  • 平文での保存。例外はありません。「今だけ」も「リリース前に修正する」も認められません。
  • エラーハンドラーを含む、どのコードパスでも平文パスワードをログに記録すること。
  • 独自のハッシュ方式。独自実装は、侵害の最も一般的な前提条件です。

パスワードポリシー(NIST SP 800-63B-4)

NIST SP 800-63B-4は2025-07-31に最終版となりました(https://csrc.nist.gov/pubs/sp/800/63/b/4/final)。旧版の800-63Bは2025-08-01に廃止されました。以下の値は規範的な要件です。逸脱しないでください。

長さ
  • 単一要素のパスワード:最低15文字。
  • 多要素認証のパスワード(複数要素のうちの1要素):最低8文字。
  • 最大長:少なくとも64文字(検証者は64文字まで許可しなければならず、さらに長い文字列を許可してもかまいません)。
文字構成
  • 文字構成の規則は設けないでください。 NIST 800-63B-4 §5.1.1には、「検証者およびCSPは、その他の文字構成規則を課してはならない」(「大文字/数字/記号を含めること」など)と明記されています。
  • Unicodeを許可してください。 Unicodeの各コードポイントを1文字として数えます。
  • 貼り付けを許可してください。 パスワードマネージャーは貼り付けに依存しています。
定期変更
  • 定期的な変更は不要です。 「90日ごとに変更」を強制しないでください。侵害の証拠がある場合にのみ変更してください。
ブロックリスト(2026年に必須)

検証者は、入力されたパスワード候補を既知の侵害済みパスワードのリストと照合しなければなりません。HaveIBeenPwnedのk-anonymity API(https://haveibeenpwned.com/API/v3#PwnedPasswords)を使用してください。SHA-1ハッシュの先頭5文字を送信し、一致する残りの部分を受け取ります。パスワードそのものを送信することはありません。

// Check candidate password against HIBP
const crypto = require('crypto');

async function isPwned(password) {
  const sha1 = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
  const prefix = sha1.slice(0, 5);
  const suffix = sha1.slice(5);

  const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
    headers: { 'Add-Padding': 'true' }
  });
  if (!res.ok) {
    // Fail closed on availability blip? Up to your threat model.
    // Default: don't block registration if HIBP is down; log and proceed.
    return false;
  }
  const body = await res.text();
  return body.split('\n').some(line => line.startsWith(suffix));
}
フィッシング耐性

NIST 800-63B-4では、AAL3でフィッシング耐性が必須です。パスワードだけではAAL3に到達できません。現在、ほとんどの一般消費者向けアプリが目標とすべきなのは、フィッシング耐性を備えたAAL2です。つまり、TOTPやSMSではなく、WebAuthn(パスキー)またはPIV/CACを使います。

セッションベースの認証

Express.jsの完全な実装
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const argon2 = require('argon2');
const crypto = require('crypto');

const app = express();

// Redis client for session storage
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect();

// Session configuration
app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET, // At least 32 random bytes
  name: 'sessionId', // Don't use default 'connect.sid'
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
    httpOnly: true,  // Not accessible via JavaScript
    sameSite: 'lax', // CSRF protection
    maxAge: 24 * 60 * 60 * 1000 // 24 hours
  }
}));

// Rate limiting for auth endpoints
const loginAttempts = new Map();

function checkRateLimit(ip) {
  const attempts = loginAttempts.get(ip) || { count: 0, resetAt: Date.now() + 900000 };

  if (Date.now() > attempts.resetAt) {
    attempts.count = 0;
    attempts.resetAt = Date.now() + 900000; // 15 minute window
  }

  if (attempts.count >= 5) {
    return false;
  }

  attempts.count++;
  loginAttempts.set(ip, attempts);
  return true;
}

// Argon2id parameters, one of the 5 OWASP-equivalent profiles
const ARGON2_OPTS = {
  type: argon2.argon2id,
  memoryCost: 19456, // KiB
  timeCost: 2,
  parallelism: 1
};

// Precompute a real argon2id hash for the user-not-found timing-attack defense.
// Must be a valid hash string at the same parameters used for storage so that
// argon2.verify does the full work, a malformed string would short-circuit at
// parse time and reintroduce the timing channel.
let DUMMY_VERIFY_HASH = null;
(async () => {
  DUMMY_VERIFY_HASH = await argon2.hash('argon2-timing-defense-init', ARGON2_OPTS);
})();

// Registration
app.post('/auth/register', async (req, res) => {
  const { email, password } = req.body;

  // Validate input
  if (!email || !password) {
    return res.status(400).json({ error: 'Email and password required' });
  }

  // NIST 800-63B-4: 15-char minimum for single-factor passwords
  if (password.length < 15) {
    return res.status(400).json({ error: 'Password must be at least 15 characters' });
  }

  // Reject up-front pwned passwords (HIBP k-anonymity)
  if (await isPwned(password)) {
    return res.status(400).json({ error: 'This password has appeared in a known breach. Choose a different one.' });
  }

  // Check if user exists
  const existingUser = await db.query(
    'SELECT id FROM users WHERE email = $1',
    [email.toLowerCase()]
  );

  if (existingUser.rows.length > 0) {
    // Don't reveal if email exists - use same message/timing
    return res.status(400).json({ error: 'Registration failed' });
  }

  // Hash password
  const hashedPassword = await argon2.hash(password, ARGON2_OPTS);

  // Create user
  const result = await db.query(
    'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id',
    [email.toLowerCase(), hashedPassword]
  );

  // Create session
  req.session.userId = result.rows[0].id;
  req.session.createdAt = Date.now();

  res.json({ success: true });
});

// Login
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;
  const clientIp = req.ip;

  // Rate limiting
  if (!checkRateLimit(clientIp)) {
    return res.status(429).json({ error: 'Too many attempts. Try again later.' });
  }

  // Validate input
  if (!email || !password) {
    return res.status(400).json({ error: 'Email and password required' });
  }

  // Find user
  const result = await db.query(
    'SELECT id, password_hash FROM users WHERE email = $1',
    [email.toLowerCase()]
  );

  if (result.rows.length === 0) {
    // Timing attack prevention: full-cost verify against a real precomputed hash.
    // A malformed hash string would make argon2.verify fail at parse time,
    // that's faster than the valid-user path and leaks "user not found" via
    // timing. DUMMY_VERIFY_HASH is computed once at module init below so
    // this path does the same work as the real verify.
    if (DUMMY_VERIFY_HASH) {
      await argon2.verify(DUMMY_VERIFY_HASH, password).catch(() => false);
    }
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const user = result.rows[0];

  // Verify password
  const isValid = await argon2.verify(user.password_hash, password).catch(() => false);

  if (!isValid) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Regenerate session to prevent fixation.
  // Also regenerate on privilege CHANGE (e.g. role escalation, MFA upgrade), not just login.
  req.session.regenerate((err) => {
    if (err) {
      return res.status(500).json({ error: 'Session error' });
    }

    req.session.userId = user.id;
    req.session.createdAt = Date.now();

    // Clear rate limit on successful login
    loginAttempts.delete(clientIp);

    res.json({ success: true });
  });
});

// Logout
app.post('/auth/logout', (req, res) => {
  req.session.destroy((err) => {
    if (err) {
      return res.status(500).json({ error: 'Logout failed' });
    }
    res.clearCookie('sessionId');
    res.json({ success: true });
  });
});

// Auth middleware
function requireAuth(req, res, next) {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Authentication required' });
  }

  // Optional: Check session age
  const maxAge = 24 * 60 * 60 * 1000; // 24 hours
  if (Date.now() - req.session.createdAt > maxAge) {
    req.session.destroy();
    return res.status(401).json({ error: 'Session expired' });
  }

  next();
}

// Protected route
app.get('/api/profile', requireAuth, async (req, res) => {
  const user = await db.query(
    'SELECT id, email, created_at FROM users WHERE id = $1',
    [req.session.userId]
  );
  res.json(user.rows[0]);
});

JWT認証

完全なトークン検証:Storm-0558の教訓

MicrosoftのStorm-0558インシデント(CSRBのレビュー:https://www.cisa.gov/resources-tools/resources/CSRB-Review-Summer-2023-MEO-Intrusion)では、OWAがコンシューマー向けキーで署名されたトークンをエンタープライズメールボックスで受け入れていたことが明らかになりました。トークン検証ライブラリは、発行者/オーディエンス/スコープ/署名鍵のテナンシーに関するチェックを省略していました。すべてのクレームを毎回検証してください。

  • iss(発行者)は、想定される発行者文字列と一致しなければなりません。
  • aud(オーディエンス)には、サービス識別子が含まれていなければなりません。
  • exp、nbf(有効期限/有効期間開始時刻)は必ず適用し、期限切れまたは未来の日時が設定されたトークンを拒否しなければなりません。
  • scopeには、エンドポイントに必要なスコープが含まれていなければなりません。
  • 署名鍵は発行者のテナンシーに属していなければならず、「JWKSエンドポイントが返す任意の鍵」であってはなりません。
リフレッシュトークンを含む完全な実装
const jwt = require('jsonwebtoken');
const crypto = require('crypto');

// Token configuration
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
const ISSUER = 'https://auth.yourapp.com';
const AUDIENCE = 'https://api.yourapp.com';

// Store refresh tokens (use Redis in production).
// Each entry tracks the family chain so reuse can revoke siblings.
const refreshTokens = new Map();

function generateAccessToken(userId, scopes = []) {
  return jwt.sign(
    { userId, scope: scopes.join(' '), type: 'access' },
    ACCESS_TOKEN_SECRET,
    {
      expiresIn: ACCESS_TOKEN_EXPIRY,
      issuer: ISSUER,
      audience: AUDIENCE
    }
  );
}

function generateRefreshToken(userId, familyId = null) {
  const tokenId = crypto.randomBytes(32).toString('hex');
  const family = familyId || crypto.randomBytes(16).toString('hex');
  const token = jwt.sign(
    { userId, tokenId, familyId: family, type: 'refresh' },
    REFRESH_TOKEN_SECRET,
    {
      expiresIn: REFRESH_TOKEN_EXPIRY,
      issuer: ISSUER,
      audience: AUDIENCE
    }
  );

  refreshTokens.set(tokenId, {
    userId,
    familyId: family,
    createdAt: Date.now(),
    used: false,
    revoked: false
  });

  return { token, familyId: family };
}

// Revoke an entire refresh-token family (used on reuse detection).
function revokeFamily(familyId) {
  for (const [id, entry] of refreshTokens) {
    if (entry.familyId === familyId) {
      entry.revoked = true;
    }
  }
}

// Login - returns both tokens
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;

  // ... validation and password check ...

  const accessToken = generateAccessToken(user.id, ['read', 'write']);
  const { token: refreshToken } = generateRefreshToken(user.id);

  // Set refresh token as httpOnly cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
  });

  // Return access token in response body
  res.json({ accessToken });
});

// Refresh endpoint with rotation + reuse detection
app.post('/auth/refresh', (req, res) => {
  const refreshToken = req.cookies.refreshToken;

  if (!refreshToken) {
    return res.status(401).json({ error: 'Refresh token required' });
  }

  let decoded;
  try {
    decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, {
      issuer: ISSUER,
      audience: AUDIENCE
    });
  } catch (err) {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }

  const stored = refreshTokens.get(decoded.tokenId);
  if (!stored || stored.revoked) {
    return res.status(401).json({ error: 'Token revoked' });
  }

  // REUSE DETECTION: if a previously-rotated refresh token is presented,
  // the family is compromised. Revoke every sibling immediately.
  if (stored.used) {
    revokeFamily(decoded.familyId);
    return res.status(401).json({ error: 'Token reuse detected; family revoked' });
  }

  stored.used = true;

  // Rotate: issue a new access + refresh in the same family.
  const newAccess = generateAccessToken(decoded.userId, ['read', 'write']);
  const { token: newRefresh } = generateRefreshToken(decoded.userId, decoded.familyId);

  res.cookie('refreshToken', newRefresh, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000
  });

  res.json({ accessToken: newAccess });
});

// Logout - revoke entire refresh-token family
app.post('/auth/logout', (req, res) => {
  const refreshToken = req.cookies.refreshToken;

  if (refreshToken) {
    try {
      const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, {
        issuer: ISSUER,
        audience: AUDIENCE
      });
      revokeFamily(decoded.familyId);
    } catch (err) {
      // Token invalid, no action needed
    }
  }

  res.clearCookie('refreshToken');
  res.json({ success: true });
});

// Auth middleware for protected routes.
//
// Scope of THIS sample: single-issuer private-token deployments using HS256
// (shared secret). It enforces iss / aud / exp / scope / pinned algorithm,
// the claim-level half of the Storm-0558 lesson.
//
// What this sample does NOT cover and you MUST add for multi-issuer / OIDC
// (Azure AD / Auth0 / Cognito / etc.): the signing-key tenancy half. There:
// 1. Use a public-key algorithm (RS256, ES256, EdDSA), not HS256.
// 2. Resolve the issuer's JWKS at the issuer's well-known URL.
// 3. Pin which JWKS each trusted issuer is allowed to use, and reject any
//    token whose iss does not match the JWKS that signed it. Storm-0558's
//    proximate failure was OWA accepting a consumer-tenant key for an
//    enterprise-tenant token because the verifier did NOT enforce this
//    issuer-to-key-set binding.
// 4. Select the verifying key by the JWT's `kid`, but only from within
//    the pinned key set for that issuer.
function requireAuth(requiredScope) {
  return function (req, res, next) {
    const authHeader = req.headers.authorization;

    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return res.status(401).json({ error: 'Access token required' });
    }

    const token = authHeader.substring(7);

    let decoded;
    try {
      decoded = jwt.verify(token, ACCESS_TOKEN_SECRET, {
        issuer: ISSUER,
        audience: AUDIENCE,
        algorithms: ['HS256'] // pin algorithm; reject 'none' and unexpected algs
      });
    } catch (err) {
      if (err.name === 'TokenExpiredError') {
        return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
      }
      return res.status(401).json({ error: 'Invalid token' });
    }

    if (decoded.type !== 'access') {
      return res.status(401).json({ error: 'Invalid token type' });
    }

    if (requiredScope) {
      const scopes = (decoded.scope || '').split(' ');
      if (!scopes.includes(requiredScope)) {
        return res.status(403).json({ error: 'Insufficient scope' });
      }
    }

    req.userId = decoded.userId;
    req.scopes = (decoded.scope || '').split(' ');
    next();
  };
}

// Usage: app.get('/api/admin', requireAuth('admin'), handler)
高価値API向けのバインドトークン

ベアラートークンは盗まれる可能性があります。決済、医療、行政など、トークンの窃取が壊滅的な結果を招くあらゆる状況では、送信者制約付きトークンを使用してください。

どちらの場合も、盗まれたトークンは攻撃者にとって無価値になります。

フロントエンドでのトークン処理
// auth.js - Frontend token management

class AuthManager {
  constructor() {
    this.accessToken = null;
  }

  async login(email, password) {
    const response = await fetch('/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include', // Important for cookies
      body: JSON.stringify({ email, password })
    });

    if (!response.ok) {
      throw new Error('Login failed');
    }

    const { accessToken } = await response.json();
    this.accessToken = accessToken;

    return true;
  }

  async refreshToken() {
    const response = await fetch('/auth/refresh', {
      method: 'POST',
      credentials: 'include'
    });

    if (!response.ok) {
      this.accessToken = null;
      throw new Error('Session expired');
    }

    const { accessToken } = await response.json();
    this.accessToken = accessToken;

    return accessToken;
  }

  async fetchWithAuth(url, options = {}) {
    if (!this.accessToken) {
      throw new Error('Not authenticated');
    }

    const response = await fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${this.accessToken}`
      }
    });

    // If token expired, try to refresh and retry
    if (response.status === 401) {
      const body = await response.json();

      if (body.code === 'TOKEN_EXPIRED') {
        await this.refreshToken();

        // Retry original request
        return fetch(url, {
          ...options,
          headers: {
            ...options.headers,
            'Authorization': `Bearer ${this.accessToken}`
          }
        });
      }
    }

    return response;
  }

  async logout() {
    await fetch('/auth/logout', {
      method: 'POST',
      credentials: 'include'
    });

    this.accessToken = null;
  }
}

export const auth = new AuthManager();

アクセストークンを localStorage に保存しないでください。XSS ペイロードから読み取れるためです。上記のようにメモリ内に保持し、httpOnly のリフレッシュ Cookie を併用するのが標準的な方法です。ページを更新しても状態を維持する必要がある SPA では、BroadcastChannel API を使ってタブ間でメモリ内のトークンを共有し、ハードリフレッシュ時には /auth/refresh の呼び出しが必要になることを受け入れてください。

パスキー/WebAuthn

2026-01-13 時点で WebAuthn L3 は W3C Candidate Recommendation Snapshot です(https://www.w3.org/TR/webauthn-3/)。コメントは 2026-02-10 まで受け付けています。CTAP 2.3 は 2026-02-26 に FIDO Alliance Proposed Standard になりました。

推奨ライブラリ:

  • Node: @simplewebauthn/server(現行バージョン 13.3.0)。ブラウザー用の関連パッケージ:@simplewebauthn/browser。
  • Python: PyPI の webauthn(現行バージョン 2.7.1)。注意:GitHub リポジトリは duo-labs/py_webauthn ですが、インストールコマンドは pip install webauthn です。PyPI パッケージ名は webauthn であり、py_webauthn ではありません。
登録セレモニー
// Node, @simplewebauthn/server
const {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} = require('@simplewebauthn/server');

const RP_ID = 'yourapp.com';                  // your effective domain
const RP_NAME = 'YourApp';
const ORIGIN = 'https://yourapp.com';

// 1. Server: build options for the browser
app.post('/auth/passkey/register/options', requireAuth(), async (req, res) => {
  const user = await db.query('SELECT id, email, name FROM users WHERE id = $1', [req.userId]);
  const existing = await db.query(
    'SELECT credential_id, transports FROM passkeys WHERE user_id = $1',
    [req.userId]
  );

  const options = await generateRegistrationOptions({
    rpName: RP_NAME,
    rpID: RP_ID,
    userID: Buffer.from(String(user.rows[0].id)),
    userName: user.rows[0].email,
    userDisplayName: user.rows[0].name,
    attestationType: 'none',
    excludeCredentials: existing.rows.map(c => ({
      id: c.credential_id,
      transports: c.transports || undefined,
    })),
    authenticatorSelection: {
      residentKey: 'preferred',           // discoverable credentials enable conditional UI
      userVerification: 'preferred',
    },
  });

  // Store challenge for the verify step (Redis with short TTL)
  await redisClient.setEx(
    `webauthn:reg:${req.userId}`,
    300,
    options.challenge
  );

  res.json(options);
});

// 2. Server: verify what the browser returned
app.post('/auth/passkey/register/verify', requireAuth(), async (req, res) => {
  const expectedChallenge = await redisClient.get(`webauthn:reg:${req.userId}`);

  let verification;
  try {
    verification = await verifyRegistrationResponse({
      response: req.body,
      expectedChallenge,
      expectedOrigin: ORIGIN,
      expectedRPID: RP_ID,
      requireUserVerification: false,
    });
  } catch (err) {
    return res.status(400).json({ error: err.message });
  }

  if (!verification.verified || !verification.registrationInfo) {
    return res.status(400).json({ error: 'Registration not verified' });
  }

  const { credential, credentialBackedUp, credentialDeviceType } =
    verification.registrationInfo;

  await db.query(
    `INSERT INTO passkeys
     (user_id, credential_id, public_key, counter, transports, device_type, backed_up)
     VALUES ($1, $2, $3, $4, $5, $6, $7)`,
    [
      req.userId,
      credential.id,
      credential.publicKey,
      credential.counter,
      req.body.response?.transports || null,
      credentialDeviceType,
      credentialBackedUp,
    ]
  );

  await redisClient.del(`webauthn:reg:${req.userId}`);
  res.json({ verified: true });
});

ブラウザーヘルパーをインストールし、ローカルでバンドルしてください。ロックファイルと生成されたアセットをコミットし、デプロイ時にチェックサムを検証してください。以下の例のようにモジュールコードをインラインにする場合は、レスポンスごとに新しい nonce を生成し、script-src 'self' 'nonce-{{CSP_NONCE}}' などの CSP を送信してください。ヘッダーとタグの両方でプレースホルダーを置き換え、nonce を再利用しないでください。あるいは、モジュールコードを同一オリジンの外部ファイルに移動し、nonce なしで script-src 'self' を使用してください。リモートのモジュールグラフは、最初の import に SRI を追加するだけでは保護できません。

npm install --save-exact @simplewebauthn/[email protected]
npm install --save-dev --save-exact [email protected]
npm ci
mkdir -p public/vendor
npx esbuild @simplewebauthn/browser --bundle --format=esm --platform=browser \
  --outfile=public/vendor/simplewebauthn-browser-13.3.0.mjs
sha256sum public/vendor/simplewebauthn-browser-13.3.0.mjs \
  > public/vendor/SHA256SUMS
sha256sum -c public/vendor/SHA256SUMS

<script type="module" nonce="{{CSP_NONCE}}">
  import { startRegistration }
    from '/vendor/simplewebauthn-browser-13.3.0.mjs';

  async function enrollPasskey() {
    const optsRes = await fetch('/auth/passkey/register/options', { method: 'POST', credentials: 'include' });
    const options = await optsRes.json();

    const attResp = await startRegistration({ optionsJSON: options });

    const verRes = await fetch('/auth/passkey/register/verify', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(attResp),
    });
    return verRes.json();
  }

  document.getElementById('enroll').addEventListener('click', enrollPasskey);
</script>
認証セレモニー(条件付き UI)

条件付き UI を使うと、ブラウザーはユーザー名の自動入力ドロップダウン内にパスキーを直接表示できます。ユーザーはメールアドレスの自動入力に使うのと同じ UI からパスキーを選択します。

// Node, server side
const {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} = require('@simplewebauthn/server');

app.post('/auth/passkey/login/options', async (req, res) => {
  const options = await generateAuthenticationOptions({
    rpID: RP_ID,
    userVerification: 'preferred',
    // allowCredentials omitted → discoverable credentials → conditional UI works
  });

  // Tie the challenge to a temporary session marker
  const challengeId = crypto.randomBytes(16).toString('hex');
  await redisClient.setEx(`webauthn:auth:${challengeId}`, 300, options.challenge);
  res.json({ options, challengeId });
});

app.post('/auth/passkey/login/verify', async (req, res) => {
  const { challengeId, response } = req.body;
  const expectedChallenge = await redisClient.get(`webauthn:auth:${challengeId}`);

  if (!expectedChallenge) {
    return res.status(400).json({ error: 'Challenge expired' });
  }

  const credentialId = response.id;
  const stored = await db.query(
    `SELECT user_id, public_key, counter, transports FROM passkeys WHERE credential_id = $1`,
    [credentialId]
  );
  if (stored.rows.length === 0) {
    return res.status(401).json({ error: 'Unknown credential' });
  }

  let verification;
  try {
    verification = await verifyAuthenticationResponse({
      response,
      expectedChallenge,
      expectedOrigin: ORIGIN,
      expectedRPID: RP_ID,
      credential: {
        id: credentialId,
        publicKey: stored.rows[0].public_key,
        counter: stored.rows[0].counter,
        transports: stored.rows[0].transports || undefined,
      },
      requireUserVerification: false,
    });
  } catch (err) {
    return res.status(401).json({ error: err.message });
  }

  if (!verification.verified) {
    return res.status(401).json({ error: 'Authentication not verified' });
  }

  // Persist new counter
  await db.query(
    `UPDATE passkeys SET counter = $1, last_used_at = NOW() WHERE credential_id = $2`,
    [verification.authenticationInfo.newCounter, credentialId]
  );

  // Issue session/tokens as usual
  req.session.regenerate(() => {
    req.session.userId = stored.rows[0].user_id;
    res.json({ verified: true });
  });
});

<input type="email" name="email" autocomplete="username webauthn">
<script type="module" nonce="{{CSP_NONCE}}">
  import { startAuthentication, browserSupportsWebAuthnAutofill }
    from '/vendor/simplewebauthn-browser-13.3.0.mjs';

  if (await browserSupportsWebAuthnAutofill()) {
    const optsRes = await fetch('/auth/passkey/login/options', { method: 'POST' });
    const { options, challengeId } = await optsRes.json();

    const authResp = await startAuthentication({
      optionsJSON: options,
      useBrowserAutofill: true, // conditional UI
    });

    await fetch('/auth/passkey/login/verify', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ challengeId, response: authResp }),
    });
    location.href = '/dashboard';
  }
</script>
WebAuthn L3 のシグナルメソッド

L3 では PublicKeyCredential の静的メソッドが追加され、再登録を強制せずに、依拠当事者が同期可能なパスキーをサーバーの状態と一致させられるようになりました。

  • signalUnknownCredential({ rpId, credentialId }):サーバーが把握していないクレデンシャルに対する検証を試みた後に呼び出します。オーセンティケーターは、ユーザーのアカウント選択 UI からそのクレデンシャルを非表示にします。
  • signalAllAcceptedCredentials({ rpId, userId, allAcceptedCredentialIds }):サーバーのクレデンシャル一覧が変更された(失効、登録)後に呼び出します。オーセンティケーターは、一覧にないクレデンシャルを削除します。
  • signalCurrentUserDetails({ rpId, userId, name, displayName }):プロフィールの変更後に呼び出します。オーセンティケーターは選択 UI のラベルを更新します。

これらは参考情報であり、ベストエフォートで動作します。セキュリティ境界ではなく、保守作業として扱ってください。

webauthn(Python)を使用する場合
# pip install webauthn   # PyPI package name is `webauthn`, NOT py_webauthn
from webauthn import (
    generate_registration_options,
    verify_registration_response,
    generate_authentication_options,
    verify_authentication_response,
)
from webauthn.helpers.structs import (
    AuthenticatorSelectionCriteria,
    ResidentKeyRequirement,
    UserVerificationRequirement,
)

RP_ID = "yourapp.com"
RP_NAME = "YourApp"
ORIGIN = "https://yourapp.com"

options = generate_registration_options(
    rp_id=RP_ID,
    rp_name=RP_NAME,
    user_id=str(user_id).encode(),
    user_name=user_email,
    authenticator_selection=AuthenticatorSelectionCriteria(
        resident_key=ResidentKeyRequirement.PREFERRED,
        user_verification=UserVerificationRequirement.PREFERRED,
    ),
)

パスワードリセットのフロー

セキュアな実装
const crypto = require('crypto');

// Request password reset
app.post('/auth/forgot-password', async (req, res) => {
  const { email } = req.body;

  // Always return success to prevent email enumeration
  res.json({ message: 'If an account exists, a reset link has been sent.' });

  // Find user (async, after response)
  const result = await db.query(
    'SELECT id FROM users WHERE email = $1',
    [email.toLowerCase()]
  );

  if (result.rows.length === 0) {
    return; // User doesn't exist, but don't reveal that
  }

  const user = result.rows[0];

  // Generate secure token
  const token = crypto.randomBytes(32).toString('hex');
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
  const expiresAt = new Date(Date.now() + 3600000); // 1 hour

  // Store hashed token (not plain token)
  await db.query(
    'INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
    [user.id, tokenHash, expiresAt]
  );

  // Send email with plain token
  const resetUrl = `https://yourapp.com/reset-password?token=${token}`;
  await sendEmail(email, 'Password Reset', `Reset your password: ${resetUrl}`);
});

// Reset password
app.post('/auth/reset-password', async (req, res) => {
  const { token, newPassword } = req.body;

  if (!token || !newPassword) {
    return res.status(400).json({ error: 'Token and new password required' });
  }

  // NIST 800-63B-4: 15-char minimum
  if (newPassword.length < 15) {
    return res.status(400).json({ error: 'Password must be at least 15 characters' });
  }

  if (await isPwned(newPassword)) {
    return res.status(400).json({ error: 'This password has appeared in a known breach. Choose a different one.' });
  }

  // Hash the provided token to compare with stored hash
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');

  // Find valid reset token
  const result = await db.query(
    `SELECT user_id FROM password_resets
     WHERE token_hash = $1 AND expires_at > NOW() AND used = false`,
    [tokenHash]
  );

  if (result.rows.length === 0) {
    return res.status(400).json({ error: 'Invalid or expired token' });
  }

  const userId = result.rows[0].user_id;

  // Hash new password with argon2id
  const hashedPassword = await argon2.hash(newPassword, ARGON2_OPTS);

  // Update password and invalidate token
  await db.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hashedPassword, userId]);
  await db.query('UPDATE password_resets SET used = true WHERE token_hash = $1', [tokenHash]);

  // Invalidate all existing sessions and refresh-token families for this user
  await db.query('DELETE FROM sessions WHERE user_id = $1', [userId]);
  // (also revoke all refresh-token families for the user in your token store)

  res.json({ success: true });
});

OAuth 2.1(Google の例)

OAuth 2.1 は現在 draft-ietf-oauth-v2-1-15 で、日付は 2026-03-02 です(https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15)。まだ RFC ではありませんが、プロバイダー(Google、Microsoft、Okta、Auth0)はすでにこれらの制約を実装しているため、2.1 を基準に設計してください。

2.1 で必須となる要件(および廃止される機能)
  • すべてのクライアントで PKCE が必須です。機密クライアントも対象です。 S256 のみを使用し、plain は非推奨です。
  • リダイレクト URI は文字列を完全一致させます。 前方一致やワイルドカードによる一致は使用できません。
  • インプリシットグラント(response_type=token)は使用できません。 PKCE を用いた認可コードを使用してください。
  • Resource Owner Password Credentials(ROPC)は使用できません。 ファーストパーティのログインフローは認可エンドポイントを経由します。
  • ベアラートークンを URL のクエリ文字列に含めてはいけません。 常に Authorization ヘッダーを使用してください。
サーバー側の PKCE を使った認可コードフロー(Google)
const { OAuth2Client } = require('google-auth-library');
const crypto = require('crypto');

const oauth2Client = new OAuth2Client(
  process.env.GOOGLE_CLIENT_ID,
  process.env.GOOGLE_CLIENT_SECRET,
  process.env.GOOGLE_REDIRECT_URI
);

function pkcePair() {
  const verifier = crypto.randomBytes(32).toString('base64url');
  const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
  return { verifier, challenge };
}

// Step 1: Redirect to Google
app.get('/auth/google', (req, res) => {
  const state = crypto.randomBytes(32).toString('hex');
  const { verifier, challenge } = pkcePair();

  req.session.oauthState = state;
  req.session.oauthVerifier = verifier;

  const authUrl = oauth2Client.generateAuthUrl({
    access_type: 'offline',
    scope: ['email', 'profile'],
    state,
    prompt: 'consent',
    code_challenge: challenge,
    code_challenge_method: 'S256',
  });

  res.redirect(authUrl);
});

// Step 2: Handle callback
app.get('/auth/google/callback', async (req, res) => {
  const { code, state } = req.query;

  // Verify state to prevent CSRF
  if (!state || state !== req.session.oauthState) {
    return res.status(400).send('Invalid state parameter');
  }

  const verifier = req.session.oauthVerifier;
  delete req.session.oauthState;
  delete req.session.oauthVerifier;

  if (!verifier) {
    return res.status(400).send('Missing PKCE verifier');
  }

  try {
    // Exchange code for tokens, include the PKCE verifier
    const { tokens } = await oauth2Client.getToken({
      code,
      codeVerifier: verifier,
    });
    oauth2Client.setCredentials(tokens);

    // Validate the id_token: Google verifies signature; we MUST check audience
    const ticket = await oauth2Client.verifyIdToken({
      idToken: tokens.id_token,
      audience: process.env.GOOGLE_CLIENT_ID,
    });

    const payload = ticket.getPayload();
    if (payload.iss !== 'https://accounts.google.com' && payload.iss !== 'accounts.google.com') {
      return res.status(400).send('Unexpected issuer');
    }

    const { sub: googleId, email, name, picture } = payload;

    // Find or create user
    let user = await db.query('SELECT id FROM users WHERE google_id = $1', [googleId]);

    if (user.rows.length === 0) {
      user = await db.query(
        `INSERT INTO users (google_id, email, name, avatar_url)
         VALUES ($1, $2, $3, $4) RETURNING id`,
        [googleId, email, name, picture]
      );
    }

    // Create session
    req.session.regenerate((err) => {
      if (err) {
        return res.status(500).send('Session error');
      }
      req.session.userId = user.rows[0].id;
      res.redirect('/dashboard');
    });

  } catch (error) {
    console.error('OAuth error:', error);
    res.status(400).send('Authentication failed');
  }
});

同じ方法は OAuth 2.1/OIDC プロバイダー全般に適用できます。issuer、client、スコープ、userinfo の参照先を置き換えてください。プロバイダーのコンソールに設定されたリダイレクト URI が、サーバーから送信する URI と文字列で完全一致していることを確認してください。

MFA(パスキー優先)

CISA の「Implementing Phishing-Resistant MFA」(2022-10-31、2026-05 現在も有効;https://www.cisa.gov/sites/default/files/publications/fact-sheet-implementing-phishing-resistant-mfa-508c.pdf)では、認証要素の強度を次のように分類しています。

  • フィッシング耐性のある階層: FIDO2 / WebAuthn(パスキー)、PKI スマートカード(PIV/CAC)。NIST AAL3 では必須です。
  • AAL2 で許容されるが、フィッシング耐性はない: 番号照合を使うアプリベースのプッシュ通知、認証アプリ経由の TOTP。
  • 制限対象、最終手段: SMS / 音声 OTP。NIST 800-63B-4 §5 では、これらを明示的に制限しています。

2026 年時点では、新しい認証要素として SMS を追加しないでください。 可能であれば、ユーザーを SMS から移行させてください。

第 2 要素としてのパスキー

パスワードが引き続き第 1 要素である場合、登録済みのパスキーは利用可能な中で最も強力な第 2 要素です。上記の「パスキー / WebAuthn」セクションにある登録および認証の手順はそのまま使えます。パスワードの検証後にパスキーの検証が成功した場合にのみ、/auth/login を許可してください。

TOTP(AAL2 では引き続き有効)
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');

// Enable MFA for user
app.post('/auth/mfa/enable', requireAuth(), async (req, res) => {
  // Generate secret
  const secret = speakeasy.generateSecret({
    name: `YourApp:${req.user.email}`,
    issuer: 'YourApp'
  });

  // Store secret (encrypted) temporarily until verified
  await db.query(
    'UPDATE users SET mfa_secret_temp = $1 WHERE id = $2',
    [encrypt(secret.base32), req.userId]
  );

  // Generate QR code
  const qrCode = await QRCode.toDataURL(secret.otpauth_url);

  res.json({
    secret: secret.base32, // Show this as backup
    qrCode: qrCode
  });
});

// Verify and activate MFA
app.post('/auth/mfa/verify', requireAuth(), async (req, res) => {
  const { code } = req.body;

  const result = await db.query(
    'SELECT mfa_secret_temp FROM users WHERE id = $1',
    [req.userId]
  );

  const secret = decrypt(result.rows[0].mfa_secret_temp);

  const verified = speakeasy.totp.verify({
    secret: secret,
    encoding: 'base32',
    token: code,
    window: 1 // Allow 1 step tolerance
  });

  if (!verified) {
    return res.status(400).json({ error: 'Invalid code' });
  }

  // Move secret from temp to permanent
  await db.query(
    'UPDATE users SET mfa_secret = mfa_secret_temp, mfa_secret_temp = NULL, mfa_enabled = true WHERE id = $1',
    [req.userId]
  );

  res.json({ success: true });
});

// Login with MFA
app.post('/auth/login', async (req, res) => {
  const { email, password, mfaCode } = req.body;

  // ... verify email/password first ...

  if (user.mfa_enabled) {
    if (!mfaCode) {
      return res.status(401).json({
        error: 'MFA code required',
        requiresMfa: true
      });
    }

    const verified = speakeasy.totp.verify({
      secret: decrypt(user.mfa_secret),
      encoding: 'base32',
      token: mfaCode,
      window: 1
    });

    if (!verified) {
      return res.status(401).json({ error: 'Invalid MFA code' });
    }
  }

  // ... create session/token ...
});
SMS:2026 年に新しい認証要素として追加しないでください

すでに本番環境で SMS を使用している場合は、廃止を計画してください。Snowflake(侵害から得られる教訓を参照)や、広範に及ぶ SIM スワップの状況を踏まえると、SMS は防御策ではなくリスクです。NIST 800-63B-4 §5 では SMS を制限しており、CISA は SMS からの移行を推奨しています。

侵害から得られる一般的な教訓

実際のインシデントにパターンを結び付ける事例です。それぞれ、2~3 文の要約と、上記のレシピに反映されている教訓を示します。

Change Healthcare ランサムウェア攻撃(2024 年 2 月)

攻撃者は、MFA が設定されていない Citrix のリモートアクセス・ポータルで、侵害された認証情報を使用しました。サービスの中断は数週間続き、UnitedHealth Group は約 2,200 万ドルの身代金と、米国患者のおよそ 3 人に 1 人分の記録が漏えいしたことを公表しました。詳細は UnitedHealth Group の RCA を参照してください:https://www.unitedhealthgroup.com/newsroom/2024/2024-04-22-uhg-update-on-change-healthcare-cyberattack.html。

教訓: ユーザー向けアプリだけでなく、すべてのリモートアクセス・ポータル、VPN、Citrix、RDP ゲートウェイ、踏み台ホストで MFA を必須にしてください。攻撃者が標的にするのは、まさに警備が手薄な「内部」ポータルです。

Snowflake / UNC5537(2024 年 4~6 月)

Snowflake の MFA はテナントごとのオプトイン方式だったため、約 165 の顧客テナントが侵害されました。攻撃者は、インフォスティーラーによって窃取された認証情報を、第 2 要素のないアカウントに対して使用しました。Mandiant による解説は https://cloud.google.com/blog/topics/threat-intelligence/unc5537-snowflake-data-theft-extortion にあります。Snowflake は 2024 年 10 月から MFA をデフォルトで有効にし、2025~2026 年を通じてパスワードのみのサインインを必須でブロックする措置を段階的に導入しています。現在の展開スケジュールについては、Snowflake の MFA 適用ドキュメント(https://docs.snowflake.com/)を参照してください。

教訓: MFA はオプトインではなく、デフォルトで有効にする必要があります。顧客が MFA を無効にできるなら、攻撃者は無効にした顧客を見つけ出します。

Storm-0558(CSRB によるレビュー、2024-04)

Microsoft のコンシューマー向け署名キーが侵害され、攻撃者は Azure AD トークンを偽造して、企業の OWA メールボックスにアクセスしました。直接の原因は、OWA のトークン検証ライブラリがコンシューマー向けキーで署名されたトークンを企業のメールボックスで受け入れ、スコープ、発行者、署名キーのテナント検証を省略していたことです。CSRB のレポート:https://www.cisa.gov/resources-tools/resources/CSRB-Review-Summer-2023-MEO-Intrusion。(Microsoft の MSRC 事後分析の URL は廃止されています。)

教訓: すべてのクレームを毎回検証してください。発行者、オーディエンス、スコープ、有効期限、固定されたアルゴリズムを検証します。さらに、複数の発行者を扱う / OIDC を導入する場合は、署名キーのテナントも検証してください。信頼する発行者ごとに使用を許可する JWKS を固定し、kid が一致していても、別の発行者のキーセットから検証キーを選択してはなりません。上記の JWT レシピには iss / aud / scope / 固定されたアルゴリズムが組み込まれており、教訓のうちクレームレベルの部分をカバーし、単一発行者のプライベートトークンを使う構成にも対応しています。署名キーのテナントに関する部分(JWKS の解決と発行者からキーセットへのバインド)はサンプルには含まれていません。複数のテナントからトークンを受け入れる場合に追加すべき内容については、requireAuth 関数の上にあるコメントブロックを参照してください。

Okta の HAR 侵害(2023 年 10 月)

顧客が Okta サポートに HAR(HTTP アーカイブ)デバッグファイルをアップロードした際、ファイルに有効なセッショントークンが含まれていました。顧客 5 社のセッションが乗っ取られました。詳細は Okta の解説(https://sec.okta.com/articles/harfiles/)を参照してください。

教訓: ログの境界で、セッショントークンやその他のベアラー認証情報をサニタイズしてください。HAR のアップロード、エラーレポート、デバッグダンプについては、ユーザーのブラウザーから外部に送信される前に、Authorization ヘッダー、set-cookie、およびトークンらしい既知の値を削除してください。

23andMe のクレデンシャルスタッフィング(2023 年 10 月、2024 年に和解)

攻撃者は他の侵害で使われたパスワードを再利用し、約 14,000 アカウントにクレデンシャルスタッフィングを行いました。その後、DNA Relatives のソーシャルグラフをたどり、約 690 万ユーザーのデータを暴露しました。

教訓: IP 単位のレート制限だけでなく、つながりやグラフのエンドポイントにもアカウント単位の読み取りクォータを設定してください。侵害されたアカウント 1 つで、人間のユーザーが合理的に行える速度を超えてソーシャルグラフを列挙できないようにします。登録時およびパスワードリセット時に漏えい済みパスワードをチェック(HIBP)すれば、侵入経路を完全に遮断できます。

セキュリティに関する確認事項

該当する箇所では、括弧内に NIST AAL の用語を記載しています。

パスワードの保存
  • デフォルトは argon2id とし、OWASP の同等な 5 つのプロファイルのいずれかを使用します(m=47104/t=1、m=19456/t=2、m=12288/t=3、m=9216/t=4、m=7168/t=5。すべて p=1)
  • bcrypt を使用する場合は、コストを最低 10 にします。72 バイトを超える入力は SHA-256 で事前にハッシュ化するか、拒否します
  • PBKDF2 は FIPS-140 準拠が必要な場合に限り使用します
  • 平文のパスワードを保存しません
  • エラーハンドラー内を含め、パスワードをログに記録しません
パスワードポリシー(NIST SP 800-63B-4)
  • 単一要素認証では最低 15 文字、多要素認証では最低 8 文字にします
  • 最大文字数は最低 64 文字にします(検証側で少なくとも 64 文字を受け入れる)
  • 文字種の組み合わせルールを設けません(「課してはならない」)
  • Unicode と貼り付けを許可します
  • 定期的な変更を求めません
  • 検証側で漏えい済みパスワードのブロックリスト(HIBP の k-匿名性、または同等のもの)をチェックします
セッション管理
  • セッションをサーバー側に保存します(Cookie のみに保存しません)
  • セッション ID は暗号学的にランダムです
  • ログイン時と権限変更時にセッションを再生成します
  • ログアウト時とパスワードリセット時にセッションを無効化します
  • セッションに最大有効期間を設定します
  • Cookie に httpOnly を設定し、本番環境では secure を設定し、sameSite=lax またはそれより厳しい設定にします
JWT セキュリティ
  • アクセストークンの有効期間を短くします(15 分以下)
  • リフレッシュトークンを httpOnly Cookie に保存します
  • リフレッシュトークンをローテーションし、再利用を検出します(ローテーション済みトークンが 2 回使われた場合、ファミリー全体を無効化)
  • トークンの失効メカニズムを用意します
  • シークレットは少なくとも 256 ビットにします
  • リクエストごとに完全な検証を行います。発行者、対象者、有効期限、スコープを確認し、アルゴリズムを固定します(alg=none は不可)。署名キーのテナンシーも検証します(Storm-0558)
  • 高価値 API では、トークンをバインドします(RFC 9449 に基づく DPoP、または RFC 8705 に基づく mTLS)
パスキー / WebAuthn
  • 登録セレモニーでチャレンジ、オリジン、RP ID を検証します
  • 認証セレモニーでチャレンジ、オリジン、RP ID、署名カウンターを検証します
  • 条件付き UI 用に、検出可能な認証情報(residentKey: 'preferred')を有効にします
  • サーバー上で認証情報の状態が変わったときに、WebAuthn L3 のシグナルメソッドを呼び出します
  • 認証器ポリシーを選定する際に CTAP 2.3 を検討します
MFA(CISA のティア順位)
  • AAL3 のコンテキストでは、フィッシング耐性のあるティア(FIDO2/WebAuthn、PIV/CAC)を使用します
  • AAL2 では、番号照合に対応したアプリベースのプッシュ認証、または TOTP を使用します
  • 2026 年には新たな SMS 要素を導入せず、既存ユーザーを SMS から移行させます
  • MFA はオプトインではなく、デフォルトでオンにします(Snowflake の教訓)
  • すべてのリモートアクセス用ポータルで MFA を必須にします(Change Healthcare の教訓)
OAuth 2.1
  • 機密クライアントも含め、すべてのクライアントで S256 による PKCE を使用します
  • リダイレクト URI は完全一致で照合します
  • インプリシットグラントや ROPC グラントを使用しません
  • コールバック時に state パラメーターを検証します
  • id_token の対象者と発行者を検証します
レート制限
  • IP アドレスとアカウントごとにログイン試行を制限する
  • N 回失敗した後、アカウントをロックする
  • パスワードリセットのリクエストを制限する
  • MFA の認証試行を制限する
  • 関係性/グラフのエンドポイントにアカウントごとのクォータを設定する(23andMe の教訓)
CSRF 対策
  • SameSite Cookie 属性を設定する
  • 状態を変更する操作に CSRF トークンを使用する
  • OAuth の state パラメーターを検証する
情報漏えい
  • ユーザーの有効/無効にかかわらず、同じエラーメッセージを表示する
  • タイミング攻撃を軽減する(ユーザーが存在しない場合はダミーのハッシュ比較を行う)
  • 登録/リセット機能を通じたユーザーの列挙を防ぐ
  • ログの境界でベアラートークンを除去する(Okta HAR の教訓)

コメント