PHPログイン機能の作り方|セッションとpassword_hashで安全に実装する手順


PHPのログイン機能とは、ユーザーをメールアドレスとパスワードで認証し、ログイン状態をセッションで保持する仕組みのことです。安全に実装する鍵は3つで、パスワードはpassword_hash()でハッシュ化して保存し、SQLは必ずprepared statementで実行し、セッション固定攻撃・CSRF・SQLインジェクションへの対策を施すことです。

「ログイン機能くらい自分で書ける」と思って実装したものの、パスワードを平文やMD5で保存していたり、SQLに値を直接埋め込んでいたり——よくあるサンプルコードをそのまま使うと、動くけれど危険な実装になりがちです。ログイン機能はセキュリティの良し悪しがそのまま出る機能で、ここを雑に作ると情報漏洩に直結します。

この記事では、PHP+MySQLで会員登録からログイン・ログアウトまでを一から実装する手順を、動くコードと「なぜそう書くか」のセキュリティ根拠をセットで解説します。フレームワークは使わず、素のPHPで仕組みを理解することを優先します。最後に、実際に動かして検証する手順と、次に学ぶべき一手まで案内します。中級者が「コピペで動かす」のではなく「安全に作れる」ようになることがゴールです。


PHPログイン機能とは(仕組みの全体像)

ログイン機能は、突き詰めると「本人確認(認証)」と「ログイン状態の保持」の2つでできています。認証は、ユーザーが入力したパスワードが、登録時に保存したものと一致するかを確かめる処理。状態の保持は、一度ログインしたユーザーを次のページでも「ログイン済み」と判断し続けるための仕組みで、PHPではセッションが担います。

HTTPはリクエストごとに状態を持たない(ステートレスな)通信です。そのため「さっきログインした人」を覚えておく仕組みが別途必要になります。PHPのセッションは、サーバー側にログイン情報を保存し、ブラウザにはセッションIDだけをCookieで渡すことで、ページをまたいでもログイン状態を維持します。

構成要素役割主に使うもの
認証パスワードが正しいか確認するpassword_hash / password_verify
状態保持ログイン中かを判定し続けるセッション($_SESSION)
データ保存ユーザー情報を保管・照会するMySQL + PDO(prepared statement)
防御攻撃から守るsession_regenerate_id / CSRFトークン / prepared statement

バックエンドの全体像(サーバー・DB・APIの関係)から整理したい場合は、バックエンドとは何かの解説もあわせて読むと、ログイン処理がどの層で動いているのかが掴みやすくなります。


完成イメージと処理の流れ

今回作るのは、次の4つのファイルで構成される最小構成のログイン機能です。役割ごとにファイルを分けることで、処理の流れが追いやすくなります。

ファイル役割
db.phpデータベース接続(PDO)を共通化
register.php会員登録(パスワードをハッシュ化して保存)
login.phpログイン(認証してセッション開始)
mypage.phpログイン必須のページ(未ログインなら弾く)
logout.phpログアウト(セッション破棄)

処理の流れを順番に並べると、次のようになります。各ステップが後のセクションのコードに対応します。

  1. 会員登録:入力されたパスワードをpassword_hash()でハッシュ化し、ユーザー情報をDBに保存する
  2. ログイン:入力メールでユーザーを検索し、password_verify()でパスワードを照合する
  3. 一致したら:session_regenerate_id()でIDを再発行し、$_SESSIONにユーザーIDを保存する
  4. ログイン必須ページ:$_SESSIONにユーザーIDがあるかで、表示するか弾くかを判定する
  5. ログアウト:セッションを破棄してログイン状態を解除する

この流れの中で、セキュリティ上の急所になるのが「パスワードの保存方法」「SQLの実行方法」「セッションの扱い」の3つです。まずは動く形を作り、後半でこの3点を順に固めていきます。


事前準備:データベースと users テーブル

まずユーザー情報を保存するテーブルを用意します。ローカルで試す場合は、XAMPPやMAMPなどでPHPとMySQLが動く環境を準備してください。次のSQLでusersテーブルを作成します。

CREATE TABLE users (
    id         INT AUTO_INCREMENT PRIMARY KEY,
    username   VARCHAR(50)  NOT NULL,
    email      VARCHAR(255) NOT NULL UNIQUE,
    password   VARCHAR(255) NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

ポイントは2つです。emailUNIQUE制約を付けて同じメールアドレスでの重複登録を防ぐこと。そしてpasswordカラムをVARCHAR(255)と長めに取ることです。password_hash()が生成するハッシュは現在60文字程度ですが、将来アルゴリズムが変わると長くなる可能性があるため、PHP公式マニュアルでも255文字以上の確保が推奨されています。

次に、DB接続を共通化するdb.phpを作ります。接続には、安全なSQL実行(prepared statement)が使えるPDOを採用します。

<?php
// db.php : データベース接続を共通化する
$host    = 'localhost';
$dbname  = 'myapp';
$dbuser  = 'db_user';      // 環境に合わせて変更
$dbpass  = 'db_password';  // 環境に合わせて変更
$charset = 'utf8mb4';

$dsn = "mysql:host={$host};dbname={$dbname};charset={$charset}";
$options = [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION, // 例外で気づけるように
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,                 // 本物のprepared statementを使う
];

try {
    $pdo = new PDO($dsn, $dbuser, $dbpass, $options);
} catch (PDOException $e) {
    // 本番では詳細を画面に出さない(ログに記録する)
    exit('データベースに接続できませんでした');
}

ATTR_EMULATE_PREPARES => falseは地味ですが重要な設定です。これをfalseにすると、PHP側の擬似的な処理ではなくMySQL本来のprepared statementが使われ、SQLインジェクション対策がより確実になります。接続情報は本来ソースに直書きせず環境変数などに分離しますが、本記事では分かりやすさを優先して直書きしています。実運用ではPHPMailerの実装ガイドと同様、認証情報の管理にも注意してください。


パスワードを安全に保存する(password_hash)

ログイン機能で最も重要なのが、パスワードの保存方法です。結論から言うと、パスワードは平文でもMD5でもSHA-1でもなく、password_hash()で保存します。これはPHPに標準で用意された、パスワード専用のハッシュ関数です。

なぜMD5やSHA-1ではダメなのか。これらは高速に計算できるため、総当たり攻撃で逆算されやすく、パスワード保存には不向きとされています。一方password_hash()は、計算コストを意図的に高くし、ソルト(ランダムな文字列)も自動で付与するため、漏洩時の解読を格段に難しくします。

手法パスワード保存に理由
平文✕ 論外DBが漏れたら即アウト
MD5 / SHA-1✕ 不適切高速すぎて総当たりに弱い・ソルトなし
password_hash()◎ 推奨低速設計+自動ソルト。PHP公式が推奨

使い方はシンプルです。保存時はpassword_hash()でハッシュ化し、照合時はpassword_verify()で「入力された平文」と「保存されたハッシュ」を比較します。ハッシュは元に戻せないため、照合は必ずpassword_verify()を使います。

<?php
// 保存時:平文パスワードをハッシュ化する
$hash = password_hash($password, PASSWORD_DEFAULT);
// 例: $2y$10$... という60文字程度の文字列になる

// 照合時:入力値とハッシュを比較する(true / false)
if (password_verify($inputPassword, $hash)) {
    // パスワード一致
} else {
    // 不一致
}

PASSWORD_DEFAULTを指定しておくと、PHPのバージョンアップで推奨アルゴリズムが更新された際にも自動で追従できます。アルゴリズムを自分で固定する必要はありません。


会員登録機能を実装する

準備ができたので、会員登録(register.php)を実装します。フォームから受け取った値をバリデーションし、パスワードをハッシュ化して、prepared statementでDBに保存します。

<?php
// register.php
require 'db.php';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = trim($_POST['username'] ?? '');
    $email    = trim($_POST['email'] ?? '');
    $password = $_POST['password'] ?? '';

    // 入力チェック(最低限)
    if ($username === '' || $email === '' || $password === '') {
        exit('未入力の項目があります');
    }

    // パスワードをハッシュ化
    $hash = password_hash($password, PASSWORD_DEFAULT);

    // prepared statement で安全に保存
    $sql  = 'INSERT INTO users (username, email, password)
             VALUES (:username, :email, :password)';
    $stmt = $pdo->prepare($sql);

    try {
        $stmt->execute([
            ':username' => $username,
            ':email'    => $email,
            ':password' => $hash,
        ]);
    } catch (PDOException $e) {
        // UNIQUE制約違反(メール重複)など
        exit('登録できませんでした。すでに使われているメールアドレスの可能性があります');
    }

    header('Location: login.php');
    exit;
}

対応するHTMLフォームは次の通りです。パスワード欄は必ずtype="password"にし、通信は本番ではHTTPSにします(後述)。

<form action="register.php" method="post">
  <input type="text"     name="username" placeholder="ユーザー名" required>
  <input type="email"    name="email"    placeholder="メールアドレス" required>
  <input type="password" name="password" placeholder="パスワード" required>
  <button type="submit">登録する</button>
</form>

ここでのポイントは、INSERTの値を文字列連結で組み立てず、:usernameのようなプレースホルダを使ってexecute()に渡していることです。これがSQLインジェクション対策の基本形になります(詳細は後半で解説)。


ログイン機能を実装する(セッション開始)

次にログイン本体(login.php)です。入力されたメールアドレスでユーザーを検索し、password_verify()でパスワードを照合します。一致したら、セッションにユーザーIDを保存してログイン状態にします。

<?php
// login.php
session_start();
require 'db.php';

$error = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $email    = trim($_POST['email'] ?? '');
    $password = $_POST['password'] ?? '';

    // メールアドレスでユーザーを1件取得
    $stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
    $stmt->execute([':email' => $email]);
    $user = $stmt->fetch();

    // ユーザーが存在し、かつパスワードが一致するか
    if ($user && password_verify($password, $user['password'])) {
        // セッション固定攻撃対策:IDを作り直す
        session_regenerate_id(true);

        // ログイン状態を保存
        $_SESSION['user_id']  = $user['id'];
        $_SESSION['username'] = $user['username'];

        header('Location: mypage.php');
        exit;
    } else {
        // どちらが間違いかは明かさない(情報を与えない)
        $error = 'メールアドレスまたはパスワードが正しくありません';
    }
}

実装上の注意が2つあります。1つ目は、ファイル先頭で必ずsession_start()を呼ぶこと。これより前に出力(空白や改行を含む)があるとセッションが正しく開始されません。2つ目は、エラーメッセージで「メールが存在しない」「パスワードが違う」を区別しないこと。区別すると、攻撃者に「このメールは登録済み」という情報を与えてしまうため、あえて曖昧な1文に統一します。


ログイン判定とログアウトを実装する

ログイン必須のページ(例:mypage.php)では、ページ先頭でログイン状態を判定し、未ログインならログイン画面へ送り返します。判定処理はauth.phpとして切り出し、必要なページで読み込む形にすると再利用できます。

<?php
// auth.php : ログイン必須ページの先頭で読み込む
session_start();

if (!isset($_SESSION['user_id'])) {
    // 未ログインならログイン画面へ
    header('Location: login.php');
    exit;
}
<?php
// mypage.php : ログインしている人だけ見られるページ
require 'auth.php';
?>
<p><?php echo htmlspecialchars($_SESSION['username'], ENT_QUOTES, 'UTF-8'); ?> さん、ようこそ</p>
<a href="logout.php">ログアウト</a>

セッションに保存したユーザー名を画面に出すときは、必ずhtmlspecialchars()でエスケープします。これはXSS(クロスサイトスクリプティング)対策で、ユーザー名にHTMLタグが含まれていても無害化されます。

ログアウト(logout.php)は、セッション変数を空にし、セッションCookieを削除し、セッション自体を破棄します。ここまでやって初めて「確実にログアウトした」状態になります。

<?php
// logout.php
session_start();

// 1. セッション変数を空に
$_SESSION = [];

// 2. セッションCookieを削除
if (ini_get('session.use_cookies')) {
    $params = session_get_cookie_params();
    setcookie(
        session_name(), '', time() - 42000,
        $params['path'], $params['domain'],
        $params['secure'], $params['httponly']
    );
}

// 3. セッションを破棄
session_destroy();

header('Location: login.php');
exit;

セキュリティ対策①:セッション固定攻撃

ここからが、安全なログイン機能の本題です。まずはセッション固定攻撃(Session Fixation)。これは、攻撃者が用意したセッションIDを被害者に使わせ、被害者がログインした後にそのIDを乗っ取る攻撃です。

対策はシンプルで、ログイン成功の直後にsession_regenerate_id(true)でセッションIDを作り直すことです。ログイン前のIDを無効化するため、たとえ攻撃者がログイン前のIDを知っていても乗っ取れなくなります。先ほどのlogin.phpに含めた1行がこれにあたります。

// ログイン認証に成功した直後で実行する
session_regenerate_id(true); // 引数 true で古いセッションを削除

$_SESSION['user_id'] = $user['id'];

「ログインが成功した瞬間に呼ぶ」のがポイントです。フォーム表示時ではなく、認証を通過した直後に1行入れるだけで、この攻撃を防げます。セッションまわりの安全策は、PHP公式のセッションのセキュリティにも体系的にまとまっています。


セキュリティ対策②:CSRF(クロスサイトリクエストフォージェリ)

CSRFは、ログイン中のユーザーに、本人が意図しないリクエスト(退会・パスワード変更など)を気づかないうちに送らせる攻撃です。対策は、フォームに推測不可能なトークンを埋め込み、送信時に照合すること。トークンが一致しないリクエストは拒否します。

まずフォーム表示時に、ランダムなトークンを生成してセッションに保存し、hiddenフィールドに埋め込みます。

<?php
session_start();

// トークンがなければ生成(推測不可能な乱数)
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$token = $_SESSION['csrf_token'];
?>
<form action="register.php" method="post">
  <!-- ...入力欄... -->
  <input type="hidden" name="csrf_token"
         value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
  <button type="submit">送信</button>
</form>

POSTを受け取る側では、送られてきたトークンとセッション内のトークンを照合します。比較にはhash_equals()を使い、タイミング攻撃を避けます。

<?php
session_start();

// トークン照合(一致しなければ処理を止める)
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf_token'] ?? '')) {
    http_response_code(403);
    exit('不正なリクエストです');
}

// ここから先は正規のリクエストとして処理

トークン生成にrandom_bytes()、照合にhash_equals()を使うのが定石です。rand()のような予測可能な乱数や、==での単純比較は避けます。


セキュリティ対策③:SQLインジェクション

SQLインジェクションは、入力値に悪意あるSQLを混ぜてDBを不正操作する攻撃です。ログイン機能ではメールアドレス欄などが狙われます。対策は本記事で一貫して使ってきたprepared statement(プレースホルダ)。値を文字列連結でSQLに埋め込まないことが核心です。

やってはいけない書き方と、正しい書き方を並べます。違いは「値をどこで組み込むか」です。

<?php
// ❌ 危険:入力値を直接SQLに連結している
$email = $_POST['email'];
$sql = "SELECT * FROM users WHERE email = '{$email}'";
$user = $pdo->query($sql)->fetch();
// email に  ' OR '1'='1  などを入れられると認証を突破される
<?php
// ◎ 安全:値はプレースホルダで渡す
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute([':email' => $_POST['email']]);
$user = $stmt->fetch();
// 値はSQLとして解釈されないため、注入が成立しない

prepared statementでは、SQLの構文と値が分離して処理されるため、入力値がSQLコマンドとして解釈されることがありません。db.phpATTR_EMULATE_PREPARES => falseにしておくと、この分離がMySQL側で確実に行われます。

ここまでの3つの対策を整理すると、次の対応表になります。ログイン機能を作るときの最低限のチェックリストとして使ってください。

脅威対策使う関数・設定
パスワード漏洩ハッシュ化して保存password_hash / password_verify
セッション固定ログイン直後にID再発行session_regenerate_id(true)
CSRFトークンを埋め込み照合random_bytes / hash_equals
SQLインジェクションprepared statementPDO::prepare / execute
XSS出力時にエスケープhtmlspecialchars

動作確認:実際に動かして検証する

コードを書いたら、「書いた」で終わらせず実際に動かして想定通りに動くかを確認します。ローカル環境(XAMPP/MAMP等)で、次の観点をひと通りチェックしてください。

  1. 会員登録できる:登録後、DBのusersテーブルに行が追加され、password列が平文でなくハッシュ($2y$で始まる文字列)になっている
  2. 正しい情報でログインできる:登録したメール・パスワードでマイページに入れる
  3. 間違ったパスワードで弾かれる:エラーメッセージが「どちらが違うか分からない」曖昧な文言になっている
  4. 未ログインで保護ページに入れない:mypage.phpに直接アクセスするとログイン画面へ飛ばされる
  5. ログアウトが効く:ログアウト後にブラウザの戻るボタンで保護ページに戻れない

特に1番目の「password列がハッシュになっているか」は必ず目視してください。ここが平文だと、他がどれだけ正しくても致命的です。phpMyAdminなどでテーブルの中身を直接見て確認します。

本番環境に上げる前のもう一手として、セッションCookieのセキュリティ設定も入れておきます。HTTPS環境では次の設定をsession_start()の前に追加し、CookieをHTTPS限定かつJavaScriptから読めないようにします。

<?php
// HTTPS本番環境向け:session_start() の前に設定
session_set_cookie_params([
    'secure'   => true,   // HTTPSのときだけ送信
    'httponly' => true,   // JavaScriptから参照不可(XSS対策)
    'samesite' => 'Lax',  // 他サイトからの送信を制限(CSRF軽減)
]);
session_start();

つまずきやすいポイントと次の一手

実装中によくハマる箇所を先回りでまとめます。エラーの多くはセッションとヘッダー周りで起きます。

症状主な原因対処
「headers already sent」エラーsession_start()やheader()より前に出力があるファイル先頭の<?php前の空白・BOM・echoを消す
ログインしてもすぐ切れるページごとにsession_start()を呼んでいない保護ページの先頭で必ずsession_start()
password_verifyが常にfalseDBのpassword列が短くハッシュが切れているVARCHAR(255)で再作成して登録し直す
日本語が文字化け接続のcharset未設定DSNにcharset=utf8mb4を指定

ログイン機能が動いたら、次の一手は「ログイン状態を前提にした機能」へ広げることです。たとえば、ログインユーザーだけが投稿・編集・削除できる仕組み(CRUD)や、自分のデータだけを検索する機能。いずれも今回作ったセッション判定とprepared statementがそのまま土台になります。

また、ログイン後に確認メールや通知メールを送りたくなったら、PHPMailerでのメール送信が役立ちます。問い合わせフォームと組み合わせる場合はセキュアなPHPメールフォームの作り方も同じセキュリティ作法で実装できます。


よくある質問

Q. パスワードはMD5やSHA-256で保存してはいけませんか?

パスワードの保存にはpassword_hash()を使ってください。MD5やSHA系は計算が高速なため総当たり攻撃に弱く、パスワード用途には不適切です。password_hash()は計算コストを高くし、ソルトも自動で付与するため、パスワード保存に適しています。

Q. フレームワーク(Laravelなど)を使えば自分で書かなくていいのでは?

実務ではフレームワークの認証機能を使うのが効率的です。ただし、内部で何が行われているか(ハッシュ化・セッション・トークン照合)を理解していないと、カスタマイズや障害対応で詰まります。素のPHPで一度仕組みを通しておくことは、フレームワークを使う上でも無駄になりません。

Q. session_regenerate_id() はどのタイミングで呼ぶべきですか?

ログイン認証に成功した直後に呼びます。ログイン前後でセッションIDを変えることで、セッション固定攻撃を防げます。フォーム表示時やページ読み込みのたびに呼ぶ必要はありません。

Q. ローカルでは動くのに本番で動きません。

多くは出力タイミングとHTTPS設定が原因です。session_start()より前に空白やBOMが出力されていないか、本番のCookieがHTTPS限定(secure属性)になっていてHTTPでアクセスしていないかを確認してください。エラー表示を一時的に有効にして、具体的なメッセージを確認するのも有効です。

Q. ログイン状態はどれくらい保持されますか?

標準では、PHPのセッション有効期限とブラウザを閉じるまでの設定に依存します。「ログイン状態を保持する(Remember me)」を実装する場合は、別途、安全なトークンをCookieとDBで管理する仕組みが必要になり、セッションだけの実装より一段複雑になります。


まとめ

PHPのログイン機能は、「認証」と「セッションによる状態保持」という2つの仕組みでできています。動かすだけなら難しくありませんが、安全に作るにはpassword_hashでのパスワード保存・prepared statementでのSQL実行・セッション固定/CSRF/XSSへの対策が欠かせません。これらは後付けではなく、最初から組み込むものとして実装するのが鉄則です。

まずは本記事のコードをローカルで一度動かし、検証チェックリストでひと通り確認してみてください。動いたら、ログインを前提にしたCRUDや検索機能へと広げていけます。

ログイン機能を含むWebアプリの実装や、既存サイトのセキュリティ改修を相談したい場合は、Web制作・開発を手がけるRINIAにお問い合わせください。