パスワードハッシュ入門:bcrypt と Argon2id を実測で選ぶ
セキュリティ基礎講座2。ソルトなし SHA-256 を20語の自作リストで破り、bcrypt・Argon2id・scrypt・PBKDF2 の1回あたりの時間を実測。72バイト制限も PHP 8.5 で再現。
シリーズ:セキュリティ基礎講座 2/6(初級)。 はじめて会員登録フォームを作る人、あるいは既存のユーザーテーブルを引き継いで「このパスワードの保存方法で大丈夫か」が分からない人向けです。読み終えると、PHP と Python でパスワードを正しく保存・照合でき、「十分に遅い」がミリ秒で何秒なのかを言えて、漏えい事故の報告に繰り返し出てくる間違いを避けられます。
講座1の最後の数字を思い出してください。ノートPCの1コアは SHA-256 を毎秒約789万回計算します。この講座では、その数字が「DB が漏れた日」に何を意味するかを見ます。日本でも、漏えい後の調査で「パスワードを平文で保存していた」「元に戻せる形で保存していた」と分かり、全ユーザーにパスワード変更をお願いすることになった、という発表は珍しくありません。個人情報保護法のもとでは、一定の漏えいが起きれば個人情報保護委員会への報告や本人への通知が必要になりますが、そもそもパスワードを正しくハッシュしておけば、漏れたときの被害そのものを小さくできます(法律の具体的な要件は専門家に確認してください)。
実行環境はすべて Apple Silicon Mac(M1 Pro)、PHP 8.5.6(password_hash の bcrypt と Argon2id、libsodium 1.0.22)、Python 3.9.6、OpenSSL 4.1.0 です。ここに出てくる「攻撃」は、すべて自分のマシンの中で、自作のユーザーと自作の20語リストに対して行っています。
パスワード保存の5段階:最悪から正解まで
| 保存する値 | 例 | DB を盗んだ人が手に入れるもの |
|---|---|---|
| 平文 | sakura123 |
全員のパスワードを即座に。使い回していれば他サービスの分も |
| 暗号化 | パスワードの AES | 鍵も漏れた時点で全員分(鍵はたいてい同じサーバーにある) |
| 速いハッシュ、ソルトなし | sha256("sakura123") |
弱いパスワードはマイクロ秒で。同じパスワードは同じハッシュで丸見え |
| 速いハッシュ + ソルト | sha256(salt + pw) |
弱いパスワードはやはりマイクロ秒で。1人ずつになるだけ |
| 遅いパスワードハッシュ | bcrypt、Argon2id、scrypt、PBKDF2 | 推測1回に数十〜数百ミリ秒。強いパスワードは生き残る |
用語を2つ先に説明します。ソルトはユーザーごとに違うランダムな値で、パスワードと一緒にハッシュし、ハッシュの隣に保存します。秘密ではありません。役目は、同じパスワードの2人に別々のハッシュを持たせ、攻撃者に1人ずつ攻撃させることです。遅いパスワードハッシュ(鍵導出関数、KDF とも呼びます)は、わざと処理を何度も繰り返したり大量のメモリを使ったりする関数で、ハードウェアが速くなったら上げられるコストの設定を持っています。
5分で最初の password_hash
PHP には 5.5 から正しい道具が入っています。password_hash() がソルトを作り、bcrypt(または Argon2id)を実行し、1つの列に保存する1本の文字列を返します。
<?php
$pw = 'tiger2026';
$h1 = password_hash($pw, PASSWORD_DEFAULT);
$h2 = password_hash($pw, PASSWORD_DEFAULT);
echo $h1, "\n", $h2, "\n";
var_dump($h1 === $h2); // 同じパスワードでもソルトが違う
var_dump(password_hash($pw, PASSWORD_DEFAULT) === $h1); // 初心者がやりがちな比較
var_dump(password_verify($pw, $h1), password_verify('tiger2025', $h1));
print_r(password_get_info($h1));
$2y$12$ml76qZwQkp2tlUE0cq8SROecy9eBO0VCT.qA6bCwVIdxwMoL.RBMq
$2y$12$29U5HBehXh1Llpb/miK5W.itq90n0dJTPPg7FSeA77my08szzXrES
bool(false)
bool(false)
bool(true)
bool(false)
Array
(
[algo] => 2y
[algoName] => bcrypt
[options] => Array
(
[cost] => 12
)
)
文字列の読み方:$2y$ が bcrypt、12 がコスト(2¹² ラウンド)、続く22文字がソルト、残りがハッシュ本体です。照合に必要な情報はすべて中に入っています。だから3行目が大事です。ログイン時に入力をハッシュして文字列比較してはいけません。保存値からソルトとコストを読み取ってくれる password_verify() を呼びます。
ローカルでの解読デモ:ソルトなし SHA-256 対 20語のリスト
まずは古い悪いやり方を、実際の攻撃者と同じ手順で攻めます。ユーザーもリストも自作で、マシンの外には何も出ていません。
# ローカル専用デモ:自作ユーザーと自作の20語リスト
import hashlib, time
users = {'tanaka': 'sakura123', 'suzuki': 'neko2026', 'sato': 'sakura123', 'ito': 'V7#qLm2!pZ9x'}
words = ("password 123456 qwerty sakura sakura123 neko neko2026 nekoneko tokyo2026 arigato "
"himawari daisuki ganbare yamada letmein admin welcome summer hello iloveyou").split()
leak = {u: hashlib.sha256(p.encode()).hexdigest() for u, p in users.items()} # bad old storage
for u, h in leak.items():
print(f"{u:7s} {h[:20]}...")
t = time.perf_counter_ns()
table = {hashlib.sha256(w.encode()).hexdigest(): w for w in words} # 20 hashes, once
for u, h in leak.items():
print(f"{u:7s} -> {table.get(h, '(not in list)')}")
print(f"{len(words)} words, {len(leak)} users, {(time.perf_counter_ns() - t)/1000:.0f} µs")
tanaka 0ae74d2c72af323cf1bf...
suzuki b9c3e921489dbcd95c43...
sato 0ae74d2c72af323cf1bf...
ito 3655812bce4e006ad2a4...
tanaka -> sakura123
suzuki -> neko2026
sato -> sakura123
ito -> (not in list)
20 words, 4 users, 16 µs
注目点は2つです。田中さんと佐藤さんのハッシュが同じなので、攻撃者は解読する前から「この2人は同じパスワード」と分かります。そしてリストは1回ハッシュしただけで全員に使い回せました。これを巨大な規模でやるのが事前計算テーブルです。
ユーザーごとに16バイトのランダムなソルトを足すと、重複は見えなくなりました(4つのハッシュがすべて別の値に)。しかし攻撃者はリストをユーザーごとに1回ずつ回すだけです。SHA-256 の呼び出し37回、24マイクロ秒で、同じ3人分が見つかりました。ソルトは必要ですが、それだけでは足りません。
次に同じユーザーを password_hash()(bcrypt、コスト12)で保存し、単語ごとに password_verify() で攻めます。
tanaka $2y$12$4BTMq7l5sTkNpICQQTwlou3QG1AwXIV3jSpR9kBVPFe0eZtiJ3i9O
suzuki $2y$12$PZeMB86h3Ifh/NOdjjaUZOby7L4du4M28QKA3BxlBMHwE31Z2S1Ky
sato $2y$12$f/4p/ZpQtoz.lTFQbTkxF.Ko4jEH4ehsxRVIIDu0ZomhEZP/T2js2
ito $2y$12$AeeFkiBqUnPtBqm1f4aKr.JlaOyAo14.jkbq3X6orzgNvD2b6lDUC
tanaka -> sakura123
suzuki -> neko2026
sato -> sakura123
ito -> (not in list)
37 password_verify calls, 8.7 s
正直に書くと、bcrypt は sakura123 を救えませんでした。どんなリストでも上位20語に入っているパスワードは、ハッシュが何であれ破られます。bcrypt が変えたのは値段です。24マイクロ秒が8.7秒、約36万倍になりました。数百万語の本物のリストが相手なら、この倍率が「数分」と「数年」の差になります。伊藤さんのランダムな12文字は、どちらの攻撃でも見つかりませんでした。
実測:推測1回あたりのコスト
各アルゴリズムを1コアで計測しました。PHP の行はウォームアップ1回の後に11回実行した中央値(SHA-256 は101回)です。scrypt の行と PBKDF2 の1行は openssl kdf で、プロセス起動の約6 ms を差し引いています。
同じ PBKDF2(HMAC-SHA256 を60万回)でも、PHP の hash_pbkdf2() は794 ms、Python の hashlib.pbkdf2_hmac() は178 ms、OpenSSL は66 ms でした。ログイン処理は遅い実装の値段を払い、攻撃者は速い実装を使います。 反復回数は、自分のサーバーの体感ではなく最速の数字から決めてください。
同じデータを「1コアで N 回推測するのにかかる時間」に直します。
| 保存方式(1コア、M1 Pro) | 推測/秒 | 20語リスト | 100万語リスト | 英小文字8文字すべて(26⁸) |
|---|---|---|---|---|
| SHA-256(openssl) | 7,890,000 | 2.5 µs | 0.13 秒 | 7.4 時間 |
| PBKDF2-SHA256、60万回(openssl) | 15.2 | 1.3 秒 | 18.3 時間 | 437 年 |
| Argon2id、64 MiB、t=4 | 6.6 | 3.0 秒 | 1.8 日 | 1,005 年 |
| bcrypt、コスト12 | 4.2 | 4.7 秒 | 2.7 日 | 1,567 年 |
| scrypt、N=2¹⁷、r=8 | 3.3 | 6.0 秒 | 3.5 日 | 1,993 年 |
実際の攻撃者は GPU や多数のマシンを使うので、どのマスも大きな数で割って読んでください(ここでは測っていません)。大事なのは行どうしの比です。Argon2id と scrypt には表に出ないもう1つの守りがあります。推測1回ごとに 64 MiB や 128 MiB のメモリを必要とするので、GPU の並列性が効きにくいのです。
ログイン時にコストを上げる:password_needs_rehash
PHP 8.4 で bcrypt の既定コストが10から12に上がりました。数年後には自分でもまた上げたくなるはずです。保存済みのハッシュをオフラインで作り直すことはできません(平文がないので)。でも、ログインに成功した瞬間だけは平文を持っています。
<?php
$users = ['alice' => password_hash('sunshine-river-42', PASSWORD_BCRYPT, ['cost' => 10])];
const DUMMY = '$2y$12$ml76qZwQkp2tlUE0cq8SROecy9eBO0VCT.qA6bCwVIdxwMoL.RBMq';
function login(array &$users, string $name, string $pw): bool {
$hash = $users[$name] ?? null;
if ($hash === null) { // 存在しないユーザーでも同じだけ時間を使う
password_verify($pw, DUMMY);
return false;
}
if (!password_verify($pw, $hash)) return false;
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$users[$name] = password_hash($pw, PASSWORD_DEFAULT);
echo "rehashed: ", substr($hash, 0, 7), " -> ", substr($users[$name], 0, 7), "\n";
}
return true;
}
foreach ([['alice', 'wrong'], ['alice', 'sunshine-river-42'], ['alice', 'sunshine-river-42'],
['mallory', 'x']] as [$u, $p]) {
$t = hrtime(true);
$ok = login($users, $u, $p);
printf("%-8s %-18s %-5s %6.1f ms\n", $u, $p, $ok ? 'OK' : 'FAIL', (hrtime(true) - $t) / 1e6);
}
alice wrong FAIL 59.6 ms
rehashed: $2y$10$ -> $2y$12$
alice sunshine-river-42 OK 299.0 ms
alice sunshine-river-42 OK 240.6 ms
mallory x FAIL 242.2 ms
時間の列に細かい落とし穴が見えます。未登録ユーザー用のダミーハッシュはコスト12なのに、alice の古いハッシュはコスト10でした。そのため「パスワード違い」(59.6 ms)と「ユーザーなし」(242.2 ms)が、ハッシュが更新されるまで区別できてしまいます。ダミーは実際のハッシュと同じコストにしてください。時間差の漏れは講座6で詳しく扱います。
bcrypt の72バイト制限を再現する
bcrypt はパスワードの先頭72バイトしか読みません。PHP 8.5.6 でも黙って切り捨てます。日本語のパスフレーズで試しました(有名な文はリストに入っているので、本当は使わないでください)。
<?php
$a = '吾輩は猫である。名前はまだ無い。どこで生れたかとんと見当がつかぬ。';
printf("%d chars, %d bytes\n", mb_strlen($a), strlen($a));
$h = password_hash($a . ' A', PASSWORD_BCRYPT);
var_dump(password_verify($a . ' B', $h)); // bcrypt:先頭72バイトしか見ない
var_dump(password_verify(substr($a, 0, 72), $h));
$h = password_hash($a . ' A', PASSWORD_ARGON2ID);
var_dump(password_verify($a . ' B', $h)); // Argon2id:入力全体を見る
33 chars, 99 bytes
bool(true)
bool(true)
bool(false)
末尾が違っても通り、先頭72バイト(=最初の24文字「吾輩は猫である。名前はまだ無い。どこで生れ」)だけでも通りました。英語なら72文字ですが、UTF-8 の日本語は1文字3バイトなので、24文字で上限です。長い日本語パスフレーズを勧めるサービスほど、この制限に当たります。
関連する罠がもう1つあります。PHP 8.5.6 の bcrypt は NUL バイトを拒否します。よくある「生の SHA-256 で前処理する」対策は約12%の確率で失敗します。32バイトのランダム値に \0 が含まれる確率は 1 − (255/256)³² ≈ 11.8% だからです。
first candidate with NUL in raw sha256: user-pass-2 (byte 12)
ValueError: Bcrypt password must not contain null character
ペッパー:DB の外に置く秘密
ペッパーは、すべてのパスワードにハッシュ前に混ぜるサーバー側の秘密です。ソルトと違って DB には保存せず、環境変数、シークレット管理サービス、DB サーバーからは読めないファイルに置きます。DB だけが漏れた場合、攻撃者は推測を1回も試せません。HMAC と Base64 を組み合わせると、上の bcrypt の罠も2つとも解決します。結果は44文字の ASCII で、NUL を含まず72バイト未満だからです。
<?php
$pepper = getenv('PW_PEPPER') ?: 'demo-only-pepper-change-me-32byt';
function prep(string $pw, string $pepper): string {
return base64_encode(hash_hmac('sha256', $pw, $pepper, true)); // 44 ASCII chars, no NUL
}
$a = '吾輩は猫である。名前はまだ無い。どこで生れたかとんと見当がつかぬ。';
$h = password_hash(prep($a . ' A', $pepper), PASSWORD_DEFAULT);
var_dump(password_verify(prep($a . ' A', $pepper), $h));
var_dump(password_verify(prep($a . ' B', $pepper), $h)); // 72バイトより後ろの違いも拒否
var_dump(password_verify(prep($a . ' A', 'stolen-db-no-pepper'), $h));
bool(true)
bool(false)
bool(false)
ペッパーの代償は運用です。失くせば誰もログインできず、変えるには全員の再ログインを待つしかありません。ペッパーを使わず、72バイト制限のない Argon2id を選ぶチームも多く、どちらも妥当です。妥当でないのは、ペッパーをリポジトリにコミットすることです。
Python:標準ライブラリに bcrypt はない
Python 3.9 の標準ライブラリには bcrypt も Argon2 もありません。この Mac では hashlib が LibreSSL 2.8.3 でビルドされているため、scrypt もありませんでした。
AttributeError: module 'hashlib' has no attribute 'scrypt'
ModuleNotFoundError: No module named 'bcrypt'
hashlib.pbkdf2_hmac は必ずあり、60万回の反復は OWASP の現在の PBKDF2-HMAC-SHA256 の目安と同じです。PHP と同じように、アルゴリズム・反復回数・ソルトをハッシュと一緒に保存します。
import base64, hashlib, hmac, secrets
ITER = 600_000
def hash_password(pw: str) -> str:
salt = secrets.token_bytes(16)
dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, ITER)
b = lambda x: base64.b64encode(x).decode()
return f"pbkdf2_sha256${ITER}${b(salt)}${b(dk)}"
def verify_password(pw: str, stored: str) -> bool:
algo, it, salt, dk = stored.split("$")
test = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), base64.b64decode(salt), int(it))
return hmac.compare_digest(test, base64.b64decode(dk))
s = hash_password("さくら咲く-2026")
print(s)
print(verify_password("さくら咲く-2026", s), verify_password("さくら咲く-2025", s))
pbkdf2_sha256$600000$V8Uq/8P2xUc71wW9hU+IZQ==$77qZCwGGrC9MmLkOJLCqcCfOIHwgHdogljEgFnuNf7s=
True False
実際のプロジェクトでは、メンテナンスされている argon2 や bcrypt パッケージを追加するのが普通です(ここではインストールしていません)。Django などのフレームワークも、この アルゴリズム$反復回数$ソルト$ハッシュ の形で保存しています。
よくある間違い:何が起きて、どう直すか
1. ログイン入力をハッシュして文字列比較する。 起きること:毎回新しいソルトを使うので password_hash($pw) === $stored は常に false です(上で確認しました)。すると誰かがソルトを外して「直して」しまいます。直し方:password_verify()。
2. PHP でハッシュを == で比べる。
string(32) "0e462097431906509019562988736854"
string(32) "0e830400451993494058024219903391"
bool(true) // md5('240610708') == md5('QNKCDZO')
bool(false) // ===
bool(false) // hash_equals()
どちらも「0 × 10 の何乗」という数値に見えるので、PHP 8.5 でも == は数値として比較します。直し方:パスワードは password_verify()、それ以外の秘密の比較は hash_equals()。
3. DB の列が短すぎる。 起きること:bcrypt は60文字、Argon2id は今回の実行で97文字で、PASSWORD_DEFAULT は将来変わり得ます。CHAR(60) だと将来のハッシュが黙って切れます。直し方:PHP マニュアルの推奨どおり VARCHAR(255)。
4. パスワードを trim・小文字化・短く制限する。 起きること:ハッシュ前の trim() や strtolower() はパスワードを黙って弱くし、16文字上限はパスフレーズを締め出します。直し方:入力されたバイト列をそのまま使い、少なくとも64文字まで許可し(NIST SP 800-63B の指針)、Unicode 正規化をするなら NFC で一貫させます。全角・半角の扱いも登録時とログイン時で揃えてください。
5. 「ユーザーが存在しない」と「パスワードが違う」でメッセージを分ける。 起きること:どのメールアドレスが登録済みか、攻撃者に分かります。直し方:メッセージは1つにし、上のログインコードのように同じコストでダミー照合をします。
6. ログイン試行回数を制限しない。 起きること:遅いハッシュが守るのは漏れた DB であって、稼働中のログインフォームではありません。直し方:アカウント単位・IP 単位の試行回数制限と、登録時の漏えい済みパスワードの確認。遅いハッシュはサーバーの CPU も食う点にも注意です。1回237 ms なら、1コアでさばけるログインは毎秒約4回です。
アルゴリズムとコストの選び方
| 状況 | 選択 | 出発点のパラメータ(最新の OWASP 指針を確認) |
|---|---|---|
| 新規の PHP プロジェクト | PASSWORD_ARGON2ID か PASSWORD_DEFAULT(bcrypt) |
Argon2id は m=19 MiB、t=2、p=1 以上。bcrypt はコスト10以上、PHP 既定は12 |
| 長いパスフレーズ、日本語入力 | Argon2id、または HMAC + Base64 前処理付きの bcrypt | 同上 |
| Python で標準ライブラリだけ | hashlib.pbkdf2_hmac |
SHA-256、60万回、16バイトのソルト |
| FIPS などの準拠要件 | PBKDF2-HMAC-SHA256 | 60万回以上 |
| 既存の MD5/SHA-1 テーブル | 今すぐ password_hash(旧md5) で包み、次回ログインで置き換える |
どちらの形式の行か分かる印を付ける |
最後に、本番と同じハードウェアでコストを調整します。ピーク時に1回100〜500 ms 程度が目安です。
正直な限界とバージョンの注記
- PHP 8.5.6、Python 3.9.6(LibreSSL 2.8.3)、OpenSSL 4.1.0 で実行しました。
password_hash()は PHP 5.5、Argon2i は 7.2、Argon2id は 7.3 で追加されました。bcrypt の既定コストは PHP 8.4 で10から12になり、8.5.6 で12であることを確認しました。 - NUL バイトでの
ValueErrorと、72バイトでの黙った切り捨ては PHP 8.5.6 でのみ確認しました。古い PHP の NUL バイトの扱いは違います(ここでは実行していません)。 - 速度はすべて M1 Pro の1コアです。GPU を使う攻撃者の数字は、速いハッシュに対してはずっと大きく、メモリを大量に使うハッシュに対してはそれほどでもありません。GPU は動かしていません。
- OWASP のパラメータの推奨は変わります。表の数字は執筆時点の OWASP Password Storage Cheat Sheet の値です。選ぶ前に確認し直してください。
- 攻撃はすべて、ローカルマシン上で自作のユーザーと自作の20語リストを使いました。実際のリストは数百万語あります。
練習問題
1. ユーザーテーブルに、ソルトなしの sha256(password) で保存した1万行があります。全員にパスワード再設定をお願いせずに、今日から守るにはどうしますか。
2. bcrypt コスト12の実測値236.7 ms を使うと、1コアで毎秒何回ログインを処理できますか。それは試行回数制限のないログイン API について何を示していますか。
3. あるユーザーが あいうえおかきくけこさしすせそ を2回繰り返したパスフレーズ(30文字)を設定しました。bcrypt はどこから後ろを無視しますか。
解答
1. 古いハッシュを包みます。password_hash(旧sha256の16進, PASSWORD_DEFAULT) を保存し、その行に「包み済み」の印を付けます。ログイン時は password_verify(hash('sha256', $pw), $stored) で照合し、成功したら新しい password_hash($pw) を保存して印を外します。全行がすぐに守られ、ユーザーがログインするたびに通常の形へ移っていきます。
2. 1000 ÷ 236.7 ≈ 毎秒4.2回です。攻撃者はそれよりずっと多くのリクエストを送って CPU を使い切れるので、ハッシュが遅くてもログイン API には試行回数制限が必要です。
3. UTF-8 でひらがなは1文字3バイトなので、72バイトは24文字です。25文字目以降、つまり2回目の繰り返しの最後の6文字「こさしすせそ」が無視されます(30 − 24 = 6)。PHP 8.5.6 で、先頭24文字だけの文字列が password_verify を通ることも確認しました。Argon2id を使うか、HMAC + Base64 で前処理してください。
次へ
よくある質問
SHA-256 にソルトを付ければパスワード保存に十分ですか?
十分ではありません。ソルトは事前計算した表を無効にし、同じパスワードを別のハッシュに見せますが、SHA-256 自体は速いままです。PHP は M1 Pro の1コアでソルト付き SHA-256 を毎秒約218万回計算しました。20語のリストでも弱いパスワードは24マイクロ秒で全部見つかりました。bcrypt、Argon2id、scrypt、十分な反復回数の PBKDF2 のような、わざと遅いハッシュが必要です。
bcrypt は本当に72バイトより後ろを無視しますか?
はい。PHP 8.5.6 で、99バイトの日本語の文に「 A」を付けて bcrypt でハッシュすると、「 B」に変えた文でも、先頭72バイトだけの文字列でも password_verify が true になりました。日本語の文字は UTF-8 で1文字3バイトなので、わずか24文字が上限です。Argon2id にはこの制限がありません。bcrypt を使うなら HMAC と Base64 で前処理します。
古いユーザーのハッシュを強いコストや Argon2id に移すには?
ログイン時、password_verify() が成功した直後に、現在のアルゴリズムと設定で password_needs_rehash() を呼びます。true なら、いま検証した平文パスワードをハッシュし直して行を更新します。実行例ではコスト10のハッシュが最初のログインでコスト12になりました。ログインしないユーザーは古いハッシュのままです。