Webアプリケーションにおけるパスワード認証と保持の正しい仕組み:なぜ「暗号化」もNGで「ハッシュ化」が必須なのか
Webアプリケーションを設計・開発する際、ユーザーのパスワードをデータベース(DB)にどのように保持し、どうやってログイン認証を行うかは、セキュリティ設計における最も基本的かつ重要なテーマです。
「パスワードを平文(そのままの文字列)で保存してはいけない」ということは広く知られていますが、実際の開発現場や仕様書では、以下のような誤解や不適切な表現がいまだに見受けられます。
- 「平文が危険なら、AESなどの強固な方式で暗号化してDBに保存すればよいのではないか?」
- 「パスワードは不可逆な暗号化を行って保存する」
- 「とりあえず
SHA-256でハッシュ化しておけば安全だろう」
結論から言えば、パスワードの保存に「暗号化」を使うのも、「単なるSHA-256」を使うのも誤りであり、また「不可逆な暗号化」という言葉自体が技術的に矛盾した不適切な表現です。
本記事では、パスワード保持における「平文」「暗号化」「ハッシュ化」の決定的な違いを整理した上で、現代のWebアプリケーションにおいてどのようなアルゴリズム・方式で実装するのが正しいのかを体系的に解説します。
1. 大前提:なぜ「平文保存」は絶対に行ってはいけないのか
まず大前提として、パスワードをそのままの文字列(平文)でデータベースに保存することが「論外」とされる理由を整理しておきましょう。
- 情報漏洩時の被害が致命的になる SQLインジェクションや設定不備、バックアップデータの流出、不正アクセスなどによってDBのテーブルが漏洩した瞬間、攻撃者は全ユーザーのIDと生パスワードのペアを手に入れることになります。
- パスワードの使い回しによる二次被害(リスト型攻撃) どれほど注意喚起しても、多くのユーザーは複数のWebサービスで同じパスワードを使い回しています。ひとつのサービスから平文パスワードが流出すると、攻撃者はそれをリスト化して他社のECサイトや金融サービス、SNSへの不正ログイン(クレデンシャルスタッフィング/リスト型アカウントクラッキング)に悪用し、自社サービスにとどまらない広範な社会的被害を引き起こします。
- 内部不正リスクと運用者の負担 DBの閲覧権限を持つインフラエンジニアやデータベース管理者(DBA)、あるいはログを閲覧できる開発者が、ユーザーの生パスワードをいつでも目にすることができてしまいます。これは内部不正のリスクを高めるだけでなく、善良な運用者にとっても不要な心理的負担・責任を背負わせる結果になります。
2. なぜ「暗号化」して保存するのも良くないのか?
平文保存の危険性を理解した上で、次に陥りやすい誤りが「では、AES-256などの強固な暗号アルゴリズムでパスワードを暗号化してDBに保存しよう」という設計です。
しかし、パスワードの保持において「暗号化」を選択することも明確なアンチパターン(非推奨)です。その理由は、暗号化という技術が「元のデータに戻すこと(復号)」を前提としているからです。
2.1 暗号鍵が漏洩すれば「平文保存」と全く同じになる
アプリケーションがログイン時やバッチ処理でパスワードを復号する仕組みになっている場合、アプリケーションサーバーの環境変数、設定ファイル、あるいはKMS(鍵管理サービス)など、システムのどこかに必ず「復号するための鍵(秘密鍵・共通鍵)」が存在します。
- 攻撃者がアプリサーバーや環境変数に侵入
- 2つを同時に奪取
- DBから「暗号化されたパスワード一覧」を奪取
- サーバー内(またはKMS権限)から「暗号鍵」を奪取
- 【結果】全ユーザーのパスワードが一瞬で「平文」に復号される
近年の侵害事例では、DB単体だけでなくアプリケーションサーバーやコンテナ環境ごと侵害されるケースが大半です。鍵と暗号文の両方が同じシステム圏内に存在する以上、暗号化は「平文に戻るリスク」を常に抱え続けることになります。
2.2 そもそもシステムは「元のパスワード」を知る必要がない
ここが最も本質的なポイントです。
たとえば、ユーザーの「住所」「電話番号」「マイナンバー」「APIトークン」などは、後から画面に表示したり、配送システムや外部APIへ送信したりする業務上の必要があるため、元の値に復元できる「暗号化」を用いて保護するのが正解です。
しかし、パスワードはどうでしょうか?
ログイン認証においてシステムが確認したいのは、「今ユーザーが入力した文字列が、登録時のパスワードと一致しているか」という一点のみです。システム自身が「このユーザーの元のパスワードは love_cat_2026 である」と知る必要は、登録から退会に至るまで一度もありません。
- 元の値に戻す必要があるデータ(個人情報など) ──▶ 暗号化(Encryption)
- 一致確認だけできればよく、元の値に戻す必要がないデータ(パスワード) ──▶ ハッシュ化(Hashing)
3. 「暗号化」と「ハッシュ化」の違いと、「不可逆な暗号化」が不適切な理由
設計書や仕様書、あるいは技術記事の中で「パスワードは不可逆な暗号化を行って保存する」という表現を見かけることがあります。言いたいこと(元の値に戻せない形で保存する)は伝わりますが、技術用語としては誤りであり、使うべきではありません。
「暗号化」と「ハッシュ化」の違いを正確に比較してみましょう。
| 比較項目 | 暗号化 (Encryption) | ハッシュ化 (Hashing) |
|---|---|---|
| 本来の目的 | 第三者に内容を読まれないようにデータを秘匿し、正当な鍵を持つ者だけが元のデータを復元すること | 任意のデータから固定長の「要約値(ダイジェスト)」を算出し、同一性の検証や改ざん検知を行うこと |
| 変換の方向性 | 可逆(双方向):暗号化(Encrypt)と復号(Decrypt)が対になっている | 不可逆(一方向):ハッシュ値から元のデータを逆算する操作は存在しない |
| 出力の長さ | 元のデータの長さに比例して変わる | 元のデータが1文字でも1GBでも常に固定長(例:SHA-256なら常に256ビット) |
| 鍵の有無 | 暗号鍵・復号鍵が必須 | 原則として鍵は存在しない(※HMACやペッパーを除く) |
| 代表アルゴリズム | AES, ChaCha20, RSA など | SHA-256, SHA-3, Argon2id, bcrypt, scrypt など |
なぜ「不可逆な暗号化」は矛盾しているのか?
「暗号(Cipher / Encryption)」とは、情報工学および暗号学の定義上、「特定の鍵を用いることで元の平文に一意に復号できる変換(全単射)」を指します。もし復号できないのであれば、それは暗号として成立していません。
一方で、ハッシュ関数は「無限にあり得る入力データを、有限の固定長ビット列に押し込める要約関数(多対一)」であり、情報を意図的に削ぎ落とすため原理的に逆算が不可能です。これを指す正しい用語は「一方向ハッシュ関数によるハッシュ化」です。
実務において「不可逆な暗号化」という曖昧な言葉を仕様書に書いてしまうと、次のような実装ミスを誘発します。
- 実装者が「暗号化」という言葉に引っ張られ、
AES-256-CBC等で暗号化して保存してしまう。 - 逆に、復号が必要なデータに対して「暗号化(=ハッシュ化のことだと思い込む)」を適用し、後からデータを取り出せなくなる。
4. ハッシュ値を使ったパスワード認証の基本メカニズム
元のパスワードを保存せず、復号もできないのに、どうやってログイン時にパスワードが正しいと判定できるのでしょうか。それは、ハッシュ関数が持つ以下の3つの性質を利用しているからです。
- 決定論的(同一入力・同一出力):同じ入力文字列からは、何度計算しても必ず同じハッシュ値が出力される。
- 一方向性(原像計算困難性):出力されたハッシュ値から、元の入力文字列を逆算することは計算量的に不可能。
- 雪崩効果(Avalanche Effect):入力文字列が1文字(1ビット)でも異なれば、出力されるハッシュ値は全く別物になる。
この性質を使うことで、「ハッシュ値同士を比較する」だけで認証が完結します。
- ユーザー入力:
"MySecretPass!2026" - パスワード専用ハッシュ関数 + ソルトハッシュ値:
"$argon2id$v=19$m=65536,t=3,p=4$..."- DBにはこの文字列だけを保存
- ※元の
"MySecretPass!2026"はメモリ上から破棄し、どこにも保存しない
- ユーザー入力:
"MySecretPass!2026" - DBに記録されたソルト・パラメータを使って同じハッシュ関数を実行計算されたハッシュ値:
"$argon2id$v=19$m=65536,t=3,p=4$..." - 一致するか比較(定数時間比較)DBから取得したハッシュ値:
"$argon2id$v=19$m=65536,t=3,p=4$..."
この仕組みであれば、システムは普段から元のパスワードを保持しておらず、万が一DBのテーブルが丸ごと外部に流出したとしても、そこに入っているのは「逆算できないハッシュ値のリスト」だけになります。
5. 「ただハッシュ化するだけ(SHA-256単体など)」では危険な理由
ここで次の疑問が浮かびます。「では、標準ライブラリにある SHA-256 や SHA-512 にパスワードを通して、そのハッシュ値をDBに保存すれば安全なのか?」
答えは No(不十分で危険) です。
確かにハッシュ値から元の文字列を「数学的に逆算(復号)」することはできません。しかし、攻撃者は逆算する代わりに、「ありそうなパスワード候補を片っ端からハッシュ関数に入力し、漏洩したDBのハッシュ値と一致するものを探す」という方法でパスワードを特定します。
単純な SHA-256(password) だけの実装には、以下の2つの致命的な弱点があります。
5.1 脅威①:レインボーテーブル攻撃と「同一パスワードの露呈」
同じ入力からは常に同じハッシュ値が生まれるため、もしユーザーAとユーザーBが同じ "password123" を設定していた場合、DB上のハッシュ値も全く同じ ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f になります。
攻撃者はあらかじめ、よく使われる数億〜数十億個のパスワード文字列と、その SHA-256 ハッシュ値の対応表(レインボーテーブル)を作成して持っています。DBが流出した際、計算すら行わず、この対応表でハッシュ値を検索(逆引き)するだけで、一瞬で元のパスワードが判明してしまいます。
5.2 脅威②:GPU・専用ハードウェアによる超高速な総当たり攻撃(ブルートフォース/辞書攻撃)
MD5 や SHA-1 はもちろん、SHA-256 や SHA-512、SHA-3 といった汎用ハッシュ関数は、本来「巨大なファイルやネットワーク通信の改ざん検知を、少ないCPU負荷で可能な限り高速に計算する」ために設計されています。
この「高速である」という長所が、パスワード保護においては最悪の弱点になります。
現在、コンシューマ向けのハイエンドGPU(NVIDIA RTX 4090 / 5090など)を複数枚束ねた環境やクラウドGPUインスタンスを使えば、SHA-256 のハッシュ計算を1秒間に数百億回(数十GH/s)という猛烈な速度で実行できます。8〜10文字程度の一般的なパスワードや、英単語を組み合わせたパスワードであれば、数分から数時間で総当たりが完了してしまいます。
6. 正しいパスワード保持のための3大要素(ソルト・ストレッチング・ペッパー)
こうした「事前計算(レインボーテーブル)」と「高速な総当たり攻撃」を防ぐため、現代のパスワード保持では以下の3つの仕組みを組み合わせるのが必須とされています。
- 入力をそろえる
- ユーザーの入力パスワード
- + ① ソルト(ユーザーごとにランダム生成・DBに平文保存)
- + ③ ペッパー(システム共通の秘密鍵・環境変数やKMSに保存 ※任意だが推奨)
- ② ストレッチング & メモリハード処理(Argon2id / bcrypt)
- 意図的にCPU時間と大量のメモリを消費させ、1回の計算に数十〜数百ミリ秒かける
- 最終的なパスワードハッシュ文字列(DBに保存)
① ソルト(Salt):事前計算と同一ハッシュを防ぐ
ソルト(Salt)とは、パスワードをハッシュ化する際に付与するユーザーごとに異なるランダムなバイト列(16バイト以上推奨)です。
- 仕組み:登録時に暗号論的に安全な乱数生成器(CSPRNG)でランダムなソルトを生成し、
ハッシュ関数(パスワード + ソルト)を計算します。 - 効果:たとえ1万人のユーザーが全員
"12345678"という同じパスワードを設定しても、全員に異なるソルトが付与されるため、DBに保存されるハッシュ値は1万人すべて全く異なる値になります。これにより、既存のレインボーテーブルは完全に無力化され、複数ユーザーをまとめて解読することもできなくなります。 - ソルトの保存場所:ソルトは「秘密鍵」ではないため、暗号化せずハッシュ値と一緒にDBの同じカラム(または隣り合うカラム)にそのまま保存して構いません。
② ストレッチング(Key Stretching)とメモリハード化:総当たりを遅くする
GPUによる毎秒数百億回の総当たり攻撃を防ぐ唯一の方法は、「1回のハッシュ計算を意図的にめちゃくちゃ重く(遅く)する」ことです。
- ストレッチング(CPU負荷):ハッシュ計算を内部で何千回〜何十万回も反復(イテレーション)させ、計算時間を引き延ばします。
- メモリハード(Memory-Hard:メモリ負荷):近年のGPUやASIC(専用半導体)は、単純な計算コアを何万個も並べるのは得意ですが、1コアあたりが使える高速メモリ容量は限られています。そこで、「1回のハッシュ計算に数十MBのメモリ領域を必須とする」ように設計することで、GPUによる並列計算の効率を劇的に落とすことができます。
正規のユーザーがログインする際、認証処理に 0.1秒〜0.3秒(100〜300ミリ秒) かかったとしても、体感上のストレスはほとんどありません。しかし、何十億通りものパスワードを試したい攻撃者にとって、1回あたり0.2秒かかるということは、1秒間にたった5回しか試せないことを意味します。これにより、総当たり攻撃に必要な時間を「数時間」から「数万年」へと引き延ばすことができます。
③ ペッパー(Pepper):DB単体漏洩時の最終防衛ライン
ペッパー(Pepper)とは、ソルトとは別に、アプリケーション全体で共通して使用する秘密の値(シークレットキー)です。
- 仕組み:パスワードにソルトだけでなくペッパーも混ぜてハッシュ化する(あるいは先にペッパーを鍵としてパスワードの
HMAC-SHA256を計算してから、その結果をArgon2id等に入力する)方式です。 - 保存場所:ソルトがDBに保存されるのに対し、ペッパーはDBには絶対に保存せず、アプリケーションサーバーの環境変数やシークレットマネージャ(AWS Secrets Manager / KMS 等)に隔離して保持します。
- 効果:万が一、SQLインジェクションやDBバックアップの盗難によって「DBのデータだけ」が漏洩した場合でも、別管理されているペッパーが漏洩しない限り、攻撃者はオフラインでの辞書攻撃や総当たり攻撃を1回たりとも開始できなくなります。
7. 採用すべきパスワード専用ハッシュアルゴリズム(2026年現在の標準)
OWASP(Open Web Application Security Project)の「Password Storage Cheat Sheet」および NIST SP 800-63B に基づく、現在推奨されるアルゴリズムの優先順位は以下の通りです。
| 優先度 | アルゴリズム | メモリ耐性 (GPU/ASIC耐性) | 特徴・選定基準 |
|---|---|---|---|
| 第1推奨 | Argon2id | 極めて高い(Memory-Hard) | 2015年のPassword Hashing Competition優勝アルゴリズム。サイドチャネル攻撃耐性(Argon2i)とGPUクラック耐性(Argon2d)の両方を兼ね備える現在のデファクトスタンダード。新規開発ならこれ一択。 |
| 第2推奨 | scrypt | 高い(Memory-Hard) | Argon2登場以前から使われているメモリハード関数。Argon2idが使えない環境での有力な選択肢。 |
| 第3推奨 | bcrypt | 中程度(4KB固定) | 長年使われてきた実績あるアルゴリズム。多くの言語・フレームワークで標準搭載されているが、72バイト制限などの注意点がある。 |
| 条件付き | PBKDF2 | 低い(CPU負荷のみ) | 米国政府標準(FIPS 140)等のコンプライアンス要件で指定されている場合のみ採用。GPU並列攻撃に弱いため、60万回以上の非常に高い反復回数が必要。 |
7.1 第1選択:Argon2id の推奨パラメータ
新規にWebアプリケーションを構築する場合、あるいは既存の認証基盤を刷新する場合は Argon2id を選択します。Argon2には Argon2d、Argon2i、Argon2id の3つのバリアントがありますが、パスワード保存には必ずハイブリッド型の Argon2id を使います。
OWASPが示す最低限のパラメータ目安は以下の通りです(実行環境のCPU・メモリ余裕度に合わせて、1回の計算が100ms〜300ms程度になるよう調整します)。
- メモリコスト(
m/memoryCost):19 MiB(19456 KiB)〜64 MiB(65536 KiB)以上 - イテレーション回数(
t/timeCost):2〜3回以上 - 並列度(
p/parallelism):1〜4スレッド - ソルト長:
16 バイト(128ビット)以上(ライブラリが自動生成)
7.2 第2・第3選択:bcrypt を使う場合の重大な注意点(72バイト問題)
既存のフレームワーク(Laravel、Ruby on Rails、Spring Security等)のデフォルトとして bcrypt を使うケースも多いでしょう。bcrypt 自体は現在でもワークファクター(コスト)を 10 〜 12 以上に設定すれば実用上の安全性を持っていますが、1つだけ必ず知っておくべき仕様上の罠があります。
それは、「bcrypt は入力されたパスワードの先頭72バイトまでしか処理せず、73バイト目以降を黙って切り捨てる(無視する)」という仕様です。
- 英数字のみであれば72文字ですが、日本語(UTF-8)が含まれる場合は24文字、絵文字なら18文字程度で72バイトに達します。
- また、パスワードの前に長いペッパーを結合するような誤った実装をすると、パスワード本体の大部分が72バイト枠から押し出されて無視されてしまいます。
- さらに、意図的に数メガバイトの巨大文字列を送りつけてハッシュ計算を詰まらせる DoS攻撃(計算資源枯渇) のリスクもあります。
そのため、Webアプリケーションではアルゴリズムを問わず、バリデーション層で「パスワードの最小文字数(8〜12文字以上)」だけでなく「最大文字数(例:64文字〜128文字まで)」の上限を設けることがベストプラクティスです。
8. 具体的な実装コード例(Node.js / Python / Go)
現代のパスワードハッシュライブラリは非常に洗練されており、ソルトの自動生成からパラメータの埋め込み、タイミング攻撃を防ぐ定数時間比較(Constant-time comparison)まで、すべて関数ひとつで自動的に処理してくれます。
生成される文字列(PHC String Format)は以下のような構造になっており、アルゴリズム名・バージョン・コストパラメータ・ソルト・ハッシュ値のすべてが $ 区切りで1つの文字列に収まります。
$argon2id方式$v=19バージョン$m=65536,t=3,p=4パラメータ$c29tZXNhbHQxMjM0NTYソルト$RdescudvJCsgt3ub+b+dWRWJTmaaJObGハッシュ値そのため、DBのユーザテーブルには password_hash VARCHAR(255) NOT NULL のような1つのカラムを用意するだけで完結します(ソルト用の別カラムを作る必要すらありません)。
8.1 Node.js / TypeScript(argon2 ライブラリ)
import * as argon2 from "argon2";
import { createHmac } from "node:crypto";
// 環境変数やKMSから取得するシステム共通のペッパー(32バイト以上のランダム文字列)
const PEPPER = process.env.PASSWORD_PEPPER!;
/**
* ペッパーを適用した中間値を生成(HMAC-SHA256)
* ※これによりDB単体漏洩時のオフライン解析を防ぎつつ、bcrypt等の長大入力問題も回避できる
*/
function applyPepper(plainPassword: string): Buffer {
return createHmac("sha256", PEPPER).update(plainPassword).digest();
}
/**
* 1. パスワード登録時のハッシュ化処理
*/
export async function hashPassword(plainPassword: string): Promise<string> {
const pepperedPassword = applyPepper(plainPassword);
// argon2id を指定。16バイトの暗号論的乱数ソルトが自動生成され、戻り値の文字列に埋め込まれる
const passwordHash = await argon2.hash(pepperedPassword, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MiB
timeCost: 3, // イテレーション 3回
parallelism: 4, // 4並列
});
// この passwordHash 文字列をそのまま DB の password_hash カラムに保存する
return passwordHash;
}
/**
* 2. ログイン時のパスワード検証処理
*/
export async function verifyPassword(
storedHash: string,
inputPassword: string
): Promise<boolean> {
const pepperedPassword = applyPepper(inputPassword);
// storedHash 内に含まれるソルトとパラメータを自動で読み取り、定数時間比較で安全に検証する
return await argon2.verify(storedHash, pepperedPassword);
}8.2 Python(argon2-cffi ライブラリ)
Pythonではデファクトスタンダードである argon2-cffi パッケージを使用します。将来サーバーのスペックが上がった際に、ログイン成功タイミングで自動的に新しいコストパラメータへ移行(再ハッシュ)できる check_needs_rehash メソッドも備わっています。
import os
import hmac
import hashlib
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
PEPPER = os.environ["PASSWORD_PEPPER"].encode("utf-8")
# OWASP推奨値を満たす設定でインスタンス化
ph = PasswordHasher(
time_cost=3, # イテレーション回数
memory_cost=65536, # 64 MiB (KiB単位で指定)
parallelism=4, # 並列スレッド数
hash_len=32, # 出力ハッシュ長 (32 bytes)
salt_len=16 # 自動生成されるソルト長 (16 bytes)
)
def _apply_pepper(plain_password: str) -> bytes:
return hmac.new(PEPPER, plain_password.encode("utf-8"), hashlib.sha256).digest()
def register_password(plain_password: str) -> str:
"""登録時:ハッシュ化してDB保存用の文字列を返す"""
peppered = _apply_pepper(plain_password)
return ph.hash(peppered)
def authenticate_user(stored_hash: str, input_password: str) -> bool:
"""ログイン時:パスワードを照合する"""
peppered = _apply_pepper(input_password)
try:
ph.verify(stored_hash, peppered)
# もし将来 memory_cost 等を引き上げた場合、古いパラメータのハッシュを検知して透過的に更新できる
if ph.check_needs_rehash(stored_hash):
new_hash = ph.hash(peppered)
# TODO: DBの password_hash カラムを new_hash で UPDATE する
return True
except VerifyMismatchError:
return False9. まとめ:パスワード設計のチェックリスト
最後に、Webアプリケーションにおけるパスワード認証・保持の要点をチェックリストとしてまとめます。設計レビューやセキュリティ診断の際にぜひ活用してください。
パスワードを適切にハッシュ化して保持することは、ユーザーに対する最低限かつ最大の責務です。正しい用語と正しいアルゴリズムを選択し、万が一のデータ漏洩時にもユーザーの認証情報を守り抜ける堅牢なシステムを構築しましょう。