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文字のパスワードは数時間で全数探索できる
パスワードハッシュは意図的に遅くするべきです。
専用アルゴリズムの比較
| bcrypt | Argon2id | scrypt | |
|---|---|---|---|
| 速度調整 | コスト係数(rounds) | time・memory・parallelism | N・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-*)を絶対に使わないことです。