【Laravel】パスワードハッシュ化(bcrypt)の仕組み

laravelアイキャッチ Laravel

こんにちは、かつコーチです。

ここまでSanctumによるAPI認証、Policy・Gateによる認可、CSRF対策、XSS対策と、Laravelのセキュリティ機能を順番に見てきました。

今回はその締めくくりとして、認証の土台となるパスワードのハッシュ化を扱います。

「パスワードをそのままデータベースに保存してはいけない」というのはよく聞く話ですが、なぜダメなのか、Laravelはどう対策しているのかを初心者向けに丁寧に解説します。

なぜパスワードを平文で保存してはいけないのか

平文保存のリスク

平文(へいぶん)とは、暗号化や変換をしていない、そのままの文字列のことです。

もしユーザーのパスワードを平文のままデータベースに保存していたとすると、次のようなリスクがあります。

  • データベースに不正アクセスされた場合、すべてのユーザーのパスワードがそのまま漏洩する
  • 漏洩したパスワードは、他のサービスでも使い回されている可能性が高い(パスワードの使い回しは非常に多い)
  • 開発者や運用担当者が、悪意の有無に関わらずユーザーのパスワードを見られる状態になってしまう

私が初めてこの話を学んだとき、「パスワードは暗号化して保存すればいいのでは?」と思っていました。

しかし実際には、Laravelを含む多くのフレームワークが採用しているのは「暗号化」ではなくハッシュ化という別の仕組みです。

この違いを理解することが、パスワード管理の基本になります。

暗号化とハッシュ化の違い

暗号化とハッシュ化は、似ているようで性質が大きく異なります。

暗号化ハッシュ化
元に戻せるか鍵があれば復号できる元に戻せない(不可逆)
用途通信内容の秘匿などパスワードなど「照合できればよい」情報
同じ入力の結果鍵が同じなら同じ暗号文アルゴリズムが同じでも、通常は毎回異なる出力になる

パスワードは「元の文字列に戻す必要がない」情報です。

ログイン時にやりたいのは「ユーザーが入力したパスワードが正しいか照合すること」であって、「保存されているパスワードを復元して見ること」ではありません。

だからこそ、元に戻せないハッシュ化という一方通行の変換が適しています。

ハッシュ化の仕組みとbcrypt

ハッシュ関数の基本的な性質

ハッシュ関数とは、どんな長さの入力からも、決まった長さの文字列(ハッシュ値)を生成する関数です。

同じ入力からは常に同じハッシュ値が生成されますが、ハッシュ値から元の入力を逆算することは(現実的な時間では)できません。

この性質を利用して、パスワードそのものではなく「パスワードのハッシュ値」だけをデータベースに保存します。

ログイン時は、入力されたパスワードを同じ方法でハッシュ化し、保存されているハッシュ値と一致するかどうかだけを比較します。

なぜ単純なハッシュ関数(MD5やSHA-1)ではダメなのか

「ハッシュ化すればなんでも安全」というわけではありません。

MD5やSHA-1のような単純なハッシュ関数は、パスワードのハッシュ化には向いていません。

理由は、これらの関数が高速に計算できるように設計されているためです。

計算が高速だと、攻撃者は「よく使われるパスワードのハッシュ値を大量に事前計算しておく」(レインボーテーブル攻撃)や、「総当たりで大量のパスワード候補を高速に試す」(ブルートフォース攻撃)といった手法で、ハッシュ値から元のパスワードを短時間で割り出しやすくなってしまいます。

そこで登場するのがbcryptです。

bcryptが持つ2つの特徴

bcryptは、パスワードハッシュ化専用に設計されたアルゴリズムで、次の2つの特徴を持っています。

1. ソルトが自動的に付与される

ソルトとは、ハッシュ化する前にパスワードへ付け加えるランダムな文字列です。

同じパスワード「password123」であっても、ユーザーごとに異なるソルトが使われるため、生成されるハッシュ値もユーザーごとに異なります。

これにより、事前計算されたハッシュ値の一覧(レインボーテーブル)を使った攻撃が通用しなくなります。

2. 意図的に計算コストが高い

bcryptは、意図的に計算に時間がかかるよう設計されています。

MD5が数百万回/秒計算できるのに対し、bcryptは適切な設定であれば1秒間に数十回程度しか計算できません。

正規のログイン処理では1回の計算で済むため実用上は問題になりませんが、攻撃者が総当たりでパスワードを試そうとすると、この「遅さ」が大きな障壁になります。

Laravelでのパスワードハッシュ化の実装

Hashファサードを使ったハッシュ化

Laravelでは、Hashファサードを使ってパスワードをハッシュ化します。

デフォルトの設定(config/hashing.php)では、bcryptが使用されます。

<?php
// app/Http/Controllers/Auth/RegisteredUserController.php

use App\Models\User;
use Illuminate\Support\Facades\Hash;

public function store(Request $request)
{
    $request->validate([
        'name' => ['required', 'string', 'max:255'],
        'email' => ['required', 'email', 'unique:users'],
        'password' => ['required', 'confirmed', 'min:8'],
    ]);

    $user = User::create([
        'name' => $request->name,
        'email' => $request->email,
        // ハッシュ化してから保存する
        'password' => Hash::make($request->password),
    ]);

    return redirect()->route('login');
}

Hash::make()に平文のパスワードを渡すと、bcryptでハッシュ化された文字列が返ってきます。

実際にデータベースに保存される値は、次のような見た目になります。

$2y$12$92IXUNpkjO0rOQ5byMi.YeIVeF/Kf0V4Wwz.dNyIH7g0K3.CqR/i.

$2y$はbcryptであることを示す識別子、12$はコストパラメータ(計算コストの高さを表す数値)、それ以降がソルトとハッシュ値です。

ログイン時の照合にはHash::check()を使う

ログイン時は、入力されたパスワードをそのままハッシュ化して比較するのではなく、Hash::check()メソッドを使います。

<?php
// app/Http/Controllers/Auth/AuthenticatedSessionController.php

use Illuminate\Support\Facades\Hash;

public function store(Request $request)
{
    $credentials = $request->validate([
        'email' => ['required', 'email'],
        'password' => ['required'],
    ]);

    $user = User::where('email', $credentials['email'])->first();

    if (! $user || ! Hash::check($credentials['password'], $user->password)) {
        return back()->withErrors([
            'email' => 'メールアドレスまたはパスワードが正しくありません。',
        ]);
    }

    // ログイン成功処理
    Auth::login($user);

    return redirect()->intended('/dashboard');
}

Hash::check()は、保存されているハッシュ値からソルトを取り出し、入力されたパスワードを同じソルトでハッシュ化したうえで比較してくれます。

このため、開発者が自分でソルトを管理する必要はありません。

つまずきやすいポイント:ハッシュ化を忘れる・二重にハッシュ化する

パスワードを平文のまま保存してしまう

私が初めてLaravelでユーザー登録機能を実装したとき、User::create()にリクエストの値をそのまま渡してしまい、パスワードが平文でデータベースに保存されてしまったことがあります。

❌ Before:Hash::make()を忘れてそのまま保存する

<?php
$user = User::create([
    'name' => $request->name,
    'email' => $request->email,
    // ハッシュ化を忘れて平文のまま保存してしまう
    'password' => $request->password,
]);

このコードはエラーなく動作してしまうため、テストでログインまで確認しない限り、平文保存に気づきにくいのが厄介なところです。

私はこの後、php artisan tinkerでデータベースの中身を確認したときに、パスワード欄がそのまま読める状態になっているのを見て青ざめました。

✅ After:必ずHash::make()を通してから保存する

<?php
$user = User::create([
    'name' => $request->name,
    'email' => $request->email,
    // 必ずハッシュ化してから保存する
    'password' => Hash::make($request->password),
]);

Userモデルの$castsプロパティに'password' => 'hashed'を指定しておくと、モデル経由での保存時に自動的にハッシュ化してくれる仕組みもあります(Laravel 10以降)。

<?php
// app/Models/User.php

protected function casts(): array
{
    return [
        'email_verified_at' => 'datetime',
        'password' => 'hashed',
    ];
}

このキャストを設定しておくと、うっかりHash::make()を忘れても自動的にハッシュ化されるため、平文保存の事故を構造的に防げます。

保存済みのハッシュ値を再度ハッシュ化してしまう

もう一つよくあるミスが、更新処理で「変更がない場合でも毎回ハッシュ化してしまう」パターンです。

<?php
// パスワード欄が空でもハッシュ化してしまい、
// 元のパスワードでログインできなくなる典型的なミス

public function update(Request $request, User $user)
{
    $user->update([
        'name' => $request->name,
        'password' => Hash::make($request->password), // 空文字がハッシュ化されてしまう
    ]);
}

プロフィール編集画面などで「パスワードを変更したい場合だけ入力する」形式にしている場合、入力が空のときはハッシュ化処理自体をスキップする条件分岐が必要です。

<?php
public function update(Request $request, User $user)
{
    $user->name = $request->name;

    if ($request->filled('password')) {
        $user->password = Hash::make($request->password);
    }

    $user->save();
}

$request->filled('password')で「値が入力されているか」を確認してからハッシュ化することで、意図しないパスワードリセットを防げます。

まとめ

この記事のポイント

  • パスワードは復元できる「暗号化」ではなく、不可逆な「ハッシュ化」で保存する
  • MD5・SHA-1のような高速なハッシュ関数はパスワード保存に向かない
  • bcryptはソルトの自動付与と意図的な計算コストの高さで、総当たり攻撃に強い
  • LaravelではHash::make()で保存、Hash::check()で照合するのが基本パターン
  • モデルのcasts'password' => 'hashed'を指定すると、ハッシュ化忘れを構造的に防げる

次に読むべき記事

パスワードの安全な保存方法が分かったところで、次はユーザーごとに異なる操作範囲を管理する、ロール・権限管理の実装パターンを見ていきましょう。

→ 次の記事:ロール・権限管理の実装パターン

タイトルとURLをコピーしました