Article

パスワード管理のベストプラクティス2025

高田晃太郎
パスワード管理のベストプラクティス2025

公開リスト上の漏えいクレデンシャルは数十億件規模に達し¹、自動化されたアカウント攻撃はもはや日常の雑音です。一方で、主要クラウド事業者の公開データではMFA(多要素認証)が約99.9%の自動攻撃を遮断するという傾向が繰り返し報告されています²。実務のインシデント対応の経験と公開統計を突き合わせると、突破口はパスワードの“複雑さ”を積み増すことではなく、パスワードに依存しない面の増築と、破られても被害が連鎖しないシステム管理の設計に行き着きます。2025年の現実解は、WebAuthn/Passkey(FIDO2による公開鍵認証。デバイスやプラットフォームで扱う“パスワードレス”資格情報)を第一選択に据えつつ、レガシー領域にはメモリハードなハッシュと漏えい検知、そして運用SLO(Service Level Objective:サービス目標値)を具体的に置くことです。ここでは方針論にとどめず、実装と運用を結ぶ数値の目安とコードで、CTOとエンジニアリーダーがそのまま適用できる形に落とし込みます。

2025年の前提:パスワードは“主役”ではなく“要塞の外郭”

パスワードをゼロにする理想と、レガシー資産を抱える現実の間で、いま求められるのは役割の再定義です。最新のガイダンス(NIST SP 800-63BやOWASP ASVSの更新版)では、複雑記号を強制する旧来のルールや定期的な一斉変更を推奨していません³⁴⁷。推奨は、ユーザー側では長く覚えやすいフレーズの許容³、システム側では漏えい済みパスワードの照合⁶、サーバー側ハッシュの強化⁴、そして使い回し・総当たり・スタッフィングに対する検知と遅延です。2025年はここにPasskey(FIDO2/WebAuthn)を標準の第一要素として組み込み、パスワードは互換性のために残す“外郭”として扱うのが合理的です⁵。

重要なのは、破られないパスワードを夢見るのではなく、破られても中で止まる構造です。アカウントロックは過剰だと業務を止め、緩いと踏み台化を許します。そこで、認証APIの遅延と失敗率に具体的なSLOを置き、攻撃を吸収しつつユーザー体験を守るバランスを数値で管理します。例えば、ログインのサーバー側ハッシュは200〜500msを目安に調整し、全体のp95応答は800ms以内、ボット由来の失敗ピーク時でもプラットフォームの稼働を維持する、といった合意を先に作ることが設計の起点になります(いずれも環境に合わせて調整する“例”として扱ってください)。

破られても連鎖しない設計:ハッシュ、照合、遅延、検知

レガシー互換のためにパスワードを維持する場合でも、サーバーは常にArgon2id(メモリハードなパスワードハッシュ関数)でハッシュし、アプリケーション層のpepper(サーバー側で共有する秘密をパスワードに付加するテクニック)はKMSやVault(秘密情報管理の仕組み)で分離保管します⁴。ユーザー入力は強制複雑性ではなく、長さと“漏えい済みでないこと”を満たせば受け入れる運用が良い結果を生みます³⁶。K-匿名化(k-anonymity)を用いたAPIで漏えい済み候補を照合し⁶、ヒット時は手動対応ではなくUIフローで自然に再設定へ誘導すると、ヘルプデスクの負荷も下がります。さらに、ログイン試行はIP・デバイス・ASN・時間帯などを特徴量としてスコアリングし、スコアに応じた段階的な遅延や追加検証で攻撃の“熱量”を下げます。

システム管理が握るSLO:遅延、ロック、回復

現場運用では具体的数値が意思決定を速くします。ログインAPIはp50 250ms、p95 800ms、エラー率0.5%未満、ボット由来スパイク時の自動遅延注入は最大2秒まで、誤ロック率は月間ユーザーあたり0.1%未満——こうした“目安”を置くと、保守と体験の両立が見通せます。アカウント回復はMFA登録済みなら中央値5分以内、ヘルプデスク介入が必要なケースでも同日内に完了する体制を用意します。ログ保持は監査要件を満たす範囲で400日以上、PIIはハッシュ化し、攻撃解析用のメタデータは脱識別化したうえで保持するのが安全です。

実装の要点:安全なハッシュ、Passkey、レート制御、漏えい照合

理屈だけでは運用は回りません。ここからは、すぐに組み込めるコードと設定を示します。いずれも本番では環境・SLOに合わせてパラメータを調整してください。推奨は、サーバー側ハッシュの平均計算時間が200〜500msとなるように調整し、全体の応答SLOに収めることです。

Argon2idでのハッシュと検証(Python)

from argon2 import PasswordHasher, exceptions
import os

# パラメータはサーバーのSLOに合わせて調整(目安: 200-500ms)
# time_cost=2, memory_cost=192*1024 (~192 MiB), parallelism=1 が起点
ph = PasswordHasher(time_cost=2, memory_cost=192 * 1024, parallelism=1)

# pepperはKMS/Vaultから取得。ここでは環境変数を例示
def get_pepper() -> bytes:
    val = os.getenv("APP_PEPPER")
    if not val:
        raise RuntimeError("Pepper not configured")
    return val.encode("utf-8")

def hash_password(password: str) -> str:
    peppered = (password + get_pepper().decode("utf-8"))
    return ph.hash(peppered)

def verify_password(stored_hash: str, password: str) -> bool:
    try:
        peppered = (password + get_pepper().decode("utf-8"))
        return ph.verify(stored_hash, peppered)
    except exceptions.VerifyMismatchError:
        return False
    except exceptions.InvalidHash:
        # 旧ハッシュ形式など。マイグレーションのフラグに利用
        return False

ハッシュに成功したら、ユーザーのレコードに計算時間とハッシュのバージョンを記録しておくと、将来のパラメータ調整や再ハッシュの判断に使えます。verifyがTrueで、かつph.check_needs_rehashが提供されている実装では、閾値を上げた際に安全に再ハッシュを行います。

WebAuthn/Passkeyの登録フロー(Node.js, @simplewebauthn)

import express from 'express';
import session from 'express-session';
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';

const app = express();
app.use(express.json());
app.use(session({ secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: true }));

// ユーザー・クレデンシャルのDBは省略(ORM想定)

app.post('/webauthn/register/options', async (req, res) => {
  const { userId, username } = req.body;
  const options = generateRegistrationOptions({
    rpName: 'Example Corp',
    rpID: 'example.com',
    userID: isoBase64URL.fromBuffer(Buffer.from(String(userId))),
    userName: username,
    attestationType: 'none',
    authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
  });
  req.session.challenge = options.challenge;
  res.json(options);
});

app.post('/webauthn/register/verify', async (req, res) => {
  const { userId } = req.body;
  try {
    const verification = await verifyRegistrationResponse({
      response: req.body.credential,
      expectedChallenge: req.session.challenge,
      expectedRPID: 'example.com',
      expectedOrigin: 'https://example.com',
    });
    if (!verification.verified) return res.status(400).json({ ok: false });
    const { credentialPublicKey, credentialID, counter } = verification.registrationInfo!;
    // DBに保存(ユーザーごとに複数鍵を許容)
    await saveCredential(userId, credentialID, credentialPublicKey, counter);
    res.json({ ok: true });
  } catch (e) {
    res.status(400).json({ ok: false, error: 'verification-failed' });
  }
});

app.listen(3000);

登録と同程度に、認証と回復フローのUXが重要です。Passkey優先のUIにしつつ、レガシーなパスワード+MFAを無理なく併設し、いずれも共通のレート制御と異常検知のフレームワークに載せると管理が容易になります⁵。

Redisでの滑らかなレート制御(Go)

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "time"

    "github.com/redis/go-redis/v9"
)

type Limiter struct {
    rdb *redis.Client
    limit int
    window time.Duration
}

func NewLimiter(addr string, limit int, window time.Duration) *Limiter {
    rdb := redis.NewClient(&redis.Options{Addr: addr})
    return &Limiter{rdb: rdb, limit: limit, window: window}
}

func (l *Limiter) Allow(ctx context.Context, key string) (bool, time.Duration, error) {
    now := time.Now().Unix()
    pipe := l.rdb.TxPipeline()
    zkey := fmt.Sprintf("rl:%s", key)
    // 窓外の古いイベントを削除
    pipe.ZRemRangeByScore(ctx, zkey, "-inf", fmt.Sprintf("%d", now-int64(l.window.Seconds())))
    // 現在のイベントを追加
    pipe.ZAdd(ctx, zkey, redis.Z{Score: float64(now), Member: now})
    // カウントを取得
    cnt := pipe.ZCard(ctx, zkey)
    // TTLを設定
    pipe.Expire(ctx, zkey, l.window)
    if _, err := pipe.Exec(ctx); err != nil {
        return false, 0, err
    }
    n, _ := cnt.Result()
    if n > int64(l.limit) {
        return false, l.window / 4, nil // 短い待ち時間を返し、バックオフを促す
    }
    return true, 0, nil
}

func main() {
    lim := NewLimiter("localhost:6379", 10, 60*time.Second)
    http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
        ip := r.RemoteAddr
        ok, retryAfter, err := lim.Allow(r.Context(), ip)
        if err != nil {
            http.Error(w, "rate limiter error", http.StatusInternalServerError)
            return
        }
        if !ok {
            w.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())))
            http.Error(w, "too many attempts", http.StatusTooManyRequests)
            return
        }
        // 認証処理...
        w.WriteHeader(http.StatusNoContent)
    })
    log.Fatal(http.ListenAndServe(":8080", nil))
}

アプリケーション側の滑らかな制御に加えて、エッジでも攻撃の熱量を落とします。CDNのWAFやフロントプロキシと多層化すると、アプリに到達する悪性トラフィックの体積を下げられます。

Nginxでの簡易スロットリング(設定)

limit_req_zone $binary_remote_addr zone=login_zone:10m rate=5r/s;

server {
  location /login {
    limit_req zone=login_zone burst=20 nodelay;
    proxy_pass http://app-backend;
  }
}

この設定は1秒あたり5リクエストの平常流量に、瞬間的に20件までのバーストを許容します。実際にはボットのパターンに合わせてASNやUser-Agent、パス単位でルールを分けると誤検知を抑えられます。

漏えいパスワードのK匿名照合(Python)

import hashlib
import requests

def is_leaked(password: str) -> bool:
    sha1 = hashlib.sha1(password.encode('utf-8')).hexdigest().upper()
    prefix, suffix = sha1[:5], sha1[5:]
    url = f"https://api.pwnedpasswords.com/range/{prefix}"
    r = requests.get(url, timeout=3)
    r.raise_for_status()
    for line in r.text.splitlines():
        h, count = line.split(':')
        if h == suffix and int(count) > 0:
            return True
    return False

入力中にローカルで評価し、送信前に候補を避けるガードとしても機能します。サーバー側ではこの結果のみで拒否せず、UIで再設定を促すほうが体験とセキュリティの両立につながります⁶。

認証情報スキーマと監査のためのフィールド(SQL)

CREATE TABLE users (
  id BIGSERIAL PRIMARY KEY,
  email CITEXT UNIQUE NOT NULL,
  password_hash TEXT, -- PasskeyのみのユーザーはNULL
  hash_algo TEXT NOT NULL DEFAULT 'argon2id-v1',
  last_password_change TIMESTAMPTZ,
  mfa_enrolled BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE webauthn_credentials (
  id BIGSERIAL PRIMARY KEY,
  user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  credential_id BYTEA NOT NULL,
  public_key BYTEA NOT NULL,
  sign_count BIGINT NOT NULL DEFAULT 0,
  device_label TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(user_id, credential_id)
);

CREATE INDEX ON users (updated_at);

ユーザーごとに複数のPasskeyを許容し、パスワードはNULL許容にして併存・移行の柔軟性を保ちます。hash_algoでパラメータ変更の世代管理を行い、移行期間中の検証分岐をシンプルにします。

Pepperのローテーション(Vault)

#!/usr/bin/env bash
set -euo pipefail

# 新しいpepperを生成し、VaultのKVに格納
NEW=$(openssl rand -base64 48)
vault kv put secret/app/pepper value="$NEW"

# デプロイ先のアプリは起動時にVaultから取得する設計。
# ローテ後もしばらくは旧pepperも受理するため、二重検証期間を設ける。
# 旧pepperは24時間後に破棄(環境変数や設定ファイルに残さない)。

pepperの二重受理期間をSLOに合わせて設定し、全ユーザーが次回ログイン時に新パラメータへ再ハッシュされるよう設計します。アプリケーションコードでは旧pepperで失敗したら新pepperで再試行し、成功したタイミングで新ハッシュを保存すると滑らかに移行できます⁴。

運用設計とビジネス効果:ヘルプデスク、コンプライアンス、ROI

パスワード管理はセキュリティ項目であると同時に、運用コストの大口です。次のモデルで投資対効果を具体化します。まず対象ユーザーを1,000人、年間のパスワード関連問い合わせを一人あたり1.2件、1件あたりの総コスト(一次対応、本人確認、業務遅延の機会損失を含む)を3,000円と仮置きすると、年間の損失は3,600,000円になります。Passkey優先と漏えい照合の組み合わせで問い合わせが60%減るだけで2,160,000円の圧縮です。MFAハードウェアキー配布を含む初期費用が1,500,000円、実装・教育で500,000円の投資だとしても、初年度で損益分岐を超える絵を描けます。もちろん数字は各社で変わりますが、こうした具体的数値を先に経営と握ると、導入の合意形成が速くなります。

コンプライアンスの側面では、NIST SP 800-63Bに整合するポリシー定義³、OWASP ASVSに準拠した実装⁴、ISO 27001の管理策と監査証跡の運用をマッピングします。具体的には、辞書攻撃・総当たり対策のロジック、パスワードの保存形式、MFAの強度、回復フロー、ログ保持とアクセス制御の手順が監査の論点になります。社内規程には複雑記号の強制や定期変更のような旧来ルールではなく、長さ・漏えい照合・二要素登録・端末紛失時の即時無効化といった運用に焦点を当てるほうが、監査適合とユーザー体験が両立します³⁶。

ロールアウト計画と教育:抵抗を減らすUX設計

導入成功は技術的完成度だけでなく、順序とメッセージに大きく依存します。まず管理コンソールや従業員の基幹システムからPasskeyを既定とし、早期参加者プログラムで社内のチャンピオンを育てます。UIはログイン画面の最上段に「この端末で続行」を提示し、パスワードの入力欄は折りたたみます。教育は「何を守るか」より「どうすれば早く安全に仕事が始められるか」に焦点を当てると、抵抗が減ります。端末紛失時の回復手順をチャットボットに組み込み、就業初日に本人が自力で完了できる流れにすると、ヘルプデスクの介入率が下がります。

監査・証跡・ダッシュボード:見える化で継続改善

日々の運用では、攻撃の熱量とユーザー体験を同じ画面で把握できることが重要です。ダッシュボードにはログインp50/p95、ハッシュ計算時間の中央値、ロック・解除の件数、異常スコアの分布、Passkey比率、回復フローの所要時間、問い合わせ件数を置き、月次の目標値と実績を並べます。アラートはIP単位よりもASNや自治体ネット、クラウド事業者のレンジでまとめ、ネットワーク単位で対策する癖を付けると、誤検知とアラート疲れを抑制できます。

よくある落とし穴と、その先回り

現場で繰り返し見られる失敗は、パスワードの複雑性にこだわり過ぎて長さと漏えい照合を軽視する、pepperを環境変数に固定してバイナリに焼き込みローテーション不能にする、ボット攻撃のピークで一律ロックを発動し業務を止める、といったものです。これらは方針の一言で避けられます。複雑性ではなく長さと辞書照合、pepperはKMSやVaultで二重受理期間付きローテ運用、ロックではなく遅延と段階的検証です。もう一つ大切なのは、回復フローの過剰な本人確認が業務を麻痺させる問題です。リスクベースで検証強度を上げ、MFA登録済みなら完全オンラインで即時回復、未登録や高リスクのみ手動対応という分岐を作ると、体験と安全性が両立します。

インシデント後のプレイブック:48時間でやるべきこと

漏えい疑義や大規模なスタッフィングを検知したら、最初の48時間でやるべきことは決め打ちにしておきます。まず外部リストでヒットしたアカウントの強制再設定を静かに走らせ、並行してログの保存期限延長を実行します。WAFとアプリのレート制御は段階的に引き締め、誤検知の影響をダッシュボードで監視しながら調整します。経営には影響の範囲、現在の封じ込め策、ユーザー体験への影響見込み、次の24時間の計画を定型の1枚で報告し、対外コミュニケーションの草案を準備します。技術的対処だけでなく、ステークホルダーコミュニケーションを時系列で固定化しておくことが、被害の連鎖と混乱を最小化します。

まとめ:主役をPasskeyに、外郭を堅牢に

2025年のベストプラクティスは、Passkeyを主役に据え、パスワードは互換性のために残す外郭と定義し直すことです。外郭はArgon2idとpepperで堅牢にし⁴、漏えい照合と段階的な遅延で攻撃の熱量を落とし⁶、SLOで運用を数値化します。こうして技術と運用を一体でデザインすれば、セキュリティは強くなり、ログインは速くなり、ヘルプデスクの負荷は下がるはずです。あなたの組織では、どのシステムからPasskeyを既定にしますか。今週は認証APIのp95と誤ロック率をダッシュボードに出し、来週は従業員向けのPasskey登録キャンペーンを計画してみてください。

参考文献

  1. BleepingComputer. No, the 16 billion credentials leak is not a new data breach. https://www.bleepingcomputer.com/news/security/no-the-16-billion-credentials-leak-is-not-a-new-data-breach/
  2. Microsoft Security Blog (2019-08-20). One simple action you can take to prevent 99.9 percent of account attacks. https://www.microsoft.com/en-us/security/blog/2019/08/20/one-simple-action-you-can-take-to-prevent-99-9-percent-of-account-attacks/
  3. NIST SP 800-63B (Draft Rev. 4). Digital Identity Guidelines: Authentication and Lifecycle Management. https://pages.nist.gov/800-63-4/sp800-63b.html
  4. OWASP Cheat Sheet Series. Password Storage Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
  5. W3C Press Release (2019-03-04). W3C and FIDO Alliance Finalize Web Authentication: W3C Recommendation. https://www.w3.org/press-releases/2019/webauthn/
  6. Cloudflare Blog. Validating leaked passwords with k-anonymity. https://blog.cloudflare.com/validating-leaked-passwords-with-k-anonymity/
  7. NIST 800-63 FAQ. https://pages.nist.gov/800-63-FAQ/