ログイン機能を作るときに必ず出てくるのが「パスワードをどうやってデータベースに保存するか」という問題です。平文のまま保存するのは論外として、md5() や sha1() で変換してから保存している古いコードもまだ見かけます。実はこれも安全ではありません。
PHP には、パスワード専用のハッシュ関数 password_hash() と、照合用の password_verify() が標準で用意されています。この記事では、なぜ専用の関数が必要なのか、基本の使い方、アルゴリズムやオプションの指定、登録からログインまでの実践的なコード、そして password_verify() が常に false になってしまうときの原因まで解説します。
目次
md5() や sha1() でパスワードを保存してはいけない理由
パスワードを平文でデータベースに入れてはいけないことは、多くの人が知っています。データベースが漏えいした時点で全ユーザーのパスワードがそのまま流出しますし、ユーザーは他のサービスでも同じパスワードを使い回していることが多いので、被害は自分のサイトの外まで広がります。
では md5() や sha1() を通して保存すれば安心かというと、そうではありません。理由は大きく2つあります。
同じパスワードが同じハッシュになる(レインボーテーブル)
md5('password123') の結果は、誰がどこで実行しても同じ文字列になります。ということは、よく使われるパスワードとそのハッシュ値をあらかじめ一覧表にしておけば、漏えいしたハッシュ値を表から逆引きするだけで元のパスワードが分かってしまいます。この一覧表がレインボーテーブルと呼ばれるもので、有名なハッシュ関数については既に大量に出回っています。
さらに、同じハッシュ値が並んでいれば「この2人は同じパスワードを使っている」ということまで分かってしまいます。これを防ぐには、ユーザーごとに違うランダムな文字列(ソルト)をパスワードに混ぜてからハッシュ化する必要があります。
計算が速すぎる
MD5 や SHA-1 は、ファイルの同一性チェックのように大量のデータを高速に処理することを目的に設計されています。速いことが利点の関数です。ところがパスワードの保護では、この速さがそのまま攻撃者の武器になります。総当たりで片っ端から試す攻撃に対して、1回あたりの計算が速いということは、単位時間に試せる候補が多いということだからです。
パスワード用のハッシュには、わざと時間とメモリを消費するアルゴリズム(bcrypt や Argon2 など)を使います。ログイン1回に数十ミリ秒かかる程度なら利用者は気になりませんが、総当たり攻撃の側から見ると試行回数が桁違いに制限されます。
この「ユーザーごとのソルト」と「意図的に遅いアルゴリズム」の2つをまとめて面倒見てくれるのが、PHP の password_hash() です。
password_hash() の基本
使い方はとても簡単で、パスワードとアルゴリズムを渡すだけです。
<?php
$password = 'my-secret-password';
$hash = password_hash( $password, PASSWORD_DEFAULT );
echo $hash;
// $2y$10$3xKQ1v... のような文字列が返る
戻り値は、アルゴリズムの識別子・コスト・ソルト・ハッシュ値をひとまとめにした1本の文字列です。ソルトは password_hash() が自動で生成して結果に含めてくれるので、自分でランダム文字列を作って連結したり、別カラムに保存したりする必要はありません。むしろ余計なことをしないほうが安全です。
ソルトが毎回ランダムに作られるので、同じパスワードをハッシュ化しても、実行のたびに違う文字列になります。ここは最初につまずきやすいところなので、実際に確かめてみましょう。
<?php
$a = password_hash( 'same-password', PASSWORD_DEFAULT );
$b = password_hash( 'same-password', PASSWORD_DEFAULT );
var_dump( $a === $b ); // bool(false) ← 同じパスワードでも一致しない
つまり、ログイン時に「入力されたパスワードをハッシュ化して、保存済みのハッシュと文字列比較する」というやり方は使えません。照合には次の password_verify() を使います。
password_verify() で照合する
password_verify() は、第1引数にユーザーが入力した生のパスワード、第2引数に保存してあるハッシュを渡します。戻り値は true か false の真偽値です。
<?php
$hash = password_hash( 'my-secret-password', PASSWORD_DEFAULT );
var_dump( password_verify( 'my-secret-password', $hash ) ); // bool(true)
var_dump( password_verify( 'wrong-password', $hash ) ); // bool(false)
ハッシュ文字列にはソルトもコストも埋め込まれているので、password_verify() はそれを読み取って同じ条件でハッシュ化し直し、内部で比較してくれます。こちらが情報を渡す必要はありません。
やってしまいがちなのが、次のように自分でハッシュ化して == で比べるコードです。
<?php
// 悪い例:ソルトが毎回変わるので、正しいパスワードでも絶対に一致しない
if ( password_hash( $input, PASSWORD_DEFAULT ) === $storedHash ) {
// ここには到達しない
}
// 良い例
if ( password_verify( $input, $storedHash ) ) {
// ログイン成功
}
なお password_verify() の内部比較は、文字列の先頭から順に比べて違いが見つかった時点で打ち切る、という実装にはなっていません。処理時間の差から情報が漏れるのを防ぐためです。自前で === を使った比較を書くより、この点でも安全です。
アルゴリズムの定数とオプション
password_hash() の第2引数には、次の定数を指定できます。
| 定数 | 説明 |
|---|---|
PASSWORD_DEFAULT | PHP が既定とするアルゴリズムを使う。より強力なアルゴリズムが PHP に追加されたときに、指すものが変わる可能性がある |
PASSWORD_BCRYPT | bcrypt(CRYPT_BLOWFISH)を使う。$2y$ で始まる60文字のハッシュを返す |
PASSWORD_ARGON2I | Argon2i を使う。PHP が Argon2 サポート付きでビルドされている場合のみ利用できる |
PASSWORD_ARGON2ID | Argon2id を使う。こちらも Argon2 サポート付きのビルドが必要 |
基本的には PASSWORD_DEFAULT を使っておけば問題ありません。PASSWORD_DEFAULT が指すアルゴリズムは固定ではなく、将来 PHP により強力なアルゴリズムが追加されたときに変更される可能性があると公式マニュアルに明記されています。これは欠点ではなく、アプリ側のコードを書き換えなくても、PHP のバージョンアップに合わせて推奨設定に追従できるという利点です。あとで説明する password_needs_rehash() と組み合わせることで、既存ユーザーのハッシュも少しずつ新しい方式に移行できます。
アルゴリズムの細かい強度は、第3引数の連想配列で調整します。指定できるキーはアルゴリズムごとに決まっています。
| オプション | 対象 | 説明 |
|---|---|---|
cost | bcrypt | 計算量を表す数値。1増やすと計算回数がおよそ2倍になる |
memory_cost | Argon2 | ハッシュ化に使うメモリ量(KiB 単位) |
time_cost | Argon2 | ハッシュ化の反復回数 |
threads | Argon2 | ハッシュ化に使うスレッド数 |
<?php
// bcrypt でコストを明示的に指定する
$hash = password_hash( $password, PASSWORD_BCRYPT, [
'cost' => 12,
] );
// ハッシュに使われた設定は password_get_info() で確認できる
print_r( password_get_info( $hash ) );
// [algo] => 2y
// [algoName] => bcrypt
// [options] => Array ( [cost] => 12 )
cost の既定値は PHP のバージョンによって変わることがあるので、強度を自分でコントロールしたい場合は明示的に指定してください。値を上げれば安全側に倒れますが、そのぶんログイン処理の時間も伸びます。自分のサーバーで実際に計測して、待たされても不自然でない範囲に収めるのが現実的な決め方です。
ちなみに、かつて存在した salt オプションは PHP 7.0 で非推奨となり、PHP 8.0 で削除されました。ソルトを自分で指定する方法はもうありません。自動生成に任せるのが正解です。
もうひとつ、bcrypt を使う場合の仕様として、パスワードのうち先頭72バイトまでしか使われないという制限があります。日本語を含むパスフレーズのように長い文字列を許可しているサイトでは、73バイト目以降が照合に影響しないことを頭の片隅に置いておいてください。
保存先のカラムは255文字にしておく
実務でいちばんよくあるトラブルが、カラムが短すぎてハッシュが途中で切り捨てられるケースです。bcrypt のハッシュはちょうど60文字なので、VARCHAR(60) で作ってしまう人がいます。これは動いてしまうぶん厄介です。
しかし PASSWORD_DEFAULT の中身は将来変わる可能性があり、そのときハッシュの長さも変わります。Argon2 のハッシュは bcrypt より長くなります。公式マニュアルでも60文字を超えて拡張できるカラム、具体的には255文字を推奨しています。
CREATE TABLE users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(255) NOT NULL,
-- ハッシュは長さが変わりうるので余裕を持たせる
password_hash VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
カラム名も password ではなく password_hash のようにしておくと、「ここに入っているのは平文ではない」と一目で分かります。
password_needs_rehash() でハッシュを作り直す
コストを上げたり、PHP のバージョンアップで PASSWORD_DEFAULT の中身が変わったりすると、すでに保存済みのハッシュは古い設定のままになります。とはいえ、データベースにはハッシュしか入っていないので、管理者側から一括で作り直すことはできません。元のパスワードが分からないからです。
そこで使うのが password_needs_rehash() です。ログインが成功した瞬間だけは生のパスワードが手元にあるので、そのタイミングで作り直して保存し直します。
<?php
// 引数は password_hash() に渡しているものと同じ条件を指定する
if ( password_needs_rehash( $storedHash, PASSWORD_DEFAULT ) ) {
$newHash = password_hash( $password, PASSWORD_DEFAULT );
// $newHash をデータベースに保存し直す
}
第2引数以降には、今後そろえたい設定を書きます。password_hash() をコスト付きで呼んでいるなら、password_needs_rehash() にも同じオプション配列を渡してください。設定がずれていると、ログインのたびに毎回「作り直しが必要」と判定されてしまいます。
<?php
// 設定を1か所にまとめておくと、ずれが起きにくい
const HASH_ALGO = PASSWORD_BCRYPT;
const HASH_OPTIONS = [ 'cost' => 12 ];
$hash = password_hash( $password, HASH_ALGO, HASH_OPTIONS );
if ( password_needs_rehash( $storedHash, HASH_ALGO, HASH_OPTIONS ) ) {
// 作り直す
}
登録からログインまでの実践コード
ここまでの内容をつないで、ユーザー登録とログインの流れを書いてみます。データベースへのアクセスは PDO を使い、値は必ずプレースホルダーで渡します。ユーザーの入力を SQL 文に直接つなげると SQL インジェクションの入り口になるので、文字列連結で組み立てることは避けてください。
<?php
function get_pdo(): PDO {
$dsn = 'mysql:host=localhost;dbname=myapp;charset=utf8mb4';
return new PDO( $dsn, 'db_user', 'db_password', [
// エラーを例外として受け取る
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
// エミュレーションを切って、本物のプリペアドステートメントを使う
PDO::ATTR_EMULATE_PREPARES => false,
] );
}
登録側です。受け取ったパスワードには一切手を加えず、そのまま password_hash() に渡すのがポイントです。
<?php
require_once __DIR__ . '/db.php';
$email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';
if ( $email === '' || $password === '' ) {
exit( 'メールアドレスとパスワードを入力してください' );
}
// パスワードは加工せず、入力されたままハッシュ化する
$hash = password_hash( $password, PASSWORD_DEFAULT );
$pdo = get_pdo();
$stmt = $pdo->prepare(
'INSERT INTO users (email, password_hash) VALUES (:email, :hash)'
);
// 値はプレースホルダー経由で渡す(SQL インジェクション対策)
$stmt->execute( [
':email' => $email,
':hash' => $hash,
] );
echo '登録が完了しました';
ログイン側では、メールアドレスでユーザーを1件取得し、password_verify() で照合します。あわせて password_needs_rehash() も入れておきます。
<?php
require_once __DIR__ . '/db.php';
session_start();
$email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';
$pdo = get_pdo();
$stmt = $pdo->prepare(
'SELECT id, password_hash FROM users WHERE email = :email'
);
$stmt->execute( [ ':email' => $email ] );
$user = $stmt->fetch();
// ユーザーが存在しない場合とパスワードが違う場合で、
// メッセージを変えない(どちらなのかを攻撃者に教えない)
if ( $user === false || ! password_verify( $password, $user['password_hash'] ) ) {
exit( 'メールアドレスまたはパスワードが違います' );
}
// 認証に成功した今だけ生のパスワードが手元にあるので、
// 必要ならこのタイミングでハッシュを作り直す
if ( password_needs_rehash( $user['password_hash'], PASSWORD_DEFAULT ) ) {
$newHash = password_hash( $password, PASSWORD_DEFAULT );
$update = $pdo->prepare(
'UPDATE users SET password_hash = :hash WHERE id = :id'
);
$update->execute( [
':hash' => $newHash,
':id' => $user['id'],
] );
}
// セッション固定攻撃を防ぐため、ID を振り直してからログイン状態にする
session_regenerate_id( true );
$_SESSION['user_id'] = $user['id'];
echo 'ログインしました';
この形にしておけば、あとからコストを上げたくなったときも password_hash() と password_needs_rehash() の設定を変えるだけで済みます。ログインしてきたユーザーから順に、新しい設定のハッシュへ置き換わっていきます。
password_verify() が常に false になるとき
「登録はできるのに、正しいパスワードでログインできない」という相談は本当によくあります。関数自体の使い方ではなく、その手前でパスワードやハッシュが変わってしまっていることが原因のほとんどです。まず、データベースに入っているハッシュを直接見て、$2y$ で始まる文字列が途中で切れていないかを確認してください。
カラムが短くてハッシュが切り捨てられている
いちばん多い原因です。password_hash カラムを VARCHAR(32) や VARCHAR(50) で作っていると、60文字のハッシュが入りきらずに後ろが落ちます。困ったことに、MySQL の設定によってはエラーにならず警告だけで保存されてしまうので、登録処理は成功したように見えます。
切り捨てられたハッシュは password_verify() に渡しても復元できないため、何度やっても false です。カラムを VARCHAR(255) に広げたうえで、既に壊れているレコードのパスワードは再設定してもらう必要があります。
パスワードを trim() や htmlspecialchars() で加工している
入力値のサニタイズを一括処理している既存コードだと、パスワードまで対象になっていることがあります。たとえば htmlspecialchars() を通すと、& や < を含むパスワードが別の文字列に化けます。登録時とログイン時で処理が揃っていれば偶然動いてしまいますが、片方だけ加工されていれば当然一致しません。
そもそもパスワードは HTML として出力するものではないので、エスケープは不要です。trim() も同様で、前後の空白を意味のある文字として設定したユーザーがいれば、その人はログインできなくなります。パスワードは受け取ったバイト列のまま password_hash() と password_verify() に渡す、と決めてしまうのが確実です。
ハッシュ化を二重に行っている
古いコードから移行した場合に起きがちなのが、password_hash( md5( $password ), PASSWORD_DEFAULT ) のように、以前のハッシュ処理を残したまま新しい関数を重ねてしまうパターンです。登録側だけこの形になっていて、ログイン側が password_verify( $password, $hash ) になっていれば一致しません。
同じことは password_hash() の結果をもう一度 password_hash() に渡している場合にも起こります。ハッシュ化は登録処理の中で1回だけ、と整理してください。
引数の順番が逆になっている
password_verify( $hash, $password ) と書いてしまうミスも意外とあります。第1引数が生のパスワード、第2引数がハッシュです。逆に渡しても PHP はエラーを出さず、単に false を返すだけなので気づきにくい種類の間違いです。
同じ理由で、データベースから取得したカラムを取り違えているケースもあります。$user['password_hash'] のつもりで $user['password'] を渡していて中身が空、というような場合です。切り分けに迷ったら、password_verify() に渡す直前の2つの値を一時的に出力して確認してみてください(確認が終わったら必ず消してください)。
まとめ
パスワードの保存は、password_hash( $password, PASSWORD_DEFAULT ) でハッシュ化して保存し、password_verify( $input, $hash ) で照合する。基本はこれだけです。ソルトは自動生成されるので自分で用意する必要はなく、同じパスワードでも毎回違うハッシュになるため、ハッシュ同士を == で比較することはできません。
保存先のカラムは VARCHAR(255) にして、将来アルゴリズムが変わっても入りきるようにしておきます。そして、ログイン成功時に password_needs_rehash() を挟んでおけば、設定を新しくしたときも既存ユーザーのハッシュが自然に移行していきます。md5() や sha1() を使った自前の実装より、標準関数に任せるほうが短く書けて安全です。