パスワードのハッシュ化とは、パスワードを元に戻せない値に変換して保存することで、新規実装ではArgon2id、使えない環境ではscryptかbcryptを選ぶのが現在の定石です。 ポイントは「わざと遅く、メモリも使うハッシュ関数」を、推奨パラメータで使うことです。SHA-256のような高速なハッシュ関数を1回かけるだけでは、パスワードの保存には向きません。

この記事では、OWASP Password Storage Cheat Sheet の推奨値を表にまとめたうえで、PythonとNode.jsで実際にハッシュ時間を測った結果、bcryptの72バイト制限をライブラリごとに再現した結果、そして既存のbcryptハッシュをログイン時にArgon2idへ移し替えるコード(実行確認済み)を紹介します。「どのアルゴリズムを、どの設定で、どう移行するか」まで一度に判断できる内容です。

パスワードハッシュ化の早見表

アルゴリズム 推奨度 OWASPの推奨パラメータ(代表値) 注意点
Argon2id 第一候補 m=19456(19 MiB), t=2, p=1 メモリ量・回数・並列度の3つを設定
scrypt Argon2idが使えないとき N=2^17(128 MiB), r=8, p=1 メモリを多く使う
bcrypt 既存システムや、上2つが使えないとき cost(work factor)10以上 入力は最大72バイト
PBKDF2 FIPS準拠が必要なとき HMAC-SHA256で600,000回 GPUに対する耐性は上3つより低い
SHA-256単体・MD5 パスワード保存には使わない ― 高速すぎて総当たりに弱い

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「パスワードのハッシュ化|bcrypt・Argon2の推奨値と実測・移行コード」

なぜ「遅いハッシュ」が必要なのか

パスワード用ハッシュ関数は、攻撃者の総当たりを遅くするためにあえて計算コストを高くしています。 データベースが漏えいした場合、攻撃者は手元でパスワード候補を次々とハッシュ化し、一致するものを探します。1回の計算が速いほど、試せる候補が増えます。

SHA-256はどれくらい速いか(実測)

このマシン(Intel Xeon 2.10GHz・2コア、Python 3.11)で、SHA-256を100万回計算した結果です。

import hashlib, time
pw = b"correct horse battery staple"
s = time.perf_counter()
for i in range(1_000_000):
    hashlib.sha256(pw).digest()
el = time.perf_counter() - s
print(f"{el:.2f} s -> {1_000_000/el:,.0f} hashes/s")
0.48 s -> 2,072,297 hashes/s

1コアのPythonでも1秒に約200万回です。専用のGPUツールではさらに桁違いに速くなります。一方、後で紹介するbcrypt(cost=12)は同じマシンで1回約300ミリ秒でした。1回あたりの計算時間の差が、そのまま攻撃にかかる時間の差になります。

方式(このマシンでの実測) 1回あたり 1秒で試せる回数の目安
SHA-256 1回 約0.0005ミリ秒 約200万回
PBKDF2-HMAC-SHA256 60万回 約170ミリ秒 約6回
bcrypt cost=12 約309ミリ秒 約3回
Argon2id m=19456,t=2,p=1 約24ミリ秒 約40回(ただし1回に19 MiB使用)

Argon2idは時間だけ見るとbcryptより短いですが、1回ごとに大きなメモリを要求するため、GPUや専用回路で大量に並列実行しにくいのが強みです。

ソルトとペッパーの違い

用語 何か どこに保存するか 目的
ソルト ユーザーごとのランダム値 ハッシュと一緒にDBへ 同じパスワードでも別のハッシュにし、事前計算表(レインボーテーブル)を無効化
ペッパー システム共通の秘密値 DBとは別(シークレット管理・HSM) DBだけが漏れた場合の追加の防御
ストレッチング ハッシュを何度も繰り返す パラメータとしてハッシュ文字列に含む 1回の計算を遅くする

bcrypt・Argon2・scryptはソルトを自動生成し、ハッシュ文字列の中に含めてくれます。 自分でソルト列をDBに追加する必要はありません。

$argon2id$v=19$m=19456,t=2,p=1$<ソルト>$<ハッシュ本体>
$2b$12$<22文字のソルト><31文字のハッシュ本体>

アルゴリズム・パラメータ・ソルトが1つの文字列に入っているので、後からパラメータを強化しても、古いハッシュをそのまま検証できます。これが後述の「移行」を簡単にしてくれます。

OWASPの最新推奨パラメータ

OWASP Password Storage Cheat Sheet(2026年9月確認)では、次の設定がどれも同等の強さとして示されています。 CPUとメモリのどちらに余裕があるかで選びます。

Argon2id

m(メモリ) t(反復回数) p(並列度) 備考
47104(46 MiB) 1 1 Argon2iでは使わない
19456(19 MiB) 2 1 Argon2iでは使わない
12288(12 MiB) 3 1
9216(9 MiB) 4 1
7168(7 MiB) 5 1

scrypt・bcrypt・PBKDF2

アルゴリズム 推奨値
scrypt N=2^17(128 MiB), r=8, p=1 / N=2^16, r=8, p=2 / N=2^15, r=8, p=3 / N=2^14, r=8, p=5 / N=2^13, r=8, p=10
bcrypt work factor 10以上、入力は72バイトまで
PBKDF2-HMAC-SHA256 600,000回
PBKDF2-HMAC-SHA512 220,000回
PBKDF2-HMAC-SHA1 1,400,000回(既存システム向け。新規では選ばない)

数値は定期的に見直されるため、導入時には必ず公式ページで最新値を確認してください。 推奨値は「最低ライン」です。サーバーに余裕があれば、ログイン時の待ち時間が許容できる範囲で強くして問題ありません。

PythonとNode.jsでハッシュ時間を実測する

同じパラメータでも、実際にかかる時間はマシンによって変わります。 本番に近い環境で測ってから値を決めるのが確実です。ここでは次の環境で測りました。

  • CPU:Intel Xeon Processor @ 2.10GHz(2コア)、メモリ7.8GiB
  • Python 3.11 / argon2-cffi 25.1.0 / bcrypt 5.0.0
  • Node.js 22.22.2 / argon2 0.45.1 / bcrypt 6.0.0
  • 各設定を数回実行した中央値

計測コード(Python)

import time, statistics, bcrypt
from argon2 import PasswordHasher

pw = b"correct horse battery staple"

def median_ms(f, n=5):
    xs = []
    for _ in range(n):
        s = time.perf_counter(); f(); xs.append((time.perf_counter() - s) * 1000)
    return statistics.median(xs)

for m, t in [(47104, 1), (19456, 2), (12288, 3), (9216, 4), (7168, 5)]:
    ph = PasswordHasher(time_cost=t, memory_cost=m, parallelism=1)
    print(f"argon2id m={m} t={t}: {median_ms(lambda: ph.hash(pw)):.1f} ms")

for cost in [10, 11, 12, 13, 14]:
    print(f"bcrypt cost={cost}: {median_ms(lambda: bcrypt.hashpw(pw, bcrypt.gensalt(cost)), 3):.1f} ms")

Node.jsも同じ考え方で、bcrypt.hash(pw, cost) と argon2.hash(pw, { type: argon2.argon2id, memoryCost, timeCost, parallelism }) を performance.now() で挟んで測りました。

実測結果(このマシンでの値)

設定 Python Node.js
Argon2id m=47104, t=1, p=1 51.0 ms 50.7 ms
Argon2id m=19456, t=2, p=1 24.1 ms 29.3 ms
Argon2id m=12288, t=3, p=1 21.8 ms 24.1 ms
Argon2id m=9216, t=4, p=1 21.2 ms 25.5 ms
Argon2id m=7168, t=5, p=1 20.4 ms 22.3 ms
Argon2id ライブラリ既定値(m=65536, t=3, p=4) 127.3 ms 127.9 ms
bcrypt cost=10 76.9 ms 71.9 ms
bcrypt cost=11 153.9 ms 142.6 ms
bcrypt cost=12 309.1 ms 282.6 ms
bcrypt cost=13 612.6 ms 560.8 ms
bcrypt cost=14 1263.4 ms (未計測)
PBKDF2-HMAC-SHA256 60万回 170.5 ms (未計測)
scrypt N=2^17, r=8, p=1 440.5 ms (未計測)

結果から読み取れることは次のとおりです。

  • bcryptはcostが1増えるごとに時間がほぼ2倍になります(costは2の指数)。
  • PythonとNode.jsはほぼ同じ速さでした。どちらもCで書かれた実装を呼んでいるためです。
  • argon2-cffiとnpmのargon2は、既定値がm=65536(64 MiB), t=3, p=4でした。OWASPの最低ラインより強い設定です。既定値のままでも問題ありませんが、同時ログイン数が多い場合は「64 MiB × 同時実行数」のメモリが必要になる点を見込んでおきます。

パラメータの決め方

手順 やること
1 本番と同じスペックのサーバーで、上のような計測をする
2 1回のハッシュ化が数十〜数百ミリ秒に収まる設定を選ぶ
3 ピーク時の同時ログイン数 × メモリ使用量がサーバーに収まるか確認する
4 OWASPの推奨値を下回っていないか確認する
5 設定値はコードの定数か設定ファイルにまとめ、見直せるようにする

bcryptの72バイト制限を再現する

bcryptは入力の先頭72バイトしか使いません。 そのため、73バイト目以降だけが違う2つのパスワードが、同じものとして扱われることがあります。ライブラリによって挙動が違うので、実際に試しました。

Python(bcrypt 5.0.0):エラーで止まる

import bcrypt
a = b"A" * 72 + b"-first"
b = b"A" * 72 + b"-second"
try:
    h = bcrypt.hashpw(a, bcrypt.gensalt())
    print("hashed", bcrypt.checkpw(b, h))
except ValueError as e:
    print("ValueError:", e)
ValueError: password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])

Pythonのbcrypt 5.0.0では、72バイトを超える入力は例外になります。 気づかないまま切り捨てられることはありませんが、長いパスワードを入力したユーザーがエラーになるため、アプリ側での対応が必要です。

Node.js(bcrypt 6.0.0/bcryptjs 3.0.3):黙って切り捨て

import bcrypt from "bcrypt";
import bcryptjs from "bcryptjs";
const a = "A".repeat(72) + "-first", b = "A".repeat(72) + "-second";
for (const [name, lib] of [["bcrypt", bcrypt], ["bcryptjs", bcryptjs]]) {
  const h = await lib.hash(a, 10);
  console.log(name, "hash ok; compare(other) =", await lib.compare(b, h));
}
console.log("truncates?", bcryptjs.truncates(a));
bcrypt hash ok; compare(other) = true
bcryptjs hash ok; compare(other) = true
truncates? true

Node.jsの2つのライブラリは、別のパスワードでも照合が成功しました。 bcryptjsには、入力が切り捨てられるかを判定する truncates() が用意されています。

日本語パスワードは早く72バイトに届く

UTF-8では、ひらがな・漢字は1文字3バイトです。

"パスワード" → 15 bytes
"あ" × 24文字 → 72 bytes(ここまでOK)
"あ" × 25文字 → Python の bcrypt 5.0.0 では ValueError

日本語を許可するなら、文字数ではなくバイト数で考える必要があります。

対策の選び方

対策 内容 向いている場面
Argon2idを使う 72バイト制限がない 新規開発
入力を72バイト以下に制限 バリデーションでバイト数をチェック 既存のbcryptをそのまま使う
HMACで事前ハッシュ 下のコードのように固定長にしてからbcrypt 長いパスワードを許可したい既存システム

OWASPは事前ハッシュの例として bcrypt(base64(hmac-sha384(data:$password, key:$pepper)), $salt, $cost) を示しています。Base64にするのは、生のバイト列に含まれるNULバイトで入力が途切れる実装への対策です。OWASPは、鍵なしのSHA-2などで単純に事前ハッシュすると、NULバイトの問題や「password shucking(別サービスから漏れたハッシュとの突き合わせ)」の危険があると注意しています。秘密鍵(ペッパー)を使うHMACにするのがポイントです。

import hmac, hashlib, base64, os, bcrypt

PEPPER = os.environ["PASSWORD_PEPPER"].encode()   # シークレット管理から渡す

def prehash(pw: str) -> bytes:
    mac = hmac.new(PEPPER, pw.encode(), hashlib.sha384).digest()
    return base64.b64encode(mac)                  # 64バイトのASCII

h = bcrypt.hashpw(prehash("A" * 72 + "-first"), bcrypt.gensalt(12))
print(len(prehash("A" * 72 + "-first")), "bytes")
print(bcrypt.checkpw(prehash("A" * 72 + "-first"), h),
      bcrypt.checkpw(prehash("A" * 72 + "-second"), h))
64 bytes
True False

(実行時は PASSWORD_PEPPER にデモ用の値を設定しました。)73バイト目以降の違いも正しく区別できるようになりました。この方式はペッパーも兼ねるため、ペッパーを紛失するとすべてのハッシュが検証できなくなる点に注意します。

定数時間比較とライブラリの検証関数

ハッシュの照合は、ライブラリの検証関数(verify / checkpw / compare)を使うのが基本です。 自分で == で比べると、先頭から1文字ずつ比較して違った時点で終わる実装の場合、処理時間の差から情報が漏れる可能性があります(タイミング攻撃)。

やりたいこと Python Node.js
Argon2の検証 PasswordHasher().verify(hash, pw) argon2.verify(hash, pw)
bcryptの検証 bcrypt.checkpw(pw, hash) bcrypt.compare(pw, hash)
自前のバイト列比較 hmac.compare_digest(a, b) crypto.timingSafeEqual(a, b)

自前でトークンやHMACを比べる場面では、定数時間比較の関数を使うと覚えておくと安心です。

既存ハッシュからArgon2idへ移行する

移行の基本は「ログインに成功した瞬間に、平文パスワードで新方式のハッシュを作り直す」ことです。 ログイン時だけは平文が手元にあるため、ユーザーに再設定をお願いせずに移し替えられます。

flowchart TD
    A[ログイン要求] --> B{保存ハッシュの形式}
    B -->|$argon2id$| C[Argon2idで検証]
    B -->|$2b$ など bcrypt| D[bcryptで検証]
    C -->|成功| E{パラメータが古い?}
    E -->|はい| F[新パラメータで再ハッシュして保存]
    E -->|いいえ| G[ログイン成功]
    F --> G
    D -->|成功| H[Argon2idで再ハッシュして保存]
    H --> G
    C -->|失敗| X[ログイン失敗]
    D -->|失敗| X

Pythonの移行コード(実行確認済み)

import bcrypt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHashError

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

# 疑似的なユーザーテーブル(ユーザー名 -> 保存済みハッシュ)
users = {"alice": bcrypt.hashpw(b"s3cret-alice", bcrypt.gensalt(10)).decode()}

def verify_and_upgrade(username: str, password: str) -> bool:
    stored = users[username]
    if stored.startswith("$argon2id$"):
        try:
            ph.verify(stored, password)
        except VerifyMismatchError:
            return False
        if ph.check_needs_rehash(stored):          # パラメータ変更にも追従
            users[username] = ph.hash(password)
        return True
    if stored.startswith(("$2a$", "$2b$", "$2y$")):  # 旧方式: bcrypt
        if not bcrypt.checkpw(password.encode(), stored.encode()):
            return False
        users[username] = ph.hash(password)        # ログイン成功時に Argon2id へ
        return True
    raise InvalidHashError("unknown hash format")

print("before :", users["alice"][:7])
print("wrong  :", verify_and_upgrade("alice", "nope"), users["alice"][:7])
print("correct:", verify_and_upgrade("alice", "s3cret-alice"), users["alice"][:30])
print("again  :", verify_and_upgrade("alice", "s3cret-alice"))

ph = PasswordHasher(time_cost=3, memory_cost=19456, parallelism=1)  # 設定を強化
print("needs_rehash after t=3:", ph.check_needs_rehash(users["alice"]))
before : $2b$10$
wrong  : False $2b$10$
correct: True $argon2id$v=19$m=19456,t=2,p=1
again  : True
needs_rehash after t=3: True

パスワードを間違えたときはハッシュを変えず、正しくログインできたときだけArgon2idに置き換わっています。 check_needs_rehash() を使うと、将来パラメータを強化したときも同じ仕組みで少しずつ更新できます。

ログインしないユーザーの扱い

ログイン時の再ハッシュだけでは、長くログインしていないユーザーの古いハッシュが残ります。OWASPは次の2つの方法を挙げています。

方法 内容 注意点
一定期間後に古いハッシュを無効化 長期間ログインのないユーザーはパスワード再設定を必須にする 再設定の導線(メール等)が必要
古いハッシュを新方式で包む 例:argon2id(旧MD5ハッシュ) として保存し、検証時も同じ手順で計算 形式の識別子を別に持ち、ログイン時に本来の形式へ置き換える

実務では「ログイン時の再ハッシュ」を先に入れ、残りを一定期間後に処理する組み合わせが進めやすいです。

Spring Securityでの書き方(DelegatingPasswordEncoder)

Javaの場合、Spring Securityの DelegatingPasswordEncoder が、この「形式ごとに検証して新方式へ移す」仕組みを標準で持っています。 保存形式は {id}encodedPassword で、先頭の {bcrypt} や {argon2} を見て検証に使うエンコーダーを選びます。以下は Spring Security 7.1 の公式ドキュメントに沿ったコードです(この環境では外部ライブラリを取得できないため未実行)。

// 既定: 新しいパスワードは bcrypt で保存される
PasswordEncoder passwordEncoder =
        PasswordEncoderFactories.createDelegatingPasswordEncoder();

新しいパスワードをArgon2idで保存したい場合は、idForEncode を "argon2" にした独自の DelegatingPasswordEncoder を作ります。

String idForEncode = "argon2";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());

PasswordEncoder passwordEncoder = new DelegatingPasswordEncoder(idForEncode, encoders);

保存される値の例(公式ドキュメントより):

{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
{argon2}$argon2id$v=19$m=4096,t=3,p=1$...
ポイント 内容
Argon2PasswordEncoderの依存 BouncyCastle が必要
既存の {bcrypt} ハッシュ そのまま検証できる
ログイン時の自動更新 UserDetailsPasswordService を実装すると、認証成功時に新方式で保存し直す仕組みを使える

defaultsForSpringSecurity_v5_8() の具体的なパラメータは、利用するバージョンのJavadocで確認し、OWASPの推奨値と比べておくと安心です。

よくある失敗と直し方

よくある失敗 起きること こうするとよい
SHA-256を1回だけかける 漏えい時に高速に総当たりされる Argon2id / scrypt / bcrypt を使う
ソルトを全ユーザー共通にする 同じパスワードが同じハッシュになる ライブラリの自動ソルトに任せる
ペッパーをDBの同じテーブルに置く DB漏えい時にペッパーも漏れる シークレット管理やHSMに置く
bcryptで長いパスワードを許可 72バイト以降が無視される/例外になる バイト数制限か事前ハッシュ、またはArgon2id
自前の == で照合 タイミング差が出る可能性 ライブラリの検証関数を使う
パラメータをコードに散らばらせる 強化するときに漏れが出る 1か所にまとめ、check_needs_rehash で追従
移行のために全ユーザーに再設定を依頼 ユーザーの負担が大きい ログイン成功時に再ハッシュする

よくある質問

Q. bcryptとArgon2idはどちらを選べばよいですか? A. 新規開発ならArgon2idがおすすめです。OWASPの第一候補で、72バイト制限もありません。既存システムでbcryptが動いていて、cost 10以上・72バイト対策ができているなら、急いで入れ替える必要はありません。移行するときはログイン時の再ハッシュで少しずつ進められます。

Q. bcryptのcostはいくつにすればよいですか? A. OWASPの最低ラインは10です。今回の実測ではcost=10で約75ミリ秒、cost=12で約300ミリ秒でした。本番サーバーで測り、ログインの待ち時間として許容できる最大の値を選ぶのが目安です。

Q. ペッパーは必須ですか? A. 必須ではありませんが、DBだけが漏れた場合の追加の防御になります。導入するなら、DBとは別の場所(シークレット管理やHSM)に保管し、ペッパーを変えたときの運用(全ハッシュの再生成が必要になる等)も決めておきます。

Q. ハッシュ化すれば暗号化は不要ですか? A. 目的が違います。ハッシュ化は元に戻せない変換、暗号化は鍵で元に戻せる変換です。パスワードは照合できればよいので、元に戻す必要のないハッシュ化を使います。通信経路の保護(HTTPS)は別途必要です。

まとめ

  • パスワードは「遅いハッシュ関数」で保存する。第一候補はArgon2id
  • OWASP推奨の代表値は Argon2id m=19456, t=2, p=1、bcryptは cost 10以上、PBKDF2-HMAC-SHA256は 60万回
  • 実測では SHA-256は1秒に約200万回、bcrypt cost=12は1回約300ミリ秒で、差は歴然
  • bcryptは72バイトまで。Pythonのbcrypt 5.0.0は例外、Node.jsのbcrypt/bcryptjsは黙って切り捨てた
  • 照合は ライブラリの検証関数、自前比較は定数時間比較で
  • 移行は ログイン成功時の再ハッシュ と check_needs_rehash で段階的に進められる

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「パスワードのハッシュ化|bcrypt・Argon2の推奨値と実測・移行コード」

株式会社RJCでは、パスワード保存のような基本となるセキュリティ実装を、実際に手を動かして確かめながらチームで知見を共有しています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?