技術資料

Laravel 13の登録フォームにPwned Passwords APIを組み込む:漏洩パスワードの確認と障害時の扱い

作成日:2026.08.30

Laravelで、説明用の登録フォームにPwned Passwords APIを組み込みます。SHA-1ハッシュの先頭5文字だけをAPIへ送り、返されたサフィックスをアプリケーション側で照合します。漏洩検出時はフォームエラー、API障害時は警告を表示して登録を続ける構成と、カスタムバリデーションルールとの使い分けを解説します。

Webアプリケーションの登録フォームでは、入力されたパスワードが過去のデータ漏洩に含まれていないか確認したい場合があります。

Have I Been Pwnedが提供するPwned Passwords APIを利用すると、パスワードそのものや完全なハッシュ値を送信せずに、既知の漏洩パスワードとの照合を行えます。

今回は、PHP 8.3.15、Laravel Framework 13.24.0の環境で、説明用のシンプルな登録フォームへPwned Passwords APIを組み込みます。APIで漏洩が確認できた場合はフォームへエラーを表示し、APIのタイムアウトや接続障害が起きた場合は警告を表示したうえで登録処理を続ける構成にします。

登録先には、以前の記事で作成したmembersテーブルを利用します。今回は説明用フォームのため、名前とメールアドレスだけをDBへ保存し、パスワードは保存しません。

前提

今回の対象環境は以下の通りです。

  • Windows 11
  • PHP 8.3.15
  • Laravel Framework 13.24.0
  • Composer
  • PowerShell
  • SQLite

Laravelプロジェクトは作成済みで、membersテーブルとMember Modelが存在する状態を前提にします。Laravel 13のBladeフォーム、バリデーション、CSRF対策については、以前のLaravel 13でBladeの新規登録フォームを実装する記事で説明しています。

今回のフォームでは、次の4項目を入力します。

  • 名前
  • メールアドレス
  • パスワード
  • パスワード確認

実際のユーザー認証、メールアドレスの確認、パスワードのDB保存、パスワードリセットは扱いません。実際のアプリケーションへ組み込む場合は、認証機能とパスワード管理を別途設計してください。

Pwned Passwords APIの仕組み

Pwned Passwords APIでは、入力されたパスワードをそのままAPIへ送信しません。まず、アプリケーション側でパスワードをSHA-1ハッシュへ変換します。

例えば、ハッシュ値が次のようになったとします。

CBFDAC6008F9CAB4083784CBD1874F76618D2A97

この値を先頭5文字のプレフィックスと、残りのサフィックスへ分けます。

プレフィックス: CBFDA
サフィックス:   C6008F9CAB4083784CBD1874F76618D2A97

APIへ送信するのは、プレフィックスだけです。

GET https://api.pwnedpasswords.com/range/CBFDA

APIは、同じプレフィックスから始まるハッシュのサフィックスと、漏洩データに登場した回数を返します。アプリケーション側で、元のサフィックスと一致する行があるか確認します。

このように、パスワード全体や完全なハッシュ値をAPIへ送らず、照合もアプリケーション側で行う仕組みをk-Anonymityと呼びます。Pwned Passwords APIでは、SHA-1ハッシュの先頭5文字を送信する方式が使われています。

Pwned Passwords APIは、現行の公式ドキュメントではAPIキー不要、無料、レート制限なしで利用できます。ただし、外部サービスへ接続する以上、タイムアウトや接続失敗を想定して実装します。また、APIリクエストには利用するアプリケーションを識別できるUser-Agentを設定します。

詳しくは以前書いた漏洩パスワードとの照合:Pwned Passwords APIの仕組みと実運用での注意点の記事なども参照してください。

今回の処理の流れ

今回の登録処理は、次の流れにします。

  1. 登録フォームを表示する。
  2. 名前、メールアドレス、パスワード、パスワード確認を送信する。
  3. Laravelの通常のバリデーションを実行する。
  4. Pwned Passwords APIへパスワードのハッシュプレフィックスを送信する。
  5. 漏洩が確認できた場合は、パスワードのエラーを表示する。
  6. APIへ接続できない場合は、警告を表示して登録を続ける。
  7. 名前とメールアドレスだけをmembersテーブルへ保存する。

APIで確認できなかった場合に登録を続ける構成は、可用性を優先する判断です。今回のような説明用フォームではこの方針にしますが、本番環境で同じ判断を採用する場合は、サービスのリスクやパスワード設定方針を確認してください。

Pwned Passwordsの照合結果を表すクラスを作成する

まず、app/Services/PwnedPasswordCheckResult.phpを作成します。app/Servicesディレクトリがない場合は、ディレクトリも作成してください。

<?php

namespace App\Services;

final readonly class PwnedPasswordCheckResult
{
    public function __construct(
        public bool $available,
        public bool $pwned,
        public int $count = 0,
    ) {
    }
}

availableは、APIから正常に照合結果を取得できたかどうかを表します。pwnedは漏洩パスワードとして一致したかどうか、countは一致した場合の登場回数です。

APIへ接続できなかった場合は、availablefalseにします。この状態を「漏洩していない」と解釈しないことが重要です。確認できなかった状態と、確認した結果一致しなかった状態を分けて扱います。

Pwned Passwordsの照合サービスを作成する

次に、app/Services/PwnedPasswordChecker.phpを作成し、SHA-1ハッシュの生成、APIリクエスト、レスポンスの照合を担当させます。

<?php

namespace App\Services;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;

final class PwnedPasswordChecker
{
    public function check(string $password): PwnedPasswordCheckResult
    {
        $hash = strtoupper(sha1($password));
        $prefix = substr($hash, 0, 5);
        $suffix = substr($hash, 5);

        try {
            $response = Http::withHeaders([
                'User-Agent' => 'techblog-laravel-example/1.0',
            ])
                ->timeout(3)
                ->get('https://api.pwnedpasswords.com/range/' . $prefix);
        } catch (ConnectionException) {
            return new PwnedPasswordCheckResult(
                available: false,
                pwned: false,
            );
        }

        if (!$response->successful()) {
            return new PwnedPasswordCheckResult(
                available: false,
                pwned: false,
            );
        }

        $lines = preg_split('/\r\n|\r|\n/', $response->body()) ?: [];

        foreach ($lines as $line) {
            $parts = explode(':', trim($line), 2);

            if (count($parts) !== 2) {
                continue;
            }

            [$returnedSuffix, $count] = $parts;

            if (hash_equals($suffix, strtoupper($returnedSuffix))) {
                return new PwnedPasswordCheckResult(
                    available: true,
                    pwned: true,
                    count: max(0, (int) $count),
                );
            }
        }

        return new PwnedPasswordCheckResult(
            available: true,
            pwned: false,
        );
    }
}

sha1()の戻り値は、デフォルトでは40文字の16進数文字列です。Pwned Passwords APIのレスポンスと比較しやすくするため、ここでは大文字へ変換しています。APIのハッシュプレフィックスは大文字・小文字を区別しませんが、比較する値の形式をそろえておくと処理が分かりやすくなります。

Http::timeout(3)で、レスポンスを3秒待っても返らない場合のタイムアウトを設定しています。LaravelのHTTPクライアントでは、タイムアウトや接続失敗時にConnectionExceptionが発生します。

HTTPステータスが400番台や500番台の場合、LaravelのHTTPクライアントは自動的に例外を発生させません。そのため、successful()でレスポンスを確認し、正常なレスポンス以外はavailable: falseとして扱います。

APIのレスポンスは、サフィックスと件数をコロンで区切ったテキストです。explode()の第3引数へ2を指定し、最初のコロンだけで分割しています。形式が想定と異なる行は、照合せずにスキップします。

登録フォームのルートとControllerを作成する

次に、フォームを表示するルートと、登録処理を行うルートを追加します。

<?php

use App\Http\Controllers\MemberController;
use Illuminate\Support\Facades\Route;

Route::get('/members/create', [MemberController::class, 'create'])
    ->name('members.create');
Route::post('/members', [MemberController::class, 'store'])
    ->name('members.store');

Controllerを作成します。

php artisan make:controller MemberController

app/Http/Controllers/MemberController.phpを次の内容にします。

<?php

namespace App\Http\Controllers;

use App\Models\Member;
use App\Services\PwnedPasswordChecker;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;

class MemberController extends Controller
{
    public function create(): View
    {
        return view('members.create');
    }

    public function store(
        Request $request,
        PwnedPasswordChecker $checker,
    ): RedirectResponse {
        $validated = $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255'],
            'password' => ['required', 'string', 'min:8', 'confirmed'],
        ], [
            'name.required' => '名前を入力してください。',
            'email.required' => 'メールアドレスを入力してください。',
            'email.email' => 'メールアドレスの形式で入力してください。',
            'password.required' => 'パスワードを入力してください。',
            'password.min' => 'パスワードは8文字以上で入力してください。',
            'password.confirmed' => 'パスワード確認が一致しません。',
        ]);

        $check = $checker->check($validated['password']);

        if ($check->pwned) {
            return back()
                ->withErrors([
                    'password' => '過去に漏洩したことがあるパスワードです。別のパスワードを使用してください。',
                ])
                ->withInput();
        }

        if (!$check->available) {
            Log::warning('Pwned Passwords APIでパスワードを確認できませんでした。', [
                'service' => 'pwned-passwords',
                'result' => 'unavailable',
            ]);
        }

        // 今回は説明用フォームのため、パスワードは保存しない。
        Member::create([
            'name' => $validated['name'],
            'email' => $validated['email'],
        ]);

        $redirect = redirect()
            ->route('members.create')
            ->with('success', '説明用の登録処理が完了しました。');

        if (!$check->available) {
            $redirect->with(
                'warning',
                'パスワードの漏洩確認サービスを利用できなかったため、確認なしで登録処理を続けました。',
            );
        }

        return $redirect;
    }
}

$request->validate()では、名前、メールアドレス、パスワード、パスワード確認を検証しています。confirmedルールは、password_confirmationの値がpasswordと一致することを確認します。

通常のバリデーションが成功した後に、Pwned Passwords APIの確認を行います。漏洩が確認された場合は、withErrors()でパスワード項目へエラーを追加し、withInput()で入力画面へ戻します。

APIが利用できなかった場合は、Log::warning()で警告を記録します。ログにはパスワード、完全なハッシュ、ハッシュプレフィックス、APIレスポンスを含めていません。

その後、Member::create()へ渡しているのは名前とメールアドレスだけです。$validated全体をそのまま渡すと、パスワードまでModelへ渡してしまうため、保存する項目を明示しています。

Bladeで登録フォームを作成する

resources/views/members/create.blade.phpを作成します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Member登録</title>
</head>
<body>
    <h1>Member登録</h1>

    @if (session('success'))
        <p>{{ session('success') }}</p>
    @endif

    @if (session('warning'))
        <p>{{ session('warning') }}</p>
    @endif

    <form action="{{ route('members.store') }}" method="post">
        @csrf

        <div>
            <label for="name">名前</label>
            <input id="name" name="name" type="text" value="{{ old('name') }}" required>
            @error('name')
                <p>{{ $message }}</p>
            @enderror
        </div>

        <div>
            <label for="email">メールアドレス</label>
            <input id="email" name="email" type="email" value="{{ old('email') }}" required>
            @error('email')
                <p>{{ $message }}</p>
            @enderror
        </div>

        <div>
            <label for="password">パスワード</label>
            <input id="password" name="password" type="password" required>
            @error('password')
                <p>{{ $message }}</p>
            @enderror
        </div>

        <div>
            <label for="password_confirmation">パスワード確認</label>
            <input id="password_confirmation" name="password_confirmation" type="password" required>
        </div>

        <button type="submit">登録する</button>
    </form>
</body>
</html>

パスワードとパスワード確認にはold()を使っていません。バリデーションエラー時も、パスワードを画面へ再表示しないためです。

API障害時は、Controllerで次に表示する画面へ一時的なメッセージとして設定したwarningを、フォーム上部へ表示します。利用者には「漏洩していない」と断定せず、「確認サービスを利用できなかった」と伝えます。

カスタムバリデーションルールを使う方法

Laravelには、独自の入力条件をルールオブジェクトへまとめる方法もあります。例えば、次のコマンドでルールを作成できます。

php artisan make:rule PwnedPasswordRule

カスタムルールを使う場合は、Controllerのルールへ次のように追加する構成になります。

use App\Rules\PwnedPasswordRule;

$rules = [
    'password' => [
        'required',
        'string',
        'min:8',
        'confirmed',
        new PwnedPasswordRule(),
    ],
];

カスタムルールの利点は、漏洩パスワードの確認を入力項目のルールとして再利用しやすいことです。複数のフォームで同じ判定を使いたい場合にも向いています。

一方、今回のように「API障害時は警告を表示して登録を続ける」という処理では、カスタムルールだけで結果を扱うと少し複雑になります。ルールオブジェクトは基本的に、入力を通すか、エラーメッセージを追加するかを判断するためのものです。入力は通すが警告も表示する、という別の状態をControllerへ渡す仕組みが必要になります。

そのため今回は、照合処理をサービスクラスへ分離し、Controllerでpwnedavailableを分岐する構成にしました。

方法 向いているケース 注意点
カスタムバリデーションルール 漏洩パスワードを通常の入力エラーとして扱いたい場合 API障害時の警告など、エラー以外の結果を渡す設計が必要
サービスクラス 照合結果、利用不能、件数などを分けて扱いたい場合 Controller側で結果を分岐する処理を書く必要がある

動作を確認する

Laravelの開発サーバーを起動します。

php artisan serve

ブラウザで次のURLへアクセスします。

http://127.0.0.1:8000/members/create
通常のバリデーションを確認する

名前やメールアドレスを空にしたり、パスワード確認を一致させずに送信したりします。Laravelの通常のバリデーションエラーが表示され、Member::create()が実行されないことを確認します。

漏洩パスワードを確認する

テスト用の既知の漏洩パスワードを入力して送信します。APIレスポンスに対応するハッシュサフィックスが含まれていれば、パスワード項目へエラーが表示され、登録処理は実行されません。

表示するメッセージには、入力されたパスワードを含めません。「入力されたパスワードが現在漏洩した」という意味ではなく、「過去の漏洩データに含まれているため、別のパスワードを使用する」という内容にします。

APIが利用できない場合を確認する

APIのタイムアウトや接続失敗は、実際のネットワーク状態だけに依存して確認しない方が安全です。LaravelのHTTPクライアントをモックすると、テスト用のレスポンスを返せます。

例えば、機能テストでAPIへ503を返させる場合は、次のように記述します。

use Illuminate\Support\Facades\Http;

Http::fake([
    'https://api.pwnedpasswords.com/range/*' => Http::response('', 503),
]);

$response = $this->post('/members', [
    'name' => 'テストユーザー',
    'email' => 'user@example.test',
    'password' => 'test-password-123',
    'password_confirmation' => 'test-password-123',
]);

$response
    ->assertRedirect('/members/create')
    ->assertSessionHas('warning')
    ->assertSessionHasNoErrors();

接続失敗を確認する場合は、次のようにHttp::failedConnection()を使えます。

Http::fake([
    'https://api.pwnedpasswords.com/range/*' => Http::failedConnection(),
]);

LaravelのHTTPクライアントをモックすれば、実際のPwned Passwords APIへテスト用のパスワードを送信せずに、成功、漏洩検出、503、接続失敗を確認できます。

送信内容を確認する

APIへ送信するURLとUser-Agentは、Http::assertSent()で確認できます。ただし、テストのログやアサーションメッセージへ、入力したパスワードや完全なハッシュを含めないようにします。

use Illuminate\Http\Client\Request;

Http::assertSent(function (Request $request): bool {
    return str_starts_with(
        $request->url(),
        'https://api.pwnedpasswords.com/range/',
    )
        && $request->hasHeader(
            'User-Agent',
            'techblog-laravel-example/1.0',
        );
});

URLの末尾にはハッシュプレフィックスが含まれます。確認用ログやスクリーンショットへURLを掲載する場合は、プレフィックスも必要に応じてマスキングします。

パスワードを保存する実装へ変更する場合

今回は説明用フォームのため、membersテーブルへ名前とメールアドレスだけを保存しました。実際の会員登録でパスワードを保存する場合は、平文のパスワードをDBへ保存してはいけません。

Laravelのハッシュ機能を使い、例えば次のようにハッシュ化してから保存します。

use Illuminate\Support\Facades\Hash;

$passwordHash = Hash::make($validated['password']);

また、今回のControllerのように、入力値全体をModelへ渡さず、保存する項目を明示します。実際の認証機能へ組み込む場合は、パスワードハッシュの保存、ログイン時の検証、パスワード変更、リセット、セッション管理などをまとめて設計してください。

注意点

  • Pwned Passwords APIへ平文パスワードや完全なハッシュ値を送信しない。
  • パスワード、完全なハッシュ、APIレスポンス、ハッシュプレフィックスをログへ不用意に出力しない。
  • API障害時の「確認できなかった」と「漏洩していなかった」を区別する。
  • 今回の例ではAPI障害時に登録を続けるが、その場合は漏洩パスワードを確認できないまま登録される可能性があるため、本番環境での採用は慎重に判断する。
  • APIへは適切なUser-Agentを設定し、HTTPSで接続する。
  • Pwned Passwords APIはパスワードの強度を評価するサービスではなく、既知の漏洩データとの一致を確認するサービスである。
  • パスワードを保存する場合は、Laravelのハッシュ機能を使い、平文を保存しない。
  • テストでは実際のユーザーのパスワードを使わず、HTTPクライアントをモックする。

まとめ

今回は、PHP 8.3.15、Laravel Framework 13.24.0の説明用登録フォームへ、Pwned Passwords APIによる漏洩パスワード確認を組み込みました。

入力されたパスワードはアプリケーション側でSHA-1ハッシュへ変換し、先頭5文字だけをAPIへ送信します。APIから返されたサフィックスとの比較もアプリケーション側で行うため、平文パスワードや完全なハッシュ値をAPIへ送信せずに照合できます。

今回は、照合結果を扱いやすくするため、Pwned Passwordsの確認処理をサービスクラスへ分離しました。漏洩が確認できた場合はバリデーションエラーを表示し、APIのタイムアウトや接続障害時は警告を表示して登録処理を続けています。

API障害時に登録を続ける方式は、確認を必須にする方式と比べて利用者への影響を抑えられます。一方で、APIへ接続できない間は漏洩パスワードを見つけられないため、本番環境へ導入する場合はサービスのリスクと運用方針を確認して決める必要があります。

参考資料

この記事を書いた人

※上が私です。

奈良市を拠点に、27年以上の経験を持つフリーランスWebエンジニア、阿部辰也です。

これまで、ECサイトのバックエンド開発や業務効率化システム、公共施設の予約システムなど、多彩なプロジェクトを手がけ、企業様や制作会社様のパートナーとして信頼を築いてまいりました。

【制作会社・企業様向けサポート】
  • 専任エンジニアのいない企業様に対するシステム面の不安を解消
  • 柔軟な契約形態や短納期での対応により、急なニーズにも迅速にサポート
  • システムの企画段階から運用まで、ワンストップでのサービスを提供

Webシステムの開発やサイト改善でお困りの際は、どうぞお気軽にご相談ください。小さな疑問から大規模プロジェクトまで、最適なご提案を心を込めてさせていただきます。

ぜひ、プロフィールWeb制作会社様向け業務案内一般企業様向け業務案内もご覧くださいね。

Laravel 13で既存のテーブルを使ってログイン機能を実装する

2026.09.02

Laravel 13の既存連載で作成したmembersテーブルを認証用ユーザーとして流用し、Memberモデルと認証プロバイダーを設定します。SQLiteに保存したユーザー情報を使ったメールアドレス・パスワード認証、ログイン済み画面の表示、データベースセッション、ログアウト時のセッション破棄までを実装します。

Laravel PHP

Laravel 13+Vue.jsで画像・CSVを非同期アップロードする:FormData・ファイル検証・非公開保存

2026.08.31

Laravel 13のBladeに組み込んだVue.jsから、FormDataを使って画像またはCSVを非同期で送信する方法を解説します。JPEG・PNGの画像検証、CSVの文字コード変換と簡易的な内容確認、20MBのサイズ制限、非公開ディスクへの保存、CSRF・バリデーションエラー処理を実装し、fetchを基本にaxiosとの違いも整理します。

JavaScript Laravel PHP Vue.js

Laravel 13をApache・XAMPPで動かす:VirtualHost・.htaccess・public_htmlの配置方法

2026.08.27

Laravel 13のプロジェクトをWindowsのXAMPP Apacheで動かすために、VirtualHostでpublicをDocumentRootに指定する方法、.htaccessでpublicへ振り分ける方法、public_htmlへpublicの中身を配置してindex.phpの参照先を変更する方法を解説します。.envやstorageなどを公開しないための注意点、Viteのビルド成果物、ApacheとLaravelのログを使ったトラブルシューティングも整理します。

.htaccess Apache Laravel PHP XAMPP

Laravel 13+Vue.jsで登録・編集フォームを非同期化する:fetchとaxiosを比較

2026.08.26

Laravel 13のBlade画面にVue.jsを組み込み、membersテーブルのnameとemailを登録・編集するフォームを作ります。Vue.jsからLaravelのWebルートへJSONを送信し、CSRFトークン、バリデーションエラー、送信中・通信失敗の状態を扱います。リクエスト送信にはfetchとaxiosを使う方法を比較し、追加パッケージの有無やエラー処理の違いを整理します。

JavaScript Laravel PHP Vue.js

阿部辰也へのお仕事の依頼・お問い合わせ

軽いご相談もお気軽にどうぞ!

個人情報の取り扱いについて *必須 プライバシーポリシーをご確認いただき、同意いただける場合は「同意する」にチェックをしてください。

keyboard_double_arrow_up
TOP