概要
SNS、ネットショッピング、大学のポータルサイト、アルバイト先の勤怠システム。
私たちは普段、いろいろなサービスでIDとパスワードを入力しています。
このとき、サービスの裏側では次のような確認が行われています。
入力されたパスワードは、このユーザーが登録したパスワードと同じか?
ここで少し気になるのが、サービス側がパスワードをどのように保存しているのか、という点です。
もし、データベースの中にパスワードがそのまま保存されていたらどうでしょうか。
万が一データベースが漏えいしたとき、利用者のパスワードがそのまま外部に出てしまいます。
同じパスワードを別のサービスでも使い回していた場合、被害はそのサービスの中だけでは終わらないかもしれません。
そのため、安全な設計では、パスワードをそのまま保存しません。
代わりに、パスワードから計算した「元に戻しにくい値」を保存し、ログイン時には入力されたパスワードから同じ値を計算して照合します。
ここでよく登場するのが、ハッシュ関数です。
ハッシュ関数は、データから固定長の値を作る技術です。
たとえば、ファイルの改ざん検知やデータの照合など、さまざまな場面で使われています。
しかし、ここで注意が必要です。
「パスワードはそのまま保存してはいけない」
「だからハッシュ化して保存する」
「では、SHA-256を1回かけて保存すれば安全」
このように考えたくなりますが、実はこれだけでは十分ではありません。
理由は、パスワード保存で本当に怖いのは、ハッシュ値から一発で元のパスワードを復元されることだけではないからです。
データベースが漏えいしたあと、攻撃者が自分のPCや専用機材を使って、大量の候補パスワードを試せることが問題になります。
たとえば、攻撃者は次のようなことを試せます。
password
123456
qwerty
password123
自分の名前 + 誕生日
サービス名 + 数字
それぞれの候補に同じハッシュ処理を行い、漏えいしたハッシュ値と一致するかを確認します。
一致すれば、「このユーザーのパスワードはこれだった」と推測できてしまいます。
つまり、パスワード保存では「元に戻せない」だけでなく、大量に試されても破られにくいことが重要です。
そこで使われるのが、パスワード保存向けに設計された仕組みです。
代表的なものには、Argon2id、scrypt、bcrypt、PBKDF2などがあります。
これらは、SHA-256のような通常のハッシュ関数とは目的が少し違います。
普通のハッシュ関数は、高速に計算できることが長所になる場面が多いです。
一方で、パスワード保存では、その「速さ」が攻撃者にも有利に働いてしまいます。
そのため、パスワード保存向けの方式では、あえて計算を重くしたり、メモリを多く使わせたりします。
正規ユーザーが1回ログインするだけなら少し待てば済みますが、攻撃者が何百万回、何億回と試す場合には、その少しの重さが大きな負担になります。
本記事では、パスワード保存について、次の流れで整理します。
- なぜパスワードをそのまま保存してはいけないのか
- 「ハッシュ化すれば安全」と言い切れない理由
- 普通のハッシュ関数とパスワード保存向けハッシュの違い
- ソルト、ストレッチング、コスト係数の役割
- Argon2id、scrypt、bcrypt、PBKDF2の概要
- パスワードハッシュだけでは守れないこと
この記事では、特定のライブラリの使い方や、各アルゴリズムの内部構造までは深く扱いません。
まずは、「なぜ普通のハッシュ関数をそのまま使ってはいけないのか」を理解することを目的にします。
この記事で分かること
- パスワードを平文で保存してはいけない理由
- 「ハッシュ化して保存すれば安全」と言い切れない理由
- SHA-256のような普通のハッシュ関数と、パスワード保存向け方式の違い
- ソルトが何を防ぎ、何を防げないのか
- ストレッチングやコスト係数がなぜ必要なのか
- Argon2id、scrypt、bcrypt、PBKDF2がどのような位置づけの方式なのか
- パスワードハッシュだけでは不十分な理由
対象読者
この記事は、次のような人を想定しています。
- 情報セキュリティを学び始めた人
- Webサービスのログイン機能の裏側に興味がある人
- 「パスワードはハッシュ化して保存する」と聞いたことがある人
- SHA-256、ソルト、bcrypt、Argon2などの違いがまだ曖昧な人
- 実装に入る前に、安全なパスワード保存の考え方を整理したい人
本記事で扱わないこと
本記事は、パスワード保存の考え方を整理することを目的にしています。
そのため、次の内容は深く扱いません。
- 各ハッシュ関数の内部構造
- Argon2id、scrypt、bcrypt、PBKDF2の厳密な数式
- 特定のプログラミング言語における実装手順
- 認証システム全体の詳細設計
- パスキーやFIDO2など、パスワードレス認証の詳細
ただし、パスワード保存はログイン機能全体の一部です。
そのため、最後の章では、MFA(多要素認証)、ログイン試行回数制限、TLS、セッション管理などにも軽く触れます。
また、本記事に出てくるコードは、考え方を理解するための学習用サンプルです。
実際のサービスでは、利用しているフレームワークやライブラリの公式ドキュメントを確認し、独自実装ではなく、検証された機能を使うことを前提にしてください。
コードを試す場合の前提
本記事では、考え方を確認するためにPythonの短いコードをいくつか使います。
コードを手元で試す場合は、次の前提で読んでください。
| 項目 | 内容 |
|---|---|
| 目的 | パスワード保存の考え方を理解するための学習用 |
| 想定環境 | Python 3.x系 |
| 標準ライブラリ |
hashlib、secrets、time、base64 など |
| 外部ライブラリ | Argon2idの例では argon2-cffi を使用 |
| 注意点 | 実サービスのパスワード保存処理へそのまま流用しない |
argon2-cffi の例を試す場合は、事前に次のようにインストールします。
pip install argon2-cffi
ただし、実際のWebサービスでは、利用しているフレームワークが提供する認証機能や、公式に推奨されているライブラリのドキュメントを確認してください。
全体の流れ
この記事では、次の順番で話を進めます。
1. いつものログイン画面の裏側で起きていること
まずは、よくあるログイン画面を思い浮かべてみます。
ユーザーID、メールアドレス、パスワードを入力して、「ログイン」ボタンを押す。
普段は数秒で終わる処理ですが、サービスの裏側では次のような確認が行われています。
入力されたパスワードは、このアカウントの持ち主が登録したものと一致しているか?
ここで大切なのは、サービス側が確認したいのは「パスワードそのものを知ること」ではなく、入力されたパスワードが正しいかどうか だという点です。
たとえば、受付で本人確認をするときに、受付係が知りたいのは「あなたが誰か」です。
必ずしも、あなたの大切な秘密を紙に書いて保管しておきたいわけではありません。
パスワード保存でも同じです。
サービス側に必要なのは、ログイン時に照合できるための情報です。
その情報を、元のパスワードが分かる形で保存してしまうと、万が一漏えいしたときの被害が大きくなります。
OWASPのPassword Storage Cheat Sheetでも、パスワードは平文で保存せず、Argon2id、bcrypt、PBKDF2などのパスワード保存向け方式を使って保護することが説明されています。
参考: OWASP Password Storage Cheat Sheet
1.1 登録時とログイン時に起きていること
パスワード保存の話は、まず「登録時」と「ログイン時」に分けると理解しやすくなります。
| タイミング | ユーザーがすること | サービス側がしたいこと |
|---|---|---|
| 登録時 | 新しいパスワードを入力する | 次回以降のログインで照合できる情報を保存する |
| ログイン時 | パスワードを入力する | 入力されたパスワードが登録時のものと一致するか確認する |
ここで、もっとも単純な作りにすると、登録時にパスワードをそのままデータベースへ保存し、ログイン時に入力値と比較する形になります。
登録時:
ユーザーが入力したパスワード: password123
データベースに保存する値: password123
ログイン時:
入力された値: password123
保存されている値: password123
一致したのでログイン成功
仕組みとしては分かりやすいです。
しかし、この方法には大きな問題があります。
データベースの中を見られた瞬間に、利用者のパスワードがそのまま分かってしまうからです。
そこで実際のパスワード保存では、元のパスワードそのものではなく、照合に使うための値を保存します。
この「照合に使うための値」を作るときに、ハッシュ関数やパスワードハッシュ方式が登場します。
1.2 「保存する」のではなく「照合できる形にする」
イメージとしては、次のような流れです。
この図では、説明を簡単にするために「照合用データ」と表現しています。
後の章で扱うように、実際にはここにソルト、コスト係数、Argon2idやbcryptなどの方式が関わってきます。
ここで押さえておきたいのは、パスワード保存の目的が「元のパスワードを後で取り出すこと」ではないという点です。
ログイン機能に必要なのは、次のような確認です。
入力されたパスワード → 同じ方法で照合用データを作る
保存済みの照合用データ → データベースから取り出す
2つが一致するか確認する
つまり、パスワードは「読める状態で保管する」のではなく、照合だけできる状態で保管する のが基本的な考え方です。
NIST SP 800-63Bでも、検証者はパスワードをオフライン攻撃に耐える形で保存し、ソルトとコスト係数を使う適切なパスワードハッシュ方式で保存するべきだと説明されています。
参考: NIST SP 800-63B - Authentication and Lifecycle Management
1.3 「パスワードを忘れたら再設定」が多い理由
ここで、身近な豆知識を1つ挟みます。
多くのサービスでは、パスワードを忘れたときに「現在のパスワードを表示する」のではなく、「パスワードを再設定する」流れになります。
これは、サービス側が元のパスワードを読める形で持っていない設計と相性がよいからです。
もちろん、実際の設計はサービスごとに異なります。
しかし、少なくとも安全なパスワード保存では、サービス側が利用者の現在のパスワードをそのまま取り出して教える必要はありません。
逆に、もしサービスから「あなたの現在のパスワードは○○です」とメールで送られてきた場合は注意が必要です。
そのサービスがパスワードを復元できる形で保存している可能性があるためです。
MITREのCWE-257では、パスワードを復元可能な形式で保存することは、悪意ある利用者によるパスワード使い回し攻撃の対象になると説明されています。
参考: CWE-257: Storing Passwords in a Recoverable Format
💡 豆知識
「パスワードを忘れたので、今のパスワードを教えてください」に対して、サービスが現在のパスワードを教えずに再設定を案内するのは不親切だからではありません。
むしろ、元のパスワードをサービス側が読める形で持たない設計にしていると考えると、自然な動きです。
ただし、初期パスワードや一時パスワードを発行するケースとは意味が異なるため、そこは分けて考える必要があります。
1.4 この章のまとめ
この章では、ログイン画面の裏側で行われている「照合」の考え方を整理しました。
ポイントは次の3つです。
- サービス側が本当に確認したいのは、パスワードそのものではなく「入力されたパスワードが正しいか」
- そのため、パスワードは元に戻せる形ではなく、照合できる形で保存することが望ましい
- 安全な保存を考えるには、登録時・ログイン時・漏えい時の3つの場面を分けて考える必要がある
ここまで見ると、「では、パスワードをそのまま保存するのはなぜ危険なのか」がより具体的に見えてきます。
次の章では、平文保存や復元可能な保存がなぜ危険なのかを、データベース漏えいとパスワード使い回しの観点から整理します。
2. パスワードをそのまま保存してはいけない理由
前の章では、パスワード保存の目的は「元のパスワードを後で読み出すこと」ではなく、ログイン時に照合できる形で持つことだと整理しました。
では、もしサービスがパスワードをそのまま保存していたら、何が起きるのでしょうか。
ここでは、あえて悪い例を使いながら、平文保存の危険性を見ていきます。
なお、この章のコードは安全な実装例ではありません。
「なぜ危険なのか」を理解するための説明用コードです。
OWASPのPassword Storage Cheat Sheetでは、パスワードは平文で保存せず、Argon2id、bcrypt、PBKDF2などの強く遅いハッシュアルゴリズムで保護することが推奨されています。
参考: OWASP Password Storage Cheat Sheet
また、MITRE CWE-256では、平文パスワードを保存すると、その保存場所を読める人がパスワードで保護されたリソースへアクセスできてしまう弱点として整理されています。
参考: CWE-256: Plaintext Storage of a Password
2.1 悪い例: パスワードをそのまま保存する
まずは、かなり単純化したユーザー登録処理を考えます。
# 注意: これは安全な実装ではありません。
# パスワードをそのまま保存してしまう「悪い例」です。
users = {}
def register_user(email: str, password: str) -> None:
"""ユーザー登録時に、メールアドレスとパスワードを保存する処理"""
# 悪い点:
# 入力されたパスワードを、そのまま辞書に保存している。
# 実際のシステムでこれをデータベースに保存すると、漏えい時にパスワードがそのまま見えてしまう。
users[email] = {
"password": password
}
def login(email: str, password: str) -> bool:
"""ログイン時に、入力されたパスワードと保存済みパスワードを比較する処理"""
# 登録されていないメールアドレスならログイン失敗
if email not in users:
return False
# 悪い点:
# 保存済みの平文パスワードと、入力されたパスワードを直接比較している。
return users[email]["password"] == password
register_user("alice@example.com", "password123")
print(users)
# 出力例:
# {'alice@example.com': {'password': 'password123'}}
この例では、users が簡易的なデータベースのような役割をしています。
実行すると、保存されている値の中に password123 がそのまま見えます。
ログイン機能としては、一見すると動いています。
登録したパスワードと入力されたパスワードを比較すれば、確かにログイン判定はできます。
しかし、問題は保存先を見られた瞬間にパスワードが読めてしまうことです。
2.2 データベースが漏えいすると、答え合わせ用の情報ではなく「答えそのもの」が出てしまう
パスワードを平文で保存している場合、データベースの漏えいはかなり深刻です。
メールアドレス 保存されているパスワード
alice@example.com password123
bob@example.com baseball2026
carol@example.com qwerty!
このような形で漏えいすると、攻撃者は追加の計算をしなくても、各ユーザーのパスワードを読めてしまいます。
これは、試験でたとえるなら「採点用のチェックリスト」ではなく、解答用紙そのものを渡してしまうような状態です。
パスワード保存で目指したいのは、少なくとも次のような形です。
入力されたパスワードが正しいかは確認できる
しかし、保存されている値だけを見ても元のパスワードは分かりにくい
平文保存では、この2つ目がまったく満たせません。
2.3 被害はそのサービスの中だけで終わらない
パスワード漏えいで怖いのは、そのサービスに不正ログインされることだけではありません。
多くの利用者は、複数のサービスで同じパスワード、または似たパスワードを使ってしまうことがあります。
そのため、あるサービスからパスワードが漏れると、別のサービスにも被害が広がる可能性があります。
もちろん、別サービスへの不正ログインは攻撃行為であり、絶対に行ってはいけません。
ここで大切なのは、1つのサービスの保存方法の甘さが、利用者の他のアカウントまで危険にさらす可能性があるという点です。
MITRE CWE-257でも、復元可能な形式でパスワードを保存すると、悪意ある利用者によるパスワード使い回し攻撃の対象になることが説明されています。
参考: CWE-257: Storing Passwords in a Recoverable Format
2.4 「Base64で隠す」は保存対策にならない
ここで、ありがちな誤解も見ておきます。
「そのまま保存するのは危険なら、Base64で変換して保存すればよいのでは?」
と思うかもしれません。
Base64は、データの表し方を変えるエンコードです。
暗号化でも、パスワードハッシュでもありません。
たとえば、次のように簡単に元に戻せます。
import base64
password = "password123"
# パスワードをBase64表現に変換する
# 注意: これは暗号化ではなく、単なる表現形式の変換です。
encoded = base64.b64encode(password.encode("utf-8"))
print(encoded.decode("utf-8"))
# 出力例: cGFzc3dvcmQxMjM=
# Base64表現を元の文字列に戻す
# 誰でも同じ手順で戻せるため、秘密を守る用途には向きません。
decoded = base64.b64decode(encoded).decode("utf-8")
print(decoded)
# 出力例: password123
見た目は cGFzc3dvcmQxMjM= のように変わっています。
しかし、これは読みにくくなっただけで、守られているわけではありません。
MITRE CWE-256でも、Base64のようなエンコードでパスワードを隠そうとしても、簡単に検出され、元の値へ戻せるため十分な保護にならないと説明されています。
参考: CWE-256: Plaintext Storage of a Password
💡 豆知識
エンコードは、データの表し方を変えることです。
たとえば、文字化けを防ぐ、バイナリデータをテキストとして扱う、といった目的で使われます。
一方、暗号化は、鍵を持っていない人には読めないようにすることを目的にします。
さらに、ハッシュ化は、基本的には元に戻すことを目的にしません。
似た言葉に見えますが、役割はかなり違います。
2.5 「暗号化して保存すればよい」とも限らない
では、Base64ではなく、きちんと暗号化して保存すればよいのでしょうか。
住所や電話番号のように、あとから元の値を表示する必要がある情報であれば、暗号化して保存する設計が使われることがあります。
しかし、パスワードは少し事情が違います。
ログイン処理で必要なのは、元のパスワードを取り出すことではありません。
必要なのは、入力されたパスワードが正しいかを確認することです。
OWASPのCryptographic Storage Cheat Sheetでも、パスワードは復号可能な暗号化ではなく、安全なパスワードハッシュアルゴリズムを使うべきだと説明されています。
参考: OWASP Cryptographic Storage Cheat Sheet
暗号化して保存する場合、どこかに復号用の鍵が必要になります。
もし攻撃者がデータベースだけでなく、その鍵にもアクセスできてしまうと、パスワードを元に戻される可能性があります。
もちろん、特殊な事情で元のパスワードを扱う必要があるシステムもあり得ます。
ただし、一般的なWebサービスのログイン機能では、パスワードを復元できる形で保存するよりも、パスワード保存向けのハッシュ方式で照合できる形にするのが基本です。
2.6 安全な方向性: 「読める形で持たない」
ここまでを整理すると、パスワード保存で避けたい形は次のようになります。
| 保存方法 | 何が問題か |
|---|---|
| 平文で保存する | 漏えいした瞬間にパスワードがそのまま読める |
| Base64などで変換して保存する | 表現を変えただけなので、簡単に元に戻せる |
| 復号可能な形で保存する | 鍵の管理に失敗すると元のパスワードを読まれる可能性がある |
| 普通のハッシュ関数を1回だけ使う | 元には戻しにくいが、大量推測への耐性が不十分になりやすい |
この章では、主に最初の3つを扱いました。
最後の「普通のハッシュ関数を1回だけ使う」については、次の章以降で詳しく見ていきます。
重要なのは、パスワード保存では、サービス側が利用者のパスワードを読める形で持たないという考え方です。
ただし、ここで次の疑問が出てきます。
それなら、SHA-256のようなハッシュ関数を使って、元に戻せない値にすれば十分なのでは?
この疑問はとても自然です。
しかし、パスワード保存では「元に戻せない」だけではまだ足りません。
次の章では、「ハッシュ化すれば安全」という考え方が、どこまで正しくて、どこから注意が必要なのかを整理します。
3. 「ハッシュ化すれば安全」は半分正しい
前の章では、パスワードをそのまま保存したり、Base64のような変換だけで隠したり、復元できる形で保存したりすることの危険性を見ました。
ここまで読むと、次のように考えたくなります。
それなら、パスワードをSHA-256でハッシュ化して保存すればよいのでは?
この考え方は、完全に間違いではありません。
少なくとも、パスワードをそのまま保存するよりは前進しています。
しかし、パスワード保存という目的で見ると、普通のハッシュ関数を1回使うだけでは不十分です。
この章では、まずハッシュ関数の基本を整理し、そのうえで「どこまで正しくて、どこから危ないのか」を分けて見ていきます。
NIST FIPS 180-4では、SHA-256を含むSHA-2系列などのハッシュ関数は、メッセージからダイジェストを生成し、メッセージが変更されたかを検出する用途で説明されています。
参考: NIST FIPS 180-4, Secure Hash Standard
一方で、OWASPのPassword Storage Cheat Sheetでは、SHA-256のような高速なハッシュアルゴリズムは、攻撃者が大量の推測を素早く試せるため、パスワード保存には適さないと説明されています。
参考: OWASP Password Storage Cheat Sheet
3.1 ハッシュ関数は「データの指紋」を作るようなもの
ハッシュ関数は、入力されたデータから固定長の値を作る関数です。
この出力値は、ハッシュ値、ダイジェスト、要約値などと呼ばれます。
たとえば、SHA-256では、入力が短い文字列でも長いファイルでも、出力は256ビットの値になります。
16進数で表示すると、64文字の文字列として表されます。
import hashlib
password = "password123"
# 文字列をバイト列に変換してから、SHA-256でハッシュ値を計算する
# 注意: これはハッシュ関数の動きを見るための例です。
# パスワード保存にこのまま使うべきではありません。
hash_value = hashlib.sha256(password.encode("utf-8")).hexdigest()
print(hash_value)
# 出力例:
# ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
このように、password123 という文字列から、元の文字列とはまったく違う見た目の値が作られます。
ハッシュ関数には、ざっくり言うと次のような特徴があります。
| 特徴 | ざっくりした意味 |
|---|---|
| 同じ入力からは同じ出力が得られる | 同じパスワードなら、同じハッシュ値になる |
| 少しでも入力が変わると出力が大きく変わる |
password123 と password1234 では、出力が大きく変わる |
| 出力の長さが固定される | 入力の長さに関係なく、一定の長さの値になる |
| 出力から入力を求めるのは難しい | ハッシュ値だけを見て、元の入力を直接戻すのは難しい |
ここだけ見ると、パスワード保存にもかなり向いていそうに見えます。
実際、パスワードをそのまま保存するより、ハッシュ値だけを保存する方が安全な方向に近づいています。
保存されている値を見ただけでは、元のパスワードがすぐに読めないからです。
3.2 ハッシュ化が「正しい」部分
まず、「ハッシュ化すれば安全」という考え方のうち、正しい部分を整理します。
パスワードを平文で保存していると、データベースが漏えいした瞬間にパスワードが読めてしまいます。
一方、ハッシュ値として保存していれば、少なくとも保存値を見ただけで元のパスワードがそのまま分かるわけではありません。
平文保存の場合:
保存値 = password123
→ 見た瞬間に元のパスワードが分かる
ハッシュ保存の場合:
保存値 = ef92b778bafe771e...
→ 見ただけでは元のパスワードは分かりにくい
この意味で、「パスワードをそのまま保存せず、元に戻しにくい値として保存する」という方向性は正しいです。
また、ログイン時の照合にも使えます。
import hashlib
# 注意: これは考え方を示すための簡略化した例です。
# 実際のパスワード保存では、SHA-256を1回使うだけでは不十分です。
stored_hash = "ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f"
input_password = "password123"
# ログイン時に入力されたパスワードから、同じ方法でハッシュ値を計算する
input_hash = hashlib.sha256(input_password.encode("utf-8")).hexdigest()
# 保存済みのハッシュ値と、入力されたパスワードから計算したハッシュ値を比較する
if input_hash == stored_hash:
print("ログイン成功")
else:
print("ログイン失敗")
このように、サービス側は元のパスワードを保存していなくても、入力されたパスワードが正しいかどうかを確認できます。
ここまでは、ハッシュ化の良いところです。
3.3 それでも「普通のハッシュ関数1回」では足りない
では、なぜこれだけでは不十分なのでしょうか。
理由は、攻撃者がハッシュ値を見て、候補パスワードを大量に試せるからです。
ハッシュ関数は「出力から入力を直接戻す」のは難しくても、入力候補を作って、同じハッシュ値になるかを試すことはできます。
たとえば、漏えいしたハッシュ値が次のようなものだったとします。
漏えいしたハッシュ値:
ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
攻撃者は、よく使われそうなパスワード候補を用意し、それぞれをSHA-256でハッシュ化して比較できます。
import hashlib
# 注意: これは「普通のハッシュ関数を1回使うだけでは危ない」ことを理解するための学習用コードです。
# 他人のアカウントや実サービスに対して試す行為は絶対に行ってはいけません。
# ここでは、候補リストも説明用にごく少数だけ用意しています。
leaked_hash = "ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f"
# よく使われそうな候補を、説明用に少数だけ用意する
candidate_passwords = [
"123456",
"password",
"qwerty",
"password123",
"letmein",
]
for candidate in candidate_passwords:
# 候補パスワードを同じSHA-256でハッシュ化する
candidate_hash = hashlib.sha256(candidate.encode("utf-8")).hexdigest()
# 漏えいしたハッシュ値と一致するかを確認する
if candidate_hash == leaked_hash:
print(f"一致した候補: {candidate}")
break
この例では、password123 が候補リストに入っているため、ハッシュ値の一致によって元のパスワードを推測できてしまいます。
もちろん、実際の攻撃はこのような数個の候補だけではありません。
辞書に載っている単語、過去に漏えいしたパスワード、名前や誕生日を組み合わせた文字列など、非常に多くの候補が試される可能性があります。
ここで大切なのは、攻撃者がハッシュ値から魔法のように元のパスワードを復元しているわけではないという点です。
やっていることは、かなり単純です。
候補を作る
↓
同じハッシュ関数に通す
↓
漏えいしたハッシュ値と一致するか確認する
↓
一致すれば、その候補が元のパスワードだと分かる
つまり、パスワード保存では「逆向きに戻せないか」だけでなく、前向きに大量に試されないかを考える必要があります。
MITRE CWE-916でも、十分な計算負荷を持たないパスワードハッシュ方式を使うと、攻撃者がハッシュを入手した場合に、総当たり攻撃やGPUなどの専用・高性能な計算資源を使った攻撃が容易になると説明されています。
参考: CWE-916: Use of Password Hash With Insufficient Computational Effort
3.4 「速い」は、場面によって長所にも短所にもなる
SHA-256のような普通のハッシュ関数は、高速に計算できるように設計されています。
これは、多くの用途では大きな長所です。
たとえば、大きなファイルの改ざん検知をしたい場合、ハッシュ計算が遅すぎると困ります。
ソフトウェアのダウンロード確認、ファイルの同一性確認、データ構造の検証などでは、素早く計算できることが便利です。
しかし、パスワード保存では話が変わります。
正規ユーザーは、ログイン時に自分のパスワードを1回入力するだけです。
一方、攻撃者は、漏えいしたハッシュ値に対して、大量の候補を試す可能性があります。
正規ユーザー:
1回ログインできればよい
攻撃者:
何万回、何百万回、場合によってはそれ以上の候補を試したい
このとき、ハッシュ計算が速すぎると、攻撃者にとっても都合がよくなります。
次のコードは、SHA-256を何回も計算できることを体感するための例です。
実行時間はPCの性能や実行環境によって大きく変わるため、数値そのものよりも「大量に繰り返せる」という点に注目してください。
import hashlib
import time
# 注意: これは速度感を確認するための学習用コードです。
# 実行環境によって結果は変わります。
password = "password123"
repeat_count = 100_000
start = time.perf_counter()
for _ in range(repeat_count):
# 同じ文字列をSHA-256で繰り返しハッシュ化する
hashlib.sha256(password.encode("utf-8")).hexdigest()
end = time.perf_counter()
print(f"{repeat_count}回のSHA-256計算にかかった時間: {end - start:.4f}秒")
パスワード保存向けの方式では、この「速く大量に試せる」状態をそのまま許さないようにします。
あえて計算を重くしたり、メモリを多く使わせたりして、攻撃者が1つの候補を試すコストを上げます。
NIST SP 800-63Bでも、パスワードハッシュ方式はパスワード、ソルト、コスト係数を入力に取り、ハッシュ化されたパスワードファイルを入手した攻撃者にとって、各パスワード推測のコストを高くすることを目的とすると説明されています。
参考: NIST SP 800-63B - Authentication and Lifecycle Management
3.5 同じパスワードなら、同じハッシュ値になる
普通のハッシュ関数をそのまま使う場合、もう1つ分かりやすい問題があります。
同じ入力からは同じ出力が得られるため、同じパスワードを使っているユーザーは、同じハッシュ値になります。
import hashlib
# 注意: これはソルトなしハッシュの問題を説明するための例です。
# 実際のパスワード保存では、ユーザーごとに一意なソルトを使います。
users = {
"alice@example.com": "password123",
"bob@example.com": "password123",
"carol@example.com": "different-password",
}
for email, password in users.items():
password_hash = hashlib.sha256(password.encode("utf-8")).hexdigest()
print(email, password_hash)
この例では、AliceとBobが同じ password123 を使っているため、保存されるハッシュ値も同じになります。
つまり、データベースを見た人は、元のパスワードまでは分からなくても、次のような推測ができてしまいます。
この2人は、同じパスワードを使っていそうだ。
これは望ましくありません。
この問題に対応するために使われるのが、後の章で説明するソルトです。
ソルトを使うと、同じパスワードでもユーザーごとに異なる値として保存できます。
3.6 ここで言いたいのは「SHA-256が悪い」ではない
ここまで読むと、「SHA-256は危ないハッシュ関数なのか」と感じるかもしれません。
しかし、そうではありません。
SHA-256は、現在でもさまざまな場面で使われる重要なハッシュ関数です。
ファイルの改ざん検知、データの同一性確認、デジタル署名の前処理など、ハッシュ関数が活躍する場面はたくさんあります。
問題は、SHA-256そのものではなく、パスワード保存という用途にそのまま使ってしまうことです。
包丁が料理には便利でも、使い方を間違えると危ないのと同じです。
道具の良し悪しだけでなく、「何を守りたいのか」「どのような攻撃を想定するのか」に合わせて使い方を選ぶ必要があります。
パスワード保存で守りたいのは、単に「保存値から元のパスワードを直接読まれないこと」だけではありません。
データベースが漏えいしたあとに、攻撃者が手元で大量の候補を試す状況まで考える必要があります。
3.7 この章のまとめ
この章では、「ハッシュ化すれば安全」という考え方を分解しました。
ポイントは次の3つです。
- パスワードを平文で保存するより、ハッシュ値として保存する方向性は正しい
- しかし、SHA-256のような普通のハッシュ関数を1回使うだけでは、大量推測への耐性が不十分になりやすい
- パスワード保存では「元に戻せない」だけでなく、「大量に試すコストを高くする」ことが重要
つまり、問題は「ハッシュ化するかどうか」だけではありません。
どのようなハッシュ方式を、どのような設定で使うのか
ここが重要になります。
次の章では、今回の記事の中心となる「漏えい後のオフライン推測攻撃」をもう少し具体的に見ていきます。
4. 本当に怖いのは漏えい後のオフライン推測攻撃
前の章では、SHA-256のような普通のハッシュ関数を1回使うだけでは、パスワード保存としては不十分だと整理しました。
ここで大切になるのが、オフライン推測攻撃という考え方です。
名前だけ見ると少し難しそうですが、考え方自体はシンプルです。
データベースからパスワードのハッシュ値が漏えいしたあと、攻撃者がサービスのログイン画面を使わずに、自分の手元で候補パスワードを大量に試すこと
です。
OWASPのPassword Storage Cheat Sheetでは、パスワードは、アプリケーションやデータベースが侵害された場合でも攻撃者から守る必要があると説明されています。
参考: OWASP Password Storage Cheat Sheet
また、NIST SP 800-63Bでは、パスワードはオフライン攻撃に耐える形で保存し、パスワード・ソルト・コスト係数を入力に取る適切なパスワードハッシュ方式を使うべきだと説明されています。
参考: NIST SP 800-63B - Authentication and Lifecycle Management
この章では、「オンライン」と「オフライン」の違いを身近な例から整理し、なぜ漏えい後の攻撃を前提にしなければならないのかを見ていきます。
4.1 オンライン攻撃とオフライン攻撃の違い
まず、攻撃者がパスワードを推測する場面を2つに分けます。
1つ目は、実際のログイン画面に何度もパスワードを入力する攻撃です。
これは、サービスにリクエストを送る必要があるため、ここではオンライン攻撃と呼びます。
2つ目は、漏えいしたハッシュ値を使って、攻撃者の手元で候補パスワードを試す攻撃です。
これは、ログイン画面を通らずに試せるため、ここではオフライン攻撃と呼びます。
| 観点 | オンライン攻撃 | オフライン攻撃 |
|---|---|---|
| 試す場所 | サービスのログイン画面 | 攻撃者の手元のPCや専用機材 |
| サービス側から見えるか | 見えやすい | 見えにくい |
| レート制限 | 効きやすい | 基本的に効かない |
| アカウントロック | 効きやすい | 基本的に効かない |
| 攻撃速度 | サービス側の制限を受ける | 攻撃者の計算資源に左右される |
オンライン攻撃であれば、サービス側はログイン失敗回数を数えたり、一定回数失敗したら一時的に止めたりできます。
OWASP Authentication Cheat Sheetでも、ログイン試行を制限するLogin ThrottlingやAccount Lockoutは、通常の対話的なパスワード推測を防ぐための対策として説明されています。
参考: OWASP Authentication Cheat Sheet
しかし、オフライン攻撃では話が変わります。
攻撃者は、すでに手元にあるハッシュ値に対して候補パスワードを試します。
そのため、サービス側のログイン画面にはアクセスしません。
つまり、次のような対策が効きにくくなります。
ログイン失敗回数を数える
一定回数失敗したらアカウントをロックする
ログイン画面にCAPTCHAを出す
IPアドレスごとにリクエスト数を制限する
これらはオンライン攻撃には重要な対策です。
ただし、漏えいしたハッシュ値を手元で解析される状況では、別の考え方が必要になります。
4.2 図で見る: ログイン画面を通る攻撃と通らない攻撃
オンライン攻撃では、攻撃者はログイン画面を通る必要があります。
この場合、サービス側は「何回も失敗している」「短時間に大量の試行がある」といった異常に気づけます。
一方、オフライン攻撃では、流れがまったく違います。
この流れでは、攻撃者はサービスにログインリクエストを送っていません。
そのため、サービス側のログイン制限や監視だけでは止めにくくなります。
ここが、パスワード保存を考えるうえでとても重要なポイントです。
4.3 オフライン推測攻撃の考え方を小さなコードで見る
ここでは、かなり小さな例でオフライン推測攻撃の考え方を見てみます。
次のコードは、漏えいしたハッシュ値に対して、少数の候補パスワードを試す学習用コードです。
実サービスや他人のアカウントに対して使うものではありません。
import hashlib
# 注意:
# このコードは、普通のハッシュ関数を1回使うだけでは
# 候補を試される可能性があることを理解するための学習用コードです。
# 実サービスや他人のアカウントに対して使ってはいけません。
# 例として、「password123」をSHA-256でハッシュ化した値を漏えいしたハッシュ値だと仮定する
leaked_hash = hashlib.sha256("password123".encode("utf-8")).hexdigest()
# 攻撃者が試しそうな候補を、説明用に少数だけ用意する
# 実際の攻撃を再現する目的ではなく、仕組みを理解するための小さな例
candidate_passwords = [
"123456",
"password",
"qwerty",
"password123",
"admin",
]
for candidate in candidate_passwords:
# 候補パスワードにも同じSHA-256をかける
candidate_hash = hashlib.sha256(candidate.encode("utf-8")).hexdigest()
# 漏えいしたハッシュ値と一致するか確認する
if candidate_hash == leaked_hash:
print(f"一致した候補: {candidate}")
break
この例では、候補の中に password123 が含まれているため、一致する候補を見つけられます。
ここで見てほしいのは、コードの難しさではありません。
重要なのは、攻撃者がログイン画面を使わずに答え合わせできてしまうという点です。
4.4 「レート制限があるから大丈夫」とは言い切れない
ログイン機能では、レート制限やアカウントロックは大切です。
たとえば、次のようにログイン失敗回数を制限する仕組みを考えます。
# 注意:
# これはレート制限の考え方を説明するための簡略化した例です。
# 実際の認証処理では、IPアドレス、アカウント単位、時間窓、監視ログ、MFAなども含めて設計します。
failed_attempts = {}
MAX_ATTEMPTS = 5
def can_try_login(email: str) -> bool:
"""このアカウントでログイン試行を続けてよいか確認する"""
# 失敗回数が上限未満なら、ログイン試行を許可する
return failed_attempts.get(email, 0) < MAX_ATTEMPTS
def record_login_failure(email: str) -> None:
"""ログイン失敗回数を記録する"""
# そのメールアドレスの失敗回数を1増やす
failed_attempts[email] = failed_attempts.get(email, 0) + 1
def login(email: str, password: str) -> bool:
"""ログイン処理のイメージ"""
# 失敗回数が多すぎる場合は、追加の試行を止める
if not can_try_login(email):
print("ログイン試行が多すぎます。しばらく待ってください。")
return False
# ここでは説明を簡単にするため、常に失敗したことにする
record_login_failure(email)
return False
このような仕組みは、ログイン画面を通る攻撃に対しては有効です。
攻撃者が何度もログインを試そうとしても、サービス側が途中で止められるからです。
しかし、オフライン推測攻撃では、攻撃者は login() のようなサービス側の処理を呼び出しません。
漏えいしたハッシュ値と候補パスワードを使って、自分の手元で計算します。
そのため、ログイン画面側の制限だけに頼っていると、漏えい後の攻撃には対応しきれません。
4.5 攻撃者にとっては「1回の計算がどれだけ軽いか」が重要になる
オフライン推測攻撃で重要になるのは、1回の候補を試すコストです。
たとえば、1回のハッシュ計算がとても軽い場合、攻撃者は多くの候補を試しやすくなります。
逆に、1回の候補を試すたびに時間やメモリを多く使う場合、同じ時間で試せる候補数は減ります。
ここで、ざっくりした計算式を見てみます。
試せる候補数 = 1秒あたりに試せる回数 × 試行できる秒数
この式自体は単純です。
しかし、パスワード保存ではとても重要です。
普通のハッシュ関数は、高速に計算できることが長所になる場面が多いです。
ファイルの改ざん検知やデータの同一性確認では、高速であることは便利です。
一方、パスワード保存では、その高速さが攻撃者にも有利に働いてしまいます。
MITRE CWE-916では、攻撃者がハッシュにアクセスできた場合、十分な計算負荷がないと、総当たり攻撃やレインボーテーブル、GPUなどの専用・高性能な計算資源を使った攻撃が容易になると説明されています。
参考: CWE-916: Use of Password Hash With Insufficient Computational Effort
4.6 「攻撃者だけを遅くする」のは難しい
ここで、少し直感に反する話があります。
パスワードハッシュを重くすると、攻撃者だけでなく、正規ユーザーのログイン処理も少し重くなります。
これは避けられない部分があります。
ログイン時には、サービス側も入力されたパスワードから同じ照合用データを作る必要があるからです。
ただし、正規ユーザーは通常、ログイン時に1回だけ計算します。
一方、攻撃者は大量の候補を試します。
正規ユーザー:
1回ログインするために、1回だけ重い計算を行う
攻撃者:
大量の候補を試すために、重い計算を何度も繰り返す
この差を利用するのが、パスワード保存向け方式の考え方です。
もちろん、重くしすぎると正規ユーザーのログイン体験が悪くなったり、サーバーに負荷がかかりすぎたりします。
そのため、後の章で扱うコスト係数を、サービスの環境に合わせて調整する必要があります。
OWASP Password Storage Cheat Sheetでも、ワークファクターを高くすると攻撃者によるクラック計算は難しくなる一方、検証処理も重くなり、設定値が高すぎるとサービスへの負荷やDoSにつながる可能性があると説明されています。
参考: OWASP Password Storage Cheat Sheet - Using Work Factors
4.7 この章のまとめ
この章では、パスワード保存で重要になるオフライン推測攻撃を整理しました。
ポイントは次の3つです。
- オンライン攻撃はログイン画面を通るため、レート制限やアカウントロックなどで抑えやすい
- オフライン攻撃は漏えいしたハッシュ値を使って、攻撃者の手元で候補を試すため、ログイン画面側の制限が効きにくい
- パスワード保存では、漏えい後に候補を大量に試される前提で、1回の推測にかかるコストを上げる必要がある
ここまでで、「普通のハッシュ関数を1回使うだけでは不十分」という理由がかなり見えてきました。
しかし、まだ重要な問題が残っています。
同じパスワードを使っているユーザーが複数いた場合、普通にハッシュ化するだけでは、同じハッシュ値が保存されてしまいます。
また、よく使われるパスワードに対して、あらかじめハッシュ値の表を作られてしまう可能性もあります。
次の章では、この問題を防ぐために使われるソルトについて整理します。
5. ソルトは何を守っているのか
前の章では、データベースからハッシュ値が漏えいしたあと、攻撃者が手元で大量の候補パスワードを試せることを整理しました。
ここで、もう1つ大切な問題があります。
それは、同じパスワードを使っているユーザーがいると、保存されるハッシュ値も同じになってしまうことです。
たとえば、AliceさんとBobさんがどちらも password123 を使っていたとします。
普通にSHA-256を1回かけるだけだと、同じ入力からは同じハッシュ値が出るため、2人の保存値も同じになります。
Aliceさんのパスワード: password123
Bobさんのパスワード: password123
SHA-256(password123) → 同じハッシュ値
これでは、攻撃者がデータベースを見たときに、次のようなことが分かってしまいます。
この2人は、同じパスワードを使っていそうだ
パスワードそのものがまだ分かっていなくても、「同じパスワードを使っている人のまとまり」が見えてしまうのは、あまりよい状態ではありません。
この問題を防ぐために使われるのが、ソルトです。
OWASP Password Storage Cheat Sheetでは、ソルトは各パスワードに加えられる一意でランダムな文字列であり、事前計算済みの表、いわゆるレインボーテーブルの利用を防ぎ、同じパスワードでも異なるハッシュ値になるようにするものとして説明されています。
参考: OWASP Password Storage Cheat Sheet - Salting
また、NIST SP 800-63Bでは、パスワードはソルト付きでハッシュ化し、ソルトとハッシュ値の両方を保存することが示されています。ソルトは少なくとも32ビット以上で、保存されたハッシュ同士で衝突しにくいように選ぶ必要があると説明されています。
参考: NIST SP 800-63B - Password Verifiers
5.1 ソルトは「ユーザーごとに違う混ぜ物」
ソルトは、パスワードに加えるランダムな値です。
イメージとしては、次のようなものです。
パスワード + ユーザーごとに違うソルト → 保存用のハッシュ値
たとえば、AliceさんとBobさんが同じ password123 を使っていたとしても、それぞれ異なるソルトを使えば、保存される値は変わります。
Aliceさん:
password123 + Alice用のソルト → Alice用のハッシュ値
Bobさん:
password123 + Bob用のソルト → Bob用のハッシュ値
図にすると、次のようなイメージです。
ここで大切なのは、ソルトによってパスワードそのものが強くなるわけではないことです。
password123 は、ソルトを付けても弱いパスワードのままです。
ソルトの主な役割は、同じパスワードを使っていても同じ保存値にならないようにすることです。
5.2 ソルトなしだと、同じパスワードが見えてしまう
実際に、簡単なコードで見てみます。
import hashlib
# 注意: これは学習用の例です。
# パスワード保存にSHA-256を1回使う実装は推奨されません。
users = {
"alice@example.com": "password123",
"bob@example.com": "password123",
"carol@example.com": "qwerty!",
}
for email, password in users.items():
# パスワードをそのままSHA-256でハッシュ化する
# 同じ入力に対しては、常に同じハッシュ値が出る
hash_value = hashlib.sha256(password.encode("utf-8")).hexdigest()
print(email, hash_value)
このコードでは、AliceさんとBobさんが同じ password123 を使っています。
そのため、2人のハッシュ値は同じになります。
alice@example.com ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
bob@example.com ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
carol@example.com 3beb3128250db97ec6c2e6451f1b940dc975bf8293f911176f94085f0ecad5eb
ここで見てほしいのは、AliceさんとBobさんのハッシュ値が同じになっている点です。
攻撃者がこのデータを見た場合、少なくとも次のことが推測できます。
AliceさんとBobさんは、同じパスワードを使っている可能性が高い
これは、元のパスワードがまだ分かっていなくても、利用者のパスワード傾向が見えてしまうということです。
5.3 ソルトありだと、同じパスワードでも保存値が変わる
次に、ユーザーごとにランダムなソルトを付けてみます。
ここではPythonの secrets モジュールを使います。
secrets は、パスワード、認証トークン、セキュリティトークンのような秘密情報を扱うための、暗号学的に強い乱数を生成する目的で用意されています。
参考: Python Documentation - secrets
import hashlib
import secrets
# 注意: これはソルトの役割を理解するための学習用コードです。
# 実際のパスワード保存では、SHA-256を直接使うのではなく、
# Argon2id、bcrypt、scrypt、PBKDF2などのパスワード保存向け方式を使います。
def hash_with_salt(password: str, salt: bytes) -> str:
"""パスワードとソルトから説明用のハッシュ値を作る"""
# パスワードをバイト列に変換する
password_bytes = password.encode("utf-8")
# ソルトとパスワードを結合してからSHA-256でハッシュ化する
# ここでは仕組みを見せるために単純化している
return hashlib.sha256(salt + password_bytes).hexdigest()
password = "password123"
# ユーザーごとに異なるソルトを生成する
# token_bytes(16) は16バイト、つまり128ビットのランダムな値を生成する
alice_salt = secrets.token_bytes(16)
bob_salt = secrets.token_bytes(16)
alice_hash = hash_with_salt(password, alice_salt)
bob_hash = hash_with_salt(password, bob_salt)
print("Alice salt:", alice_salt.hex())
print("Alice hash:", alice_hash)
print("Bob salt: ", bob_salt.hex())
print("Bob hash: ", bob_hash)
同じ password123 から計算していても、AliceさんとBobさんでソルトが違うため、出力されるハッシュ値も変わります。
Alice salt: 0b52e2c0b1e2c5f4d8a7a3c2f01a9b6d
Alice hash: 2a1b...省略...
Bob salt: a91f1b0f7d20a4119b3839e4d3e2c9a8
Bob hash: 9f43...省略...
これにより、攻撃者が保存値を見ても、同じパスワードを使っているユーザーを単純には見分けにくくなります。
5.4 ソルトはレインボーテーブル対策にもなる
ソルトのもう1つの大きな役割は、事前計算済みの表を使いにくくすることです。
攻撃者は、よく使われるパスワードに対して、あらかじめハッシュ値を計算した表を用意することがあります。
password → 5e884898...
123456 → 8d969eef...
password123 → ef92b778...
qwerty → 65e84be3...
このような表を持っていれば、漏えいしたハッシュ値を表の中から探すだけで、元のパスワード候補を見つけられる可能性があります。
これをかなり単純化して表すと、次のようになります。
import hashlib
# 注意: これはレインボーテーブルの考え方を単純化した学習用コードです。
# 実際の攻撃を目的としたものではありません。
common_passwords = [
"password",
"123456",
"password123",
"qwerty",
]
# よくあるパスワードに対して、事前にSHA-256の値を計算しておく
precomputed_table = {}
for password in common_passwords:
hash_value = hashlib.sha256(password.encode("utf-8")).hexdigest()
precomputed_table[hash_value] = password
# もし漏えいしたハッシュ値が表にあれば、対応する候補を引けてしまう
leaked_hash = hashlib.sha256("password123".encode("utf-8")).hexdigest()
print(precomputed_table.get(leaked_hash))
# 出力例: password123
ソルトがない場合、同じパスワードからは常に同じハッシュ値が出ます。
そのため、事前に作った表をいろいろなサービスやユーザーに使い回しやすくなります。
一方、ユーザーごとにソルトが異なる場合、攻撃者は次のように考えなければなりません。
password123 + Alice用ソルト のハッシュ値を計算する
password123 + Bob用ソルト のハッシュ値を計算する
password123 + Carol用ソルト のハッシュ値を計算する
つまり、同じ候補パスワードでも、ユーザーごとのソルトに合わせて計算し直す必要があります。
OWASPも、ソルトはレインボーテーブルやデータベースベースの事前計算を防ぐと説明しています。
参考: OWASP Password Storage Cheat Sheet - Salting
5.5 ソルトは秘密にする値ではない
ここで、よくある誤解があります。
ソルトは秘密にしておかないと意味がないのでは?
実は、ソルトは基本的に秘密情報ではありません。
NIST SP 800-63Bでも、ソルト値とハッシュ値の両方を各パスワードについて保存することが示されています。
つまり、ソルトは「見えたら終わり」の秘密鍵のようなものではありません。
保存イメージとしては、次のようになります。
| salt | password_hash | |
|---|---|---|
| alice@example.com | Alice用のソルト | Alice用のハッシュ値 |
| bob@example.com | Bob用のソルト | Bob用のハッシュ値 |
もちろん、実際のデータベースを外部に見られてよいわけではありません。
ただし、ソルトの役割は「秘密にすること」ではなく、ユーザーごとに違う値を使うことです。
ここは、暗号鍵との違いとして押さえておくと分かりやすいです。
| 種類 | 秘密にする必要 | 主な役割 |
|---|---|---|
| ソルト | 基本的に秘密ではない | 同じパスワードでも保存値を変える。事前計算を使いにくくする |
| 暗号鍵 | 秘密にする必要がある | 暗号文を復号できる人を制御する |
| ペッパー | 秘密にする必要がある | DBとは別に持つ追加の秘密情報として防御を厚くする |
豆知識: ソルトとペッパーは別物
ソルトと似た言葉に、ペッパーがあります。
名前だけ見ると、料理の「塩」と「こしょう」のようで少し面白いですが、役割は違います。
ソルトは、ユーザーごとに異なるランダムな値です。
基本的には、ハッシュ値と一緒に保存されます。
一方、ペッパーは複数のパスワードで共有される秘密情報です。
OWASP Password Storage Cheat Sheetでは、ペッパーはソルトとは異なり、公開せず、生成されたハッシュ値と一緒に保存せず、Secrets VaultやHSMなど、パスワードDBとは別の場所に保存するべきだと説明されています。
参考: OWASP Password Storage Cheat Sheet - Peppering
ただし、ペッパーは追加防御の考え方です。
まずは、適切なパスワードハッシュ方式、ソルト、コスト係数を正しく使うことが土台になります。
5.6 ソルトがあっても、弱いパスワードは弱い
ここまで見ると、ソルトがとても強力な対策に見えるかもしれません。
しかし、ソルトは万能ではありません。
ソルトが防ぎやすくするのは、主に次のような問題です。
- 同じパスワードを使っているユーザーの判別
- 事前計算済みの表の使い回し
- 複数ユーザーに対する一括攻撃の効率化
一方で、ソルトだけでは次の問題は解決できません。
-
password123のような弱いパスワード自体を強くすること - 攻撃者が1つのハッシュに対して候補を順番に試すこと
- 1回あたりのハッシュ計算を重くすること
つまり、ソルトは「同じものを同じに見せない」「事前計算を使いにくくする」ための仕組みです。
しかし、攻撃者が1人のユーザーに狙いを定めて、候補パスワードを1つずつ試すこと自体は止められません。
ソルトがある場合でも、攻撃者は次のように試せる
候補パスワード + 対象ユーザーのソルト → ハッシュ値を計算
保存されているハッシュ値と比較
一致するまで候補を変えて繰り返す
そのため、ソルトに加えて、1回の推測にかかるコストを上げる仕組みが必要になります。
5.7 実装では自前で頑張りすぎない
ソルトの考え方を理解するために、この章では hashlib と secrets を使った簡単なコードを示しました。
しかし、実際のアプリケーションで、次のような処理を自前で組み合わせてパスワード保存を作るのは避けるべきです。
import hashlib
# 注意: これは「避けたい例」です。
# ソルトを付けていても、SHA-256を1回使うだけではパスワード保存として不十分です。
password_bytes = password.encode("utf-8")
password_hash = hashlib.sha256(salt + password_bytes).hexdigest()
実務では、Argon2id、bcrypt、scrypt、PBKDF2などのパスワード保存向け方式を、信頼できるライブラリ経由で使うのが基本です。
OWASP Password Storage Cheat Sheetでも、Argon2id、bcrypt、PBKDF2などのパスワード保存向け方式が整理されており、現代的な実装やライブラリではソルトを内部で生成・管理するものも多いと説明されています。
参考: OWASP Password Storage Cheat Sheet
この章のコードは、あくまで「ソルトが何をしているのか」を理解するためのものです。
実装では、ライブラリの推奨設定や公式ドキュメントに従う必要があります。
5.8 この章のまとめ
この章では、ソルトの役割を整理しました。
ポイントは次の4つです。
- ソルトは、パスワードに加えるユーザーごとのランダムな値
- 同じパスワードでも、ソルトが違えば保存されるハッシュ値は変わる
- ソルトはレインボーテーブルや事前計算済みの表の使い回しを難しくする
- ソルトは基本的に秘密情報ではなく、弱いパスワードを強くするものでもない
ここまでで、パスワード保存には「元に戻せないこと」だけでなく、「同じパスワードを同じ保存値にしないこと」も重要だと分かりました。
ただし、ソルトを付けても、攻撃者が1つのハッシュに対して候補を順番に試すことはできます。
そこで次に必要になるのが、1回の推測にかかる計算コストを上げる考え方です。
次の章では、ストレッチングとコスト係数について整理します。
6. ストレッチングとコスト係数で「試すコスト」を上げる
前の章では、ソルトを使うことで、同じパスワードでも保存される値を変えられることを説明しました。
ソルトには、レインボーテーブルのような事前計算済みの表を使い回しにくくする効果があります。
また、同じパスワードを使っているユーザー同士を、保存値だけから見分けにくくする効果もあります。
ただし、ソルトだけで安心できるわけではありません。
なぜなら、攻撃者がデータベースを入手した場合、ソルトもハッシュ値も一緒に見えていることが多いからです。
そのため、攻撃者は次のように、1人分のソルトとハッシュ値を使って候補パスワードを順番に試せます。
漏えいした情報:
- salt
- password_hash
攻撃者が行うこと:
候補パスワード + salt から同じ方法で値を計算する
保存されていた password_hash と一致するか確認する
つまり、ソルトはとても大切ですが、1回の推測にかかる計算時間そのものを大きくする仕組みではないということです。
そこで登場するのが、ストレッチングとコスト係数です。
NIST SP 800-63Bでは、パスワードハッシュ方式はパスワード、ソルト、コスト係数を入力として受け取り、ハッシュ化されたパスワードを得るものとして説明されています。
また、その目的は、漏えいしたハッシュファイルを手に入れた攻撃者にとって、1回ごとのパスワード推測を高コストにすることだと説明されています。
参考: NIST SP 800-63B
6.1 ストレッチングは「わざと計算を重くする」考え方
ストレッチングは、パスワードから保存用の値を作るときに、計算を何度も繰り返すなどして、処理をあえて重くする考え方です。
普通の感覚だと、処理は速い方がよいと思いがちです。
Webサイトもアプリも、基本的には速く動く方が使いやすいです。
しかし、パスワード保存では少し話が変わります。
ログインする利用者にとって、パスワード確認に0.1秒や0.3秒ほど追加で時間がかかっても、多くの場合は大きな問題になりません。
一方で、攻撃者が何百万、何千万という候補を試そうとすると、その少しの差が大きな負担になります。
たとえば、1回の推測がとても速い場合と、少し重い場合を比べてみます。
| 1回の推測にかかる時間 | 1万回試す場合 | 100万回試す場合 |
|---|---|---|
| 0.000001秒 | 約0.01秒 | 約1秒 |
| 0.001秒 | 約10秒 | 約17分 |
| 0.1秒 | 約17分 | 約28時間 |
この表は、かなり単純化したイメージです。
実際の速度は、使うアルゴリズム、設定値、CPUやGPUなどの計算資源によって変わります。
それでも、考え方は同じです。
正規ユーザーには少しの待ち時間。
大量に試す攻撃者には大きな負担。
これが、パスワード保存で処理をあえて重くする理由です。
6.2 コスト係数は「どれくらい重くするか」を決める設定
ストレッチングの重さを調整するための値を、コスト係数やワークファクターと呼びます。
呼び方や具体的な意味は方式によって異なります。
| 方式 | コストの考え方の例 |
|---|---|
| PBKDF2 | 反復回数を増やして計算を重くする |
| bcrypt | costという値を上げると計算量が増える |
| scrypt | CPUだけでなくメモリ使用量も調整する |
| Argon2id | メモリ量、反復回数、並列度などを調整する |
ここで大切なのは、「コスト係数は大きければ大きいほど無条件によい」というものではない点です。
コストを上げると、攻撃者にとっては推測が難しくなります。
しかし同時に、正規ユーザーのログイン処理も重くなります。
極端に重くしすぎると、ログインのたびに利用者を長く待たせることになります。
さらに、ログイン処理にサーバー資源を多く使うため、大量のログイン試行を受けたときにサービス側の負荷が上がる可能性もあります。
OWASP Password Storage Cheat Sheetでも、ワークファクターは攻撃者によるパスワードクラックを難しくする一方で、高く設定しすぎるとサーバーのCPU消費やサービス拒否につながる可能性があるため、性能とのバランスを取る必要があると説明されています。
参考: OWASP Password Storage Cheat Sheet
6.3 PBKDF2で「反復回数」を体験してみる
ストレッチングのイメージをつかむために、Pythonの標準ライブラリで使える hashlib.pbkdf2_hmac() を使ってみます。
PBKDF2は、パスワードベース鍵導出関数の1つです。
ざっくり言うと、パスワードとソルトをもとに、指定した反復回数だけ計算を繰り返して値を作ります。
Python公式ドキュメントでも、hashlib.pbkdf2_hmac() はパスワードベース鍵導出関数PKCS#5 PBKDF2を提供する関数として説明されています。
参考: Python hashlib documentation
以下のコードは、反復回数を変えたときに処理時間がどう変わるかを見るための学習用コードです。
import hashlib
import secrets
import time
password = "correct horse battery staple"
password_bytes = password.encode("utf-8")
# ソルトはユーザーごとにランダムに生成する
# token_bytes(16) は、16バイトのランダムな値を生成する
salt = secrets.token_bytes(16)
# 反復回数の候補
# 数値が大きいほど、PBKDF2内部での計算回数が増える
iteration_list = [1_000, 10_000, 100_000]
for iterations in iteration_list:
start = time.perf_counter()
# PBKDF2-HMAC-SHA256で、パスワードとソルトから値を導出する
# iterations が大きいほど計算が重くなる
derived_key = hashlib.pbkdf2_hmac(
"sha256", # 内部で使うハッシュ関数
password_bytes, # パスワードをバイト列にしたもの
salt, # ユーザーごとのソルト
iterations, # 反復回数
)
end = time.perf_counter()
# derived_key 自体は長いバイト列なので、ここでは先頭だけ表示する
print(f"iterations={iterations:>6}, time={end - start:.6f} sec, key={derived_key.hex()[:16]}...")
出力は実行環境によって変わりますが、反復回数を増やすほど処理時間も増えることが分かります。
iterations= 1000, time=0.000xxx sec, key=...
iterations= 10000, time=0.00xxxx sec, key=...
iterations=100000, time=0.0xxxxx sec, key=...
ここで見たいのは、具体的な秒数そのものではありません。
大切なのは、反復回数を増やすと、1回のパスワード確認に必要な計算量を増やせるという点です。
ただし、このコードをそのままWebアプリのパスワード保存に使うことを推奨しているわけではありません。
実務では、利用するフレームワークやライブラリの推奨方式に従い、現在のOWASPや公式ドキュメントを確認して設定値を決める必要があります。
6.4 保存時には「方式」と「設定値」も残す
パスワード保存では、ハッシュ値だけを保存すればよいわけではありません。
ログイン時に同じ計算を再現するためには、次のような情報が必要になります。
どの方式を使ったか
どのソルトを使ったか
どのコスト係数を使ったか
どのハッシュ値が保存値か
たとえば、概念としては次のような形です。
algorithm = pbkdf2-sha256
iterations = 100000
salt = ランダムな値
hash = 計算結果
bcryptやArgon2idなどのライブラリでは、方式名、コスト、ソルト、ハッシュ値などを1つの文字列にまとめて扱う形式が使われることがあります。
そのため、アプリケーション側でソルトやコストを別々のカラムに保存するか、ライブラリが返す文字列をそのまま保存するかは、使用するライブラリの設計に従います。
ここで重要なのは、あとで同じ方法で検証できるように、必要な設定値を保存しておくことです。
6.5 コスト係数は「将来上げる」ことも考える
パスワード保存の難しいところは、時間が経つと計算機が速くなることです。
今日十分に重いと思っていた設定でも、数年後には軽くなっているかもしれません。
そのため、コスト係数は一度決めたら終わりではなく、システムの性能や利用者への影響を見ながら、将来的に見直す必要があります。
OWASP Password Storage Cheat Sheetでも、ワークファクターは計算機の性能向上に合わせて増やしていくことが推奨されています。
ただし、既存ユーザーのハッシュを一気に作り直すことは、元のパスワードが分からないため基本的にはできません。
そこで、よく使われる考え方が、次回ログイン時に必要なら更新するという方法です。
この方法であれば、利用者がログインしたタイミングで、少しずつ新しい設定へ移行できます。
ただし、実際の移行では注意点もあります。
古い方式の検証コードをいつまで残すか、ログインしていないユーザーをどう扱うか、移行中の監査ログをどう残すかなど、運用上の設計が必要です。
6.6 メモリを使わせるという考え方
ここまでの説明では、主に「計算回数を増やす」方向で話してきました。
しかし、現代のパスワードハッシュでは、CPU時間だけでなく、メモリを多く使わせることも重要になります。
これは、攻撃者がGPUや専用ハードウェアを使って、大量の候補を並列に試すことを難しくするための考え方です。
たとえるなら、普通のハッシュ関数は、計算問題をすばやく解くようなものです。
一方、メモリを多く使う方式は、計算問題を解くために広い作業机も必要にするようなものです。
1問を解くだけなら何とかなるかもしれません。
しかし、何万問も同時に解こうとすると、作業机の広さが足りなくなります。
Argon2はRFC 9106で、パスワードハッシュやProof-of-Work用途のメモリハード関数として説明されています。
また、Argon2idはArgon2iとArgon2dを組み合わせた方式として説明されています。
参考: RFC 9106 - Argon2 Memory-Hard Function
このように、パスワード保存向け方式では「計算を何回も繰り返す」だけでなく、「メモリも使わせる」方向で攻撃コストを上げる考え方があります。
6.7 重くすれば何でも安全、ではない
ここまで見ると、コスト係数を大きくすればするほど安全に見えるかもしれません。
しかし、実際にはバランスが必要です。
パスワード確認が重くなりすぎると、次のような問題が起きます。
- ログイン画面の応答が遅くなる
- サーバーのCPUやメモリ使用量が増える
- 大量のログイン試行を受けたときに、サービス側の負荷が大きくなる
- ユーザー体験が悪くなり、運用上の問題につながる
そのため、コスト係数は「攻撃者を困らせるために高くする」だけではなく、サービスが安定して動く範囲で高くする必要があります。
NIST SP 800-63Bでも、コスト係数は実用上可能な限り高くし、検証サーバーの性能改善に合わせて上げていくべきだと説明されています。
参考: NIST SP 800-63B
つまり、パスワード保存では次のような姿勢が大切です。
一度設定して終わりにしない
サーバー性能とユーザー体験を確認する
公式資料やライブラリの推奨値を定期的に見直す
古い保存値を段階的に更新できる設計にする
6.8 この章のまとめ
この章では、ストレッチングとコスト係数について整理しました。
ポイントは次の4つです。
- ソルトは重要だが、1回ごとの推測を遅くする仕組みではない
- ストレッチングは、計算を重くして1回の推測コストを上げる考え方
- コスト係数は、どれくらい重くするかを調整する設定値
- コストは高ければよいだけではなく、サーバー性能やユーザー体験とのバランスが必要
ここまでで、パスワード保存には「ソルトで保存値をユーザーごとに変えること」と、「コスト係数で推測を重くすること」が必要だと分かりました。
ただし、これらを自分で適当に組み合わせて実装するのは危険です。
次の章では、実際にパスワード保存で使われる代表的な方式として、Argon2id、scrypt、bcrypt、PBKDF2を整理します。
7. パスワード保存には専用の方式を使う
前の章では、ソルト、ストレッチング、コスト係数、メモリハード性について整理しました。
ここまで読むと、次のように考えるかもしれません。
では、自分で
salt + passwordを作って、SHA-256を何十万回も回せばよいのでは?
考え方としては近づいています。
しかし、実際のパスワード保存では、自分で独自方式を組み立てるのではなく、パスワード保存向けに設計された方式と、信頼できるライブラリを使うのが基本です。
なぜなら、パスワード保存では次のような細かい点まで正しく扱う必要があるからです。
- ソルトを安全な乱数で生成する
- ソルトをユーザーごとに変える
- コスト係数を適切に設定する
- 将来のコスト変更に対応できるようにする
- 検証時にタイミングの違いから情報が漏れにくい比較を行う
- 古い方式から新しい方式へ移行できるようにする
これらをすべて自作で安全に扱うのは、かなり難しいです。
そのため、実務ではArgon2id、scrypt、bcrypt、PBKDF2など、パスワード保存で使われている方式を、フレームワークやライブラリ経由で利用します。
OWASP Password Storage Cheat Sheetでは、パスワード保存の方式としてArgon2id、scrypt、bcrypt、PBKDF2などが整理されています。
参考: OWASP Password Storage Cheat Sheet
7.1 まずは全体像をざっくり見る
代表的な方式を、最初に大きく整理すると次のようになります。
| 方式 | ざっくりした特徴 | 記事での見方 |
|---|---|---|
| Argon2id | 計算時間だけでなく、メモリ使用量も調整できる方式 | 新しく設計する場合の有力な候補 |
| scrypt | メモリを多く使わせる考え方を持つパスワードベース鍵導出関数 | Argon2idが使えない場合の候補 |
| bcrypt | 長く使われてきた、コスト調整可能なパスワードハッシュ方式 | 既存システムやレガシー環境で見かける方式 |
| PBKDF2 | 反復回数で計算コストを調整するパスワードベース鍵導出関数 | FIPS要件などの文脈で使われることがある方式 |
ここで大切なのは、どれか1つを雑に「最強」と言い切らないことです。
OWASP Password Storage Cheat Sheetでは、2026年6月確認時点でArgon2idが優先的な候補として示されています。
しかし、実際には利用している言語、フレームワーク、運用環境、準拠すべき規格、既存データの移行方針などによって選択が変わります。
この記事では、まずそれぞれの方式がどのような考え方を持っているのかを整理します。
7.2 Argon2id: メモリも使わせる現代的な方式
Argon2は、パスワードハッシュやProof-of-Work用途のためのメモリハード関数として、RFC 9106で説明されています。
参考: RFC 9106 - Argon2 Memory-Hard Function
Argon2には、Argon2d、Argon2i、Argon2idという種類があります。
このうち、パスワード保存ではArgon2idがよく候補になります。
ざっくり言うと、Argon2idは次のような特徴を持ちます。
- 計算時間を調整できる
- メモリ使用量を調整できる
- 並列度を調整できる
- GPUや専用ハードウェアで大量に並列試行されることを意識している
前の章で使ったたとえを使うなら、Argon2idは「問題を解く時間」だけでなく、「作業机の広さ」も要求する方式です。
普通の高速ハッシュ:
短い時間でどんどん計算できる
Argon2idのような方式:
計算時間に加えて、一定量のメモリも必要になる
OWASP Password Storage Cheat Sheetでは、Argon2idについてメモリ量、反復回数、並列度の最小設定例が示されています。
ただし、このような値は将来見直される可能性があるため、実装時には必ず最新の公式資料を確認する必要があります。
参考: OWASP Password Storage Cheat Sheet - Argon2id
7.3 scrypt: メモリハード性を持つ鍵導出関数
scryptは、RFC 7914で仕様が説明されているパスワードベース鍵導出関数です。
参考: RFC 7914 - The scrypt Password-Based Key Derivation Function
scryptも、単に計算回数を増やすだけでなく、メモリを多く使わせることを意識した方式です。
パスワード保存の文脈では、Argon2idが使えない場合の候補として紹介されることがあります。
OWASP Password Storage Cheat Sheetでも、Argon2idが使えない場合の選択肢としてscryptの設定例が示されています。
scryptのイメージは、Argon2idと同じく「計算するために広い作業机も必要にする」方式です。
攻撃者が大量の候補を同時に試したい
↓
1回ごとに多くのメモリが必要
↓
並列に大量試行するコストが上がる
ただし、scryptにも設定値があります。
パラメータを軽くしすぎると十分な効果が得られにくくなりますし、重くしすぎるとサービス側の負荷が大きくなります。
つまり、scryptを使う場合も「方式名だけで安心する」のではなく、適切な設定で使うことが重要です。
7.4 bcrypt: 長く使われてきたコスト調整可能な方式
bcryptは、長く使われてきたパスワードハッシュ方式です。
Niels Provos氏とDavid Mazières氏による論文「A Future-Adaptable Password Scheme」では、ハードウェアの高速化に合わせてコストを調整できるパスワード方式の考え方が説明されています。
参考: A Future-Adaptable Password Scheme
bcryptの特徴は、work factorと呼ばれる値で計算コストを調整できることです。
work factor が小さい
→ 計算が軽い
work factor が大きい
→ 計算が重い
この考え方は、前の章で説明した「計算機が速くなるなら、パスワード確認のコストも上げていく」という話とつながります。
bcryptは現在でも多くのシステムで見かける方式です。
ただし、OWASP Password Storage Cheat Sheetでは、bcryptについてパスワード長の扱いなど注意点も説明されています。
たとえば、bcryptでは72バイトを超える入力の扱いに注意が必要です。
参考: OWASP Password Storage Cheat Sheet - bcrypt
そのため、新規設計ではArgon2idなどを検討しつつ、既存システムでbcryptを使っている場合は、現在の推奨設定や移行方針を確認するのがよいです。
💡 豆知識
bcryptは「古いから全部ダメ」というものではありません。
むしろ、長く使われてきたからこそ、既存システムで見かける機会も多い方式です。
ただし、パスワード長の扱いやwork factorの設定など、現代の運用で気をつける点があります。
7.5 PBKDF2: 反復回数でコストを調整する方式
PBKDF2は、RFC 8018で説明されているパスワードベース鍵導出関数です。
参考: RFC 8018 - PKCS #5: Password-Based Cryptography Specification Version 2.1
PBKDF2は、パスワードとソルトを入力に取り、指定した反復回数だけ計算を繰り返して値を作ります。
password + salt
↓
HMACなどを使って何度も計算する
↓
導出された値を得る
前の章で使ったPythonの hashlib.pbkdf2_hmac() は、このPBKDF2の考え方を体験するためのものでした。
PBKDF2は、Argon2idやscryptのようにメモリ使用量を大きく調整する方式ではありません。
主に反復回数によって計算コストを調整します。
OWASP Password Storage Cheat Sheetでは、FIPS-140への準拠が必要な場合にはPBKDF2が選択肢として示されています。
参考: OWASP Password Storage Cheat Sheet - PBKDF2
そのため、PBKDF2は「常に最優先」というより、規格対応や既存環境との関係で使われることがある方式として理解するとよいです。
7.6 方式名だけでなく、保存形式も大切
パスワード保存では、方式名だけでなく、保存形式も重要です。
なぜなら、ログイン時には、保存されている値を見て、同じ方式・同じ設定で再計算する必要があるからです。
たとえば、Argon2idでは次のような形式の文字列を見かけることがあります。
$argon2id$v=19$m=19456,t=2,p=1$...salt...$...hash...
この文字列には、ざっくり言うと次のような情報が含まれます。
| 部分 | 意味 |
|---|---|
argon2id |
使っている方式 |
v=19 |
Argon2のバージョン情報 |
m=19456 |
メモリコスト |
t=2 |
時間コスト、反復回数に近い設定 |
p=1 |
並列度 |
salt |
ユーザーごとのソルト |
hash |
計算されたハッシュ値 |
ここで示した値は、形式を理解するための例です。
実際の設定値は、OWASPや利用ライブラリの公式ドキュメント、サービス側の性能要件を確認して決める必要があります。
このように、パスワード保存向けのライブラリでは、方式・設定値・ソルト・ハッシュ値を1つの文字列にまとめて保存する形がよく使われます。
これはとても大切です。
将来コスト係数を上げたり、方式を移行したりするときに、古い保存値を検証するための情報が必要になるからです。
7.7 PythonでArgon2idを使う場合の雰囲気
ここでは、実装の雰囲気をつかむために、Pythonの argon2-cffi を使った例を示します。
argon2-cffi のドキュメントでは、PasswordHasher を使ってパスワードのハッシュ化、検証、再ハッシュが必要かどうかの確認を行う例が示されています。
参考: argon2-cffi documentation
なお、以下のコードは学習用の例です。
実際のWebアプリケーションでは、利用しているフレームワークの認証機能や、公式ドキュメントの推奨設定を確認してください。
# 事前にインストールが必要です。
# pip install argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
# PasswordHasherは、Argon2を使ってパスワードを保存・検証するためのクラスです。
# 実際の設定値は、ライブラリの既定値や公式資料、サービスの性能要件を確認して決めます。
ph = PasswordHasher()
# ユーザー登録時:
# 入力されたパスワードから、保存用の文字列を作ります。
# この文字列には、方式名、設定値、ソルト、ハッシュ値などが含まれます。
password = "correct horse battery staple"
stored_hash = ph.hash(password)
print(stored_hash)
# 出力例:
# $argon2id$v=19$m=...,t=...,p=...$...$...
# ログイン時:
# ユーザーが入力したパスワードと、保存済みのハッシュ文字列を照合します。
try:
ph.verify(stored_hash, password)
print("ログイン成功")
except VerifyMismatchError:
print("ログイン失敗")
# 設定値が古い場合:
# ライブラリの現在の設定と比べて、保存済みハッシュを作り直すべきか確認できます。
# 作り直す場合は、ログイン時に入力された正しいパスワードを使って再ハッシュします。
if ph.check_needs_rehash(stored_hash):
stored_hash = ph.hash(password)
print("新しい設定で保存値を更新")
このコードで注目したいのは、ソルトを自分で連結したり、ハッシュ値を自分で組み立てたりしていない点です。
信頼できるライブラリを使うことで、方式名、設定値、ソルト、ハッシュ値を適切な形式で扱いやすくなります。
7.8 自作実装を避けた方がよい理由
ここまで見てきたように、パスワード保存にはいくつもの細かい注意点があります。
たとえば、次のような実装は避けるべきです。
import hashlib
# 注意: これは安全な実装例ではありません。
# 「自分でそれっぽく作る」のが危険であることを示すための例です。
def bad_password_hash(password: str, salt: str) -> str:
# 一見すると、ソルトもあり、繰り返しもあり、安全そうに見えるかもしれません。
# しかし、独自方式では設定値の妥当性、保存形式、移行、比較方法などを自分で管理する必要があります。
value = (salt + password).encode("utf-8")
for _ in range(100_000):
value = hashlib.sha256(value).digest()
return value.hex()
このコードは、平文保存よりはだいぶ複雑に見えます。
しかし、パスワード保存の実装としては推奨できません。
理由は、次のような部分を自分で判断しなければならないからです。
- 100,000回という回数は十分なのか
- 数年後にも十分なのか
- ソルトの長さや生成方法は適切か
- 方式名や設定値をどう保存するのか
- 古い設定から新しい設定へどう移行するのか
- 比較処理からタイミング差が漏れないか
- 既存ライブラリの検証済み実装と比べて安全性をどう確認するのか
暗号技術では、見た目が複雑な処理だから安全、とは限りません。
むしろ、独自方式は思わぬ弱点を作り込みやすくなります。
そのため、基本方針は次のように考えると分かりやすいです。
悪い方針:
SHA-256を何回も回して、自分なりの保存方式を作る
よい方針:
Argon2id、scrypt、bcrypt、PBKDF2などの標準的な方式を、信頼できるライブラリ経由で使う
7.9 どれを使えばよいのか
最後に、方式選びの考え方を整理します。
ただし、ここでの表はあくまで理解のための目安です。
実際には、利用するフレームワーク、実行環境、準拠要件、運用方針に合わせて、最新の公式資料を確認してください。
| 状況 | 考え方 |
|---|---|
| 新しく一般的なWebアプリを設計する | Argon2idを第一候補として検討する |
| Argon2idが使えない | scryptを候補として検討する |
| 既存システムがbcryptを使っている | work factorやパスワード長の扱いを確認し、必要に応じて移行方針を考える |
| FIPS-140などの要件がある | PBKDF2が候補になる場合がある |
| どれを使うか迷う | フレームワークの公式推奨、OWASP、NIST、利用ライブラリのドキュメントを確認する |
ここで注意したいのは、方式を選んだだけで終わりではないということです。
パスワード保存では、次の3つをセットで考える必要があります。
方式を選ぶ
設定値を適切に決める
将来見直せるように保存・移行設計をする
方式名だけを見て「Argon2idだから安全」「bcryptだから安全」と判断するのではなく、設定値や運用まで含めて考えることが大切です。
7.10 この章のまとめ
この章では、パスワード保存で使われる代表的な方式を整理しました。
ポイントは次の4つです。
- パスワード保存では、自作方式ではなく専用の方式と信頼できるライブラリを使う
- Argon2idは、計算時間だけでなくメモリ使用量も調整できる現代的な候補
- scrypt、bcrypt、PBKDF2にもそれぞれ使われる文脈や注意点がある
- 方式名だけでなく、設定値、保存形式、将来の移行まで考える必要がある
ここまでで、パスワードをどのような形で保存すべきかがかなり見えてきました。
しかし、どれだけ安全なパスワードハッシュ方式を使っても、それだけでログイン機能全体が安全になるわけではありません。
次の章では、パスワードハッシュだけでは防げない問題と、MFA、レート制限、TLS、セッション管理など、組み合わせて考えるべき対策を整理します。
8. パスワードハッシュだけで安全になるわけではない
ここまでで、パスワードを保存するときには、普通のハッシュ関数を1回使うだけでは不十分であり、Argon2id、scrypt、bcrypt、PBKDF2などのパスワード保存向け方式を使う必要があると整理しました。
では、パスワードハッシュを適切に使えば、ログイン機能はそれだけで安全になるのでしょうか。
答えは、それだけでは足りません。
少し身近な例で考えてみます。
家の玄関に強い鍵を付けても、窓が開いていたり、合鍵を落としていたり、インターホンでだまされて自分からドアを開けてしまったりすれば、家全体としては安全とは言い切れません。
パスワードハッシュも同じです。
パスワードの保存方法はとても重要ですが、ログイン機能全体の中では、あくまで1つの防御層です。
この章では、パスワードハッシュだけでは防げない代表的な問題を整理します。
8.1 弱いパスワードそのものは、ハッシュだけでは強くならない
パスワードハッシュは、保存されたパスワードを漏えい後に解析されにくくするための仕組みです。
しかし、利用者が 123456 や password のような非常に推測されやすいパスワードを使っている場合、そのパスワード自体が強くなるわけではありません。
たとえば、次の2つを比べてみます。
| パスワード | ハッシュ保存した場合の見え方 | それでも残る問題 |
|---|---|---|
123456 |
元の文字列は直接見えない | 候補として最初の方に試されやすい |
| 長くて推測されにくいパスフレーズ | 元の文字列は直接見えない | 推測候補に入りにくい |
どちらも適切にハッシュ化されていれば、データベース上には元のパスワードは見えません。
しかし、漏えい後に攻撃者が候補を試す場合、弱いパスワードは早い段階で当たりやすくなります。
OWASPのAuthentication Cheat Sheetでは、パスワード強度の管理として、最低文字数、長いパスワードを許可すること、Unicodeや空白を含む文字を許可すること、よく使われるパスワードや漏えい済みパスワードをブロックすることなどが整理されています。
参考: OWASP Authentication Cheat Sheet
ここで大切なのは、ユーザーに「大文字、小文字、数字、記号を必ず全部入れてください」と複雑なルールだけを押し付ければよい、という話ではないことです。
むしろ、短くて覚えにくいパスワードよりも、長くて推測されにくいパスフレーズの方が扱いやすい場合があります。
覚えにくい例:
P@7x!q2
覚えやすく、長くしやすい例:
coffee cat river morning 2026
もちろん、上の例をそのまま使うべきという意味ではありません。
言いたいのは、利用者が安全に使いやすい形で、弱いパスワードを避けられる設計にすることが重要だという点です。
💡 豆知識
パスワードの安全性は、「記号が入っているか」だけで決まるわけではありません。
攻撃者にとって予測しにくいか、十分な長さがあるか、過去に漏えいした文字列ではないか、といった観点も重要です。
8.2 オンライン攻撃にはレート制限が必要
ここまでの記事では、データベースが漏えいした後のオフライン推測攻撃を中心に扱ってきました。
しかし、実際のログイン機能では、ログイン画面に対して何度もパスワードを試すオンライン攻撃も考える必要があります。
オンライン攻撃では、攻撃者はサービスのログイン画面を通して試行します。
そのため、サービス側は次のような対策を入れられます。
- 一定回数失敗したら、しばらくログイン試行を止める
- 試行間隔を少しずつ長くする
- 不自然な試行を検知して追加確認を求める
- アカウント単位で失敗回数を管理する
OWASPのAuthentication Cheat Sheetでは、ブルートフォース攻撃などへの対策として、MFA、ログインスロットリング、アカウントロックアウトなどが整理されています。
また、NIST SP 800-63Bでも、認証の検証側は失敗した認証試行回数を効果的に制限するレート制限を実装する必要があると説明されています。
参考: NIST SP 800-63B
イメージとしては、次のような処理です。
import time
from collections import defaultdict
# 注意: これは考え方を説明するための簡略化したコードです。
# 実際のサービスでは、分散環境、永続化、監査ログ、通知、例外処理などを考える必要があります。
# アカウントごとの失敗時刻を保存する
failed_attempts = defaultdict(list)
# 5分間に5回まで失敗を許可する、という例
WINDOW_SECONDS = 5 * 60
MAX_FAILURES = 5
def can_try_login(account_id: str) -> bool:
"""このアカウントで、今ログイン試行を許可してよいか確認する"""
now = time.time()
# 古い失敗履歴を削除する
# ここでは「直近5分以内」の失敗だけを数える
recent_failures = [
t for t in failed_attempts[account_id]
if now - t <= WINDOW_SECONDS
]
failed_attempts[account_id] = recent_failures
# 直近5分以内の失敗回数が上限未満なら試行を許可する
return len(recent_failures) < MAX_FAILURES
def record_login_failure(account_id: str) -> None:
"""ログイン失敗を記録する"""
failed_attempts[account_id].append(time.time())
このコードでは、同じアカウントに対して短時間に何度もログイン失敗が起きた場合、追加の試行を止められるようにしています。
ただし、これはあくまで学習用の簡略化した例です。
実際には、攻撃者がIPアドレスを変えて試す可能性、正規ユーザーを締め出してしまう可能性、アカウントロックを悪用した嫌がらせなども考える必要があります。
つまり、レート制限は「厳しくすればするほどよい」という単純なものではありません。
セキュリティと使いやすさのバランスを見ながら設計する必要があります。
8.3 MFAは「パスワードが破られた後」の被害を減らす
パスワードハッシュは、保存されているパスワードを守るための仕組みです。
しかし、次のような場合はどうでしょうか。
- 利用者がフィッシングサイトにパスワードを入力してしまった
- 別サービスから漏えいしたパスワードを使い回していた
- マルウェアにより入力内容を盗まれた
- すでにパスワードが推測されてしまった
この場合、どれだけ保存方法が適切でも、攻撃者は「正しいパスワード」を知っている状態になります。
そこで重要になるのが、MFA、つまり多要素認証です。
多要素認証では、パスワードだけでなく、別の要素を組み合わせます。
| 要素 | 例 |
|---|---|
| 知っているもの | パスワード、PIN |
| 持っているもの | スマートフォン、認証アプリ、セキュリティキー |
| 本人そのもの | 指紋、顔認証などの生体情報 |
たとえば、パスワードを入力したあとに、スマートフォンの認証アプリで生成されたコードを入力する方式があります。
この場合、攻撃者がパスワードだけを知っていても、追加の確認を突破できない可能性があります。
OWASPのAuthentication Cheat Sheetでは、MFAは多くのパスワード関連攻撃に対する重要な防御策として説明されています。
また、OWASPのMultifactor Authentication Cheat Sheetでは、リスクの高い操作や新しい端末・場所からのログイン時にMFAを求めるリスクベース認証の考え方も紹介されています。
参考: OWASP Multifactor Authentication Cheat Sheet
ただし、MFAも万能ではありません。
たとえば、SMS認証、認証アプリ、プッシュ通知、セキュリティキーでは、それぞれ特徴や注意点が異なります。
ここで押さえたいのは、次の考え方です。
パスワードハッシュ:
データベース漏えい後に、保存済みパスワードを解析されにくくする
MFA:
パスワードだけを知られても、すぐにログインされにくくする
役割が違うため、どちらか一方で十分というより、組み合わせて考えるのが自然です。
8.4 TLSは入力中・送信中のパスワードを守る
ここまでの話は、主に「サーバー側でパスワードをどう保存するか」でした。
しかし、ユーザーが入力したパスワードは、ログイン時にブラウザやアプリからサーバーへ送られます。
この通信が保護されていなければ、送信中のパスワードが盗み見られたり、通信内容を改ざんされたりするリスクがあります。
そこで使われるのが、TLSです。
TLS 1.3を定めるRFC 8446では、TLSはインターネット上でクライアントとサーバーが、盗聴、改ざん、メッセージ偽造を防ぐように設計された方法で通信するためのプロトコルとして説明されています。
参考: RFC 8446: The Transport Layer Security Protocol Version 1.3
ただし、TLSもパスワードハッシュの代わりではありません。
| 対策 | 主に守る場面 |
|---|---|
| TLS | 通信中のデータを守る |
| パスワードハッシュ | 保存済みパスワードを守る |
| レート制限 | ログイン画面での大量試行を抑える |
| MFA | パスワードだけではログインしにくくする |
つまり、TLSで通信を守っていても、サーバーのデータベースに平文パスワードを保存してよいわけではありません。
逆に、パスワードを適切にハッシュ化していても、通信が保護されていなければログイン時に危険が残ります。
守る場所が違うため、両方が必要になります。
8.5 ログイン後はセッション管理が重要になる
ログインに成功したあと、ユーザーは毎回パスワードを入力しなくても、マイページや設定画面を開けます。
これは、サービスが「このユーザーはログイン済みである」という状態を覚えているからです。
この状態を管理する仕組みが、セッション管理です。
多くのWebサービスでは、ログイン後にセッションIDやトークンのような値をブラウザへ渡し、その後のリクエストでそれを使ってログイン状態を確認します。
ここで危険なのは、セッションIDを盗まれると、攻撃者がそのユーザーになりすませる可能性があることです。
OWASPのSession Management Cheat Sheetでは、セッションIDが漏えい、取得、予測、総当たり、固定化されると、攻撃者がユーザーになりすますセッションハイジャックにつながると説明されています。
また、セッションIDには十分なエントロピーを持たせ、意味のある情報を含めず、HTTPS全体で扱い、Cookieには Secure、HttpOnly、SameSite などの属性を適切に設定することが説明されています。
参考: OWASP Session Management Cheat Sheet
Cookieを使う場合のイメージは、次のようになります。
# 注意: これはWebフレームワークに依存しない説明用の疑似コードです。
# 実際には利用するフレームワークの公式機能を使って設定してください。
response.set_cookie(
name="id", # セッションIDを入れるCookie名。意味を持たせすぎない名前にする
value=session_id, # サーバー側で生成した十分ランダムなセッションID
secure=True, # HTTPS通信でのみCookieを送信する
httponly=True, # JavaScriptからCookieを読み取りにくくする
samesite="Lax", # クロスサイトリクエストでCookieが送られにくいようにする
path="/", # Cookieを送るパスの範囲
)
このコードのポイントは、パスワードハッシュとはまったく別の対策をしていることです。
パスワードハッシュは「保存済みパスワード」を守ります。
一方で、セッション管理は「ログイン後の状態」を守ります。
ログイン前とログイン後では、守るべきものが変わるということです。
8.6 パスワード再設定は「裏口」になりやすい
もう1つ見落としやすいのが、パスワード再設定です。
多くのサービスには、「パスワードを忘れた方はこちら」という機能があります。
この機能は利用者にとって必要ですが、設計が甘いとログイン画面を迂回する別ルートになってしまいます。
たとえば、次のような実装は危険です。
危険な例:
- 登録メールアドレスを入力すると、現在のパスワードをメールで送る
- 推測しやすい秘密の質問だけでパスワードを変更できる
- 再設定URLのトークンが長時間使える
- 再設定トークンを何度も使える
- 存在するメールアドレスと存在しないメールアドレスで画面表示が違う
OWASPのForgot Password Cheat Sheetでは、パスワード再設定時には、存在するアカウントと存在しないアカウントで一貫したメッセージと応答時間にすること、再設定用トークンは単回使用で適切な期限を持つこと、正しいトークンが提示されるまではアカウント状態を変更しないことなどが説明されています。
参考: OWASP Forgot Password Cheat Sheet
ここでも考え方は同じです。
パスワード保存が安全でも、再設定機能が弱ければ、攻撃者はそこを狙う可能性があります。
💡 豆知識
「現在のパスワードをメールで送ってくれるサービス」は、一見親切に見えるかもしれません。
しかし、安全な設計では、サービス側が現在のパスワードを読める形で持つ必要はありません。
そのため、多くのサービスでは「現在のパスワード通知」ではなく「再設定」という流れになります。
8.7 防御は1枚ではなく、何枚も重ねる
ここまでの内容をまとめると、パスワードハッシュはとても重要ですが、守れる範囲には限界があります。
| 対策 | 主に防ぎたいこと | パスワードハッシュとの関係 |
|---|---|---|
| パスワードハッシュ | DB漏えい後のパスワード解析 | 中心となる保存対策 |
| ソルト・コスト係数 | 事前計算や大量推測 | パスワードハッシュの一部として考える |
| パスワード強度チェック | 弱いパスワードの利用 | ハッシュ前の入力段階の対策 |
| レート制限 | ログイン画面への大量試行 | オンライン攻撃への対策 |
| MFA | パスワード単体の突破 | パスワードが知られた後の被害軽減 |
| TLS | 通信中の盗聴・改ざん | 入力値やセッションIDの通信保護 |
| セッション管理 | ログイン後のなりすまし | ログイン成功後の状態管理 |
| パスワード再設定の保護 | 再設定機能の悪用 | ログイン迂回経路の対策 |
このように、ログイン機能の安全性は1つの技術だけで決まるものではありません。
むしろ、次のように考えると分かりやすいです。
パスワード保存で守る
ログイン試行を制限する
パスワード以外の要素も確認する
通信を守る
ログイン後のセッションを守る
再設定経路も守る
このように、複数の対策を重ねる考え方を、多層防御と呼びます。
1つの対策が破られても、別の対策で被害を小さくする。
これが、現実的なセキュリティ設計ではとても重要です。
8.8 この章のまとめ
この章では、パスワードハッシュだけでは安全にならない理由を整理しました。
ポイントは次の5つです。
- パスワードハッシュは、保存済みパスワードを守るための重要な対策である
- しかし、弱いパスワード、使い回し、フィッシング、オンライン攻撃までは単独で防げない
- ログイン画面にはレート制限やアカウントロックなどのオンライン攻撃対策が必要である
- パスワードが知られた場合に備えて、MFAも重要になる
- TLS、セッション管理、パスワード再設定まで含めて、ログイン機能全体を守る必要がある
ここまでで、パスワード保存を中心にしつつ、ログイン機能全体の安全性まで視野を広げました。
次の章では、記事全体を振り返りながら、「普通のハッシュ関数をそのまま使ってはいけない理由」をまとめます。
9. まとめ
最後に、本記事で整理した内容を振り返ります。
パスワード保存では、「ハッシュ化しているか」だけでなく、「漏えいしたあとにどれだけ試されにくいか」まで考えることが大切です。
本記事では、普段のログイン画面を入口にして、パスワード保存とハッシュ関数の関係を整理しました。
パスワード保存でまず避けるべきなのは、パスワードをそのまま保存することです。
平文で保存していると、データベースが漏えいした瞬間に、利用者のパスワードがそのまま外部に出てしまいます。
また、Base64のようなエンコードは、見た目を変えているだけで、パスワードを守る仕組みではありません。
復号できる形で保存する暗号化も、ログイン機能のパスワード保存では基本的な解決策にはなりません。
ログイン時に必要なのは、元のパスワードを取り出すことではなく、入力されたパスワードが正しいかを確認することだからです。
ここでハッシュ関数が登場します。
ただし、この記事のタイトルにもある通り、パスワード保存では 普通のハッシュ関数をそのまま使うだけでは不十分 です。
9.1 なぜ普通のハッシュ関数だけでは足りないのか
SHA-256のようなハッシュ関数は、ファイルの改ざん検知やデータの照合など、さまざまな場面で使われる重要な技術です。
そのため、「SHA-256が悪い」という話ではありません。
問題は、パスワード保存という用途では、普通のハッシュ関数の「速く計算できる」という長所が、攻撃者にも有利に働いてしまうことです。
データベースからハッシュ値が漏えいすると、攻撃者はログイン画面を通らず、自分の手元で候補パスワードを大量に試せます。
このような攻撃を考えると、パスワード保存では次の2つが重要になります。
| 観点 | 必要な理由 |
|---|---|
| 同じパスワードでも保存値を変える | 同じパスワードを使っているユーザーを見分けにくくし、事前計算済みの表を使い回しにくくするため |
| 1回の推測コストを上げる | 漏えい後に攻撃者が大量の候補を試す速度を下げるため |
前者に関係するのが ソルト です。
後者に関係するのが ストレッチング や コスト係数、さらにArgon2idやscryptで重視される メモリハード性 です。
つまり、パスワード保存で必要なのは、単に「元に戻せない値」を作ることではありません。
元に戻しにくい
+ 同じパスワードでも保存値が変わる
+ 大量に試すにはコストがかかる
このように、漏えい後の攻撃まで考えて保存する必要があります。
9.2 パスワード保存では専用の方式を使う
パスワード保存では、ソルトやコスト係数を自分で適当に組み合わせるのではなく、パスワード保存向けに設計された方式を使うことが大切です。
代表的な方式には、次のようなものがあります。
| 方式 | ざっくりした位置づけ |
|---|---|
| Argon2id | 新しく設計する場合の有力な候補。計算時間だけでなくメモリ使用量も調整できる |
| scrypt | メモリを使わせることで、専用機材による大量試行を難しくする考え方を持つ |
| bcrypt | 長く使われてきたパスワードハッシュ方式。既存システムで見かけることが多い |
| PBKDF2 | 反復回数で計算コストを調整する方式。FIPS要件などの文脈で使われることがある |
OWASPのPassword Storage Cheat Sheetでは、Argon2idを優先候補として示し、環境に応じてscrypt、bcrypt、PBKDF2などの選択肢も整理されています。
参考: OWASP Password Storage Cheat Sheet
また、NIST SP 800-63Bでも、パスワードはオフライン攻撃に耐える形で保存し、パスワード、ソルト、コスト係数を入力に取る適切なパスワードハッシュ方式を使うことが説明されています。
参考: NIST SP 800-63B - Authentication and Lifecycle Management
ここで大切なのは、方式名を暗記することではありません。
「なぜその方式が必要なのか」を理解することです。
パスワード保存では、攻撃者がハッシュ値を入手したあとのことまで考え、1回ごとの推測を重くする設計が必要になります。
9.3 パスワードハッシュはログイン機能全体の一部
もう1つ大切なのは、パスワードハッシュだけでログイン機能全体が安全になるわけではないという点です。
パスワードハッシュは、主に 保存済みパスワードが漏えいしたあとの被害を小さくするための対策 です。
一方で、ログイン機能には他にも守るべき場所があります。
たとえば、次のように役割が分かれます。
| 対策 | 主に守るもの |
|---|---|
| パスワードハッシュ | 保存済みパスワード |
| ソルト・コスト係数 | 漏えい後の推測攻撃への耐性 |
| パスワード強度チェック | 弱すぎるパスワードの利用 |
| レート制限 | ログイン画面への大量試行 |
| MFA | パスワードだけに依存しない確認 |
| TLS | 通信中の盗聴・改ざん |
| セッション管理 | ログイン後のなりすまし |
| パスワード再設定保護 | 再設定機能の悪用 |
このように見ると、パスワード保存はログイン機能を守るための重要な部品ですが、全体の一部でもあります。
セキュリティでは、1つの対策にすべてを任せるのではなく、複数の対策を重ねることが大切です。
これを 多層防御 と呼びます。
9.4 本記事の要点
最後に、本記事の要点を短くまとめます。
| よくある考え方 | 注意点 |
|---|---|
| パスワードをそのまま保存する | 漏えいした瞬間にパスワードが読まれる |
| Base64で変換して保存する | エンコードなので簡単に戻せる |
| 暗号化して保存する | 復号鍵の管理に失敗すると元に戻される可能性がある |
| SHA-256を1回使う | 高速に大量推測されやすい |
| ソルトだけ付ける | 事前計算対策にはなるが、1回ごとの推測は遅くならない |
| 自作で何度もハッシュする | 設計ミスや設定ミスが入りやすい |
| 専用方式とライブラリを使う | 実務ではこの方向が基本になる |
この記事で特に伝えたいことは、次の一文です。
パスワード保存では、「ハッシュ化しているか」だけでなく、漏えい後に大量の候補を試されても破られにくいかを考える必要がある。
そのため、普通のハッシュ関数をそのまま使うのではなく、Argon2id、scrypt、bcrypt、PBKDF2などのパスワード保存向け方式を、信頼できるライブラリを通して使うことが大切です。
9.5 他暗号技術とのつながり
本記事では、暗号技術の中でも「パスワード保存」と「ハッシュ関数」に絞って説明しました。
ただし、実際のサービスではパスワードハッシュだけでなく、TLS、共通鍵暗号、公開鍵暗号、デジタル署名、MAC、証明書、鍵共有などなど、さまざまな暗号技術が組み合わされています。
暗号技術は、単語だけを見ると難しく感じるかもしれません。
しかし、「何を守るための技術なのか」「どの場面で使われるのか」「ほかの技術とどう組み合わさるのか」に分けると、少しずつ整理しやすくなります。
この記事が、ログイン機能の裏側やパスワード保存の安全な考え方を理解する入口になればうれしいです。