SJ blog
security
S

信頼度ランク

S 公式ソース確認済み
A 成功実績多数・失敗例少数
B 賛否両論
C 動作未確認・セキュリティリスク高
Z 個人所感

パスワードハッシュの正しい実装:bcrypt・Argon2・scrypt

パスワードの保存に SHA-256 を使っていませんか?bcrypt・Argon2・scryptの違いと正しい使い方、コスト係数の選び方、レインボーテーブル対策、データ漏洩時の影響を解説します。

一言結論

パスワード保存にSHA-256を使うのは致命的なミスであり、意図的に遅く設計されたArgon2id(推奨)またはbcrypt(コスト係数12以上)を使うことが、データ漏洩時のクラッキング耐性を担保する唯一の方法だ。

なぜ SHA-256 ではいけないのか

MD5 / SHA-1 / SHA-256 は「汎用ハッシュ」であり、「速い」のが問題。

GPU を使えば MD5 は毎秒数百億回ハッシュを計算できる。
→ 8文字のパスワードは数時間で全数探索できる

パスワードハッシュは意図的に遅くするべきです。

専用アルゴリズムの比較

bcryptArgon2idscrypt
速度調整コスト係数(rounds)time・memory・parallelismN・r・p
メモリ使用固定(少ない)調整可能(多くできる)調整可能
GPU 耐性高(メモリハード)
推奨度広く使われている現在の最推奨推奨

bcrypt の実装

import bcrypt from "bcrypt";

const SALT_ROUNDS = 12; // 10〜14 が現実的(12が安定的な推奨値)

// パスワードのハッシュ化
async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, SALT_ROUNDS);
}

// パスワードの検証
async function verifyPassword(password: string, hash: string): Promise<boolean> {
  return bcrypt.compare(password, hash);
}

// 使用例
const hash = await hashPassword("mysecretpassword");
// → "$2b$12$..." のような文字列(ソルト込み)

const isValid = await verifyPassword("mysecretpassword", hash); // true
const isInvalid = await verifyPassword("wrongpassword", hash);  // false

bcrypt は最大 72 バイトでトランケートされるため、長いパスワードを短く切ります。

Argon2 の実装(推奨)

import argon2 from "argon2";

async function hashPassword(password: string): Promise<string> {
  return argon2.hash(password, {
    type: argon2.argon2id,  // id が最も安全(時間 + メモリ攻撃に対応)
    memoryCost: 65536,      // 64MB(増やすほど強い)
    timeCost: 3,            // 反復回数
    parallelism: 4,         // 並列スレッド数
  });
}

async function verifyPassword(password: string, hash: string): Promise<boolean> {
  return argon2.verify(hash, password);
}

Node.js 組み込み scrypt

import { scrypt, randomBytes, timingSafeEqual } from "crypto";
import { promisify } from "util";

const scryptAsync = promisify(scrypt);

async function hashPassword(password: string): Promise<string> {
  const salt = randomBytes(32).toString("hex");
  const hash = (await scryptAsync(password, salt, 64)) as Buffer;
  return `${salt}:${hash.toString("hex")}`;
}

async function verifyPassword(password: string, stored: string): Promise<boolean> {
  const [salt, hash] = stored.split(":");
  const candidate = (await scryptAsync(password, salt, 64)) as Buffer;

  // タイミング攻撃を防ぐため timingSafeEqual を使う
  return timingSafeEqual(Buffer.from(hash, "hex"), candidate);
}

ソルトの重要性

ソルトなし:
  全ユーザーが同じパスワードなら → 同じハッシュ
  レインボーテーブル攻撃で一括クラック

ソルトあり(bcrypt・Argon2 は自動でソルトを生成・埋め込む):
  user_A: password → $2b$12$AAAA...hash_A
  user_B: password → $2b$12$BBBB...hash_B (ソルトが違うので別ハッシュ)
  → レインボーテーブル無効、一括クラック困難

コスト係数の選び方

// bcrypt のコスト係数別の処理時間(参考)
// ハードウェアにより異なる

// rounds=10: ~65ms
// rounds=12: ~250ms  ← バランスが良い(登録・ログイン時の待ち時間として許容範囲)
// rounds=14: ~1000ms
// rounds=16: ~4000ms

// 年に一度、コスト係数を見直す
// 目安: ハッシュ計算に 100〜300ms かかるように調整する

データ漏洩時の影響

ハッシュ化されたパスワードが漏洩した場合:

MD5 / SHA-256: ほぼ即座にクラック可能
bcrypt(rounds=12): 1パスワードのクラックに数時間〜数日
Argon2id(強設定): さらに長時間必要

→ クラック困難な間にユーザーに通知・パスワードリセットを促せる

実装チェックリスト

✅ bcrypt か Argon2id を使う(SHA-256 は NG)
✅ bcrypt の rounds は 12 以上
✅ Argon2 の memoryCost は 64MB 以上
✅ ソルトは自動生成(手動管理不要)
✅ 比較には timingSafeEqual を使う(タイミング攻撃対策)
✅ ペッパー(アプリレベルの秘密鍵)の追加も検討
✅ パスワードをログに出力しない

まとめ

新規プロジェクトでは Argon2id を第一選択肢としてください。既存プロジェクトで bcrypt を使っているなら、そのまま rounds=12 以上で運用しても十分な安全性があります。何より重要なのは、汎用ハッシュ(MD5・SHA-*)を絶対に使わないことです。


参考: OWASP Password Storage Cheat Sheet / Argon2 RFC 9106