なぜ「持ち方」が問題になるのか
2026 年、KDDI が ISP 事業者向けに提供しているメールシステムへの不正アクセスで、7,616,173 件のパスワードが漏えいしたと公表されました(KDDI「ISP事業者向けメールシステムへの不正アクセスに関するご報告」2026 年 7 月 6 日、7 月 21 日更新。https://newsroom.kddi.com/news/assets/2026/kddi_nr_s-73_4619/kddi_nr_s-73_4619_pdf_01.pdf )。
パスワードを平文(ひらぶん。何も加工していない、そのまま読める形)で保存していると、データベースが盗まれた瞬間に、そこに書かれたパスワードがそのまま攻撃者の手に渡ります。多くの人は同じパスワードを別のサービスでも使っているので、被害はそのサービスだけでは終わりません。
NIST(米国国立標準技術研究所)のデジタル認証の指針 SP 800-63B-4 は、パスワードをソルト付きのハッシュで保存することを求めています。この章では、その言葉の意味を順に説明します。
平文・暗号化・ハッシュの違い
パスワードの保存方法は、大きく 3 つに分けられます。
flowchart LR P["パスワード"] -->|そのまま| A["平文: 盗まれたらそのまま使われる"] P -->|鍵で暗号化| B["暗号化: 鍵も盗まれたら元に戻される"] P -->|ハッシュ関数| C["ハッシュ: 元には戻せない。照合だけできる"]
- 平文: 論外です。DB を見られた時点で終わりです。
- 暗号化(あんごうか。鍵を使って読めない形にし、同じ鍵で元に戻せる変換): 一見安全そうですが、元に戻せることが弱点です。サーバーは照合のために鍵を持っているので、サーバーごと盗まれると鍵も一緒に盗まれ、全員分が元に戻されます。
- ハッシュ(元に戻せない変換): 同じ入力からはいつも同じ出力が出るが、出力から入力を計算で求めることはできない、という性質の変換です。ログインのときは、入力されたパスワードを同じ方法で変換し、保存してある値と一致するかを比べます。
サーバーはパスワードを「知っている」必要はありません。「照合できる」だけで足ります。だから、元に戻せない形で持つのが正しい選択です。
ハッシュだけでは足りない理由: 塩(ソルト)
ハッシュには弱点が 1 つあります。同じパスワードからは同じ値が出るので、攻撃者は「よく使われるパスワードとそのハッシュの対応表」を前もって作っておけます。盗んだ DB とその表を照らし合わせるだけで、多くのパスワードが見つかってしまいます。
そこで、利用者ごとに違うソルト(塩。ハッシュの前にパスワードに混ぜる、利用者ごとのランダムな値)を使います。ソルトが違えば、同じパスワードでも保存される値はまったく別物になり、前もって作った表は役に立ちません。
flowchart TD P1["Aさん: hello-world-12345"] --> H1["ソルトX と混ぜてハッシュ"] P2["Bさん: hello-world-12345"] --> H2["ソルトY と混ぜてハッシュ"] H1 --> R1["保存される値: 1つめ"] H2 --> R2["保存される値: 2つめ(1つめと違う)"]
ソルトは秘密にする必要はなく、ハッシュの値と一緒に保存しておきます(照合のときに同じソルトを使うため)。
遅いハッシュを選ぶ理由: Argon2id
もう 1 つの工夫は、わざと計算に時間とメモリがかかるハッシュを使うことです。
ふつうのハッシュ関数(SHA-256 など)は、とても速く計算できるように作られています。これはファイルの改ざん検知などには便利ですが、パスワードでは攻撃者にとって便利すぎます。盗んだハッシュに対して、候補のパスワードを 1 秒間に膨大な数だけ試せるからです。
Argon2id(アルゴン・ツー・アイ・ディー)は、パスワードの保存のために作られたハッシュの方式で、1 回の計算に多くのメモリを必要とします(メモリハード)。利用者がログインするときの 1 回分は気にならない程度ですが、攻撃者が何億回も試そうとすると、メモリが足りずに速度が出ません。
このプロジェクトでは、ハッシュの計算そのものは argon2 というライブラリ(よく使われる処理をまとめた部品)に任せています。暗号に関わる処理を自前で書くと、目に見えない誤りが入りやすいからです。
// 出典: src/auth/password.ts:80-92
/**
* S1: Argon2id でハッシュ化する。ソルトはライブラリが毎回ランダムに作り、結果の文字列に含める。
* 利用者ごとにソルトが違うので、同じパスワードでも保存される値は別物になる(事前計算表が効かない)。
* パラメータはライブラリの既定(メモリ 64MiB・反復 3・並列 4)。OWASP の推奨最低値を上回る。
*/
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(normalizePassword(password), { type: argon2.argon2id });
}
/** S1: 保存してあるハッシュと照合する。比較はライブラリ内で一定時間で行われる */
export async function verifyPassword(passwordHash: string, password: string): Promise<boolean> {
return argon2.verify(passwordHash, normalizePassword(password));
}
hashPasswordは登録やパスワード変更のときに呼ばれ、結果だけが DB に保存されます。verifyPasswordはログインのときに呼ばれ、「一致したか(true / false)」だけを返します。normalizePasswordは、見た目が同じ文字の違い(全角・半角など)をそろえる処理です(03 章)。- OWASP(オワスプ。Web の安全性について指針を出している国際的な団体)の推奨値を上回る設定を、ライブラリの既定値として使っています。
argon2.hash が返す文字列は、$argon2id$v=19$m=65536,t=3,p=4$(ソルト)$(ハッシュ) という形をしています。方式・パラメータ・ソルト・ハッシュが 1 つの文字列にまとまっているので、DB の列は 1 つで足ります。
-- 出典: src/db/migrations/001_init.sql:9-10
-- S1: Argon2id のハッシュ(PHC 文字列。ソルトを内包する)。NULL は「パスキーのみ」のユーザー
password_hash TEXT,
列の名前が password ではなく password_hash なのは、この列にパスワードそのものを入れてはいけないことを名前で示すためです。
本当に平文が残っていないかをテストで確かめる
「ハッシュで保存しているはず」と思い込むのは危険です。たとえば、デバッグのために書いたログ出力や、別の表へのコピーに、うっかりパスワードが残ることがあります。
このプロジェクトでは、登録のあとにDB の全テーブル・全列を書き出し、登録に使ったパスワードの文字列がどこにも含まれないことをテストで確かめています(test/integration/security.test.ts の S1、test/integration/audit.test.ts)。テストで使うパスワードも、毎回ランダムに作っています。テストのファイルに具体的なパスワードを書かないためです。
まとめ
- サーバーはパスワードを「知る」必要はなく、「照合できる」だけでよい
- 暗号化は元に戻せるので不十分。元に戻せないハッシュで持つ
- 利用者ごとのソルトで、事前に作った対応表を無効にする
- Argon2id のような遅い(メモリを食う)ハッシュで、盗まれた後の総当たりを遅くする
- 暗号の処理は自前で書かず、実績のあるライブラリに任せる
次の章では、「どんなパスワードなら受け付けるか」という規則を見ます。
この解説の原本は GitHub の docs/guide/02-パスワードの正しい持ち方.md です(MIT ライセンス)。誤りや分かりにくいところは、GitHub の Issue でお知らせください。