PHPでメールOTPによる追加認証を実装する:発行・保存・送信・検証の設計
作成日:2026.09.04
PHP 8.3以上を対象に、パスワードログイン後にメールOTPで追加認証を行う処理を、フレームワークに依存しない形で整理します。コードの生成・保存・送信・検証、期限切れや再送、試行回数制限を扱い、メールOTPをTOTPやパスキーと同等に扱わないための注意点も説明します。
目次
パスワードによるログイン処理へ、メールで送信したワンタイムコードの確認を追加したい場合に考えることをまとめました。
今回は、PHP 8.3以上を対象に、パスワード認証が成功した後でメールOTP(ワンタイムパスワード)を確認する処理を、特定のフレームワークに依存しない形で整理します。OTPの生成、保存、メール送信、検証を分け、期限切れや再送、誤入力にも対応できる構成を考えます。
メールOTPは、メールアカウントへアクセスできることを利用した確認方法です。認証アプリを使うTOTPとは仕組みや安全性が異なるため、記事の最後では用途と限界についても確認します。
メールOTPを使う場面
OTPは、One-Time Passwordの略です。一度だけ使用することを前提にした確認コードを指します。メールOTPでは、サーバーが生成したコードをメールで送り、利用者がログイン画面へ入力します。
今回の処理では、最初にメールアドレスとパスワードを確認します。パスワードが正しかった場合は、すぐにログイン済みとするのではなく、メールOTPを発行して追加確認を行います。
メールアドレス・パスワードを確認
↓
メールOTPを発行して送信
↓
利用者が受信したコードを入力
↓
コードを検証
↓
ログイン済みの状態へ移行
メールアドレスの所有確認にも似た処理を使えますが、ログイン認証と所有確認は目的が異なります。同じコードを別の処理へ無制限に使い回さないようにしましょう。
メールOTPとTOTPの違い
TOTPは、サーバーと認証アプリが共有する秘密鍵と時刻からコードを生成する方式です。認証アプリに表示されるコードを入力するため、毎回メールを送信する必要はありません。
一方、メールOTPは、サーバーが生成したコードをメールで配送します。コードの安全性だけでなく、メールアカウント、メールサービス、配送経路の保護状態にも依存します。
そのため、メールOTPをTOTPやパスキーと同じ強度の認証手段として扱わないことが重要です。高い保証レベルが必要な操作などでは、別の認証手段を検討しましょう。
前提
今回の前提は次のとおりです。
- PHP 8.3以上
- メール送信にPHPの
mail()を使う - データベースへのアクセスにはPDOなどを使える
- ログイン前のパスワード確認処理は実装済みとする
- 利用するデータベースのSQL方言には依存しない擬似コードを使う
データベースの接続方法やログインセッションの詳細は、今回の主題から外します。実際のアプリケーションでは、利用しているフレームワークや認証基盤のセッション処理へ、OTP検証成功後の処理を組み込みます。
OTPの状態をデータベースで管理する
OTPは、コードの文字列だけを保存すればよいわけではありません。少なくとも、どの利用者のどの用途に発行したコードか、いつまで有効か、すでに使われたかを管理します。
| 項目 | 用途 |
|---|---|
| 対象ユーザー | どのログイン処理に対するコードかを特定する |
| メールアドレス | 入力された宛先と検証対象を結び付ける |
| 用途 | ログインやメールアドレス確認などを区別する |
| コードの検証値 | 入力されたコードと照合する。平文は保存しない |
| 発行日時・有効期限 | 利用できる時間を制限する |
| 配送状態 | 送信前、送信済み、送信失敗を区別する |
| 使用日時・無効化日時 | 使用済みや再送による無効化を判定する |
| 失敗回数 | コードの総当たりを制限する |
OTPの状態は、例えば次のように考えられます。
pending: コードを保存したが、メール送信が完了していないsent: メール送信処理が成功したfailed: メール送信処理が失敗した- 使用済み: コードの検証に成功し、消費された
- 無効化済み: 再送や試行回数超過などで使用できない
検証対象にするのは、対象ユーザーと用途が一致し、送信済みで、有効期限内かつ未使用・未無効化のコードだけです。
コードを安全な乱数から生成する
OTPは短い数字列になるため、生成方法が推測可能だと危険です。時刻や連番、rand()の値を使わず、PHPのrandom_int()を利用します。
<?php
declare(strict_types=1);
function generateOtpCode(): string
{
$number = random_int(0, 999999);
// 000001のような先頭ゼロを含むコードを文字列として返す。
return str_pad((string) $number, 6, '0', STR_PAD_LEFT);
}
$code = generateOtpCode();
if (preg_match('/\A\d{6}\z/', $code) !== 1) {
throw new RuntimeException('OTPの形式が不正です。');
}
echo $code;
コードは数値ではなく文字列として扱います。例えば004123を整数へ変換すると、先頭のゼロが失われます。
random_int()は、暗号学的に安全な乱数を返すためのPHP標準関数です。OTPのように、攻撃者から予測されてはいけない値の生成に使います。
OTPを平文で保存しない
メール本文にコードを平文で出力するのは必要です。しかし、データベースやログへ平文のコードを保存すると、データベースやログを閲覧できる人が、そのままログインできる可能性があります。
保存方法にはいくつかの選択肢があります。ここでは、短いOTPを扱う場合に検討しやすいHMAC方式と、一般的なパスワードハッシュ方式を比較します。
HMACで検証値を作る
HMAC方式では、OTPとサーバー側の秘密値から検証値を作り、その検証値だけを保存します。秘密値はデータベースとは別に、環境変数や秘密情報管理サービスなどで管理します。
<?php
declare(strict_types=1);
function createOtpDigest(string $code, string $serverSecret): string
{
return hash_hmac('sha256', $code, $serverSecret);
}
function verifyOtpDigest(
string $code,
string $storedDigest,
string $serverSecret,
): bool {
$digest = createOtpDigest($code, $serverSecret);
return hash_equals($storedDigest, $digest);
}
$serverSecret = getenv('OTP_SERVER_SECRET');
if ($serverSecret === false || $serverSecret === '') {
throw new RuntimeException('OTP_SERVER_SECRETが設定されていません。');
}
$code = generateOtpCode();
$digest = createOtpDigest($code, $serverSecret);
// データベースへは$codeではなく$digestを保存する。
if (!verifyOtpDigest($code, $digest, $serverSecret)) {
throw new RuntimeException('OTPの検証に失敗しました。');
}
hash_equals()は、タイミング攻撃への配慮が必要な文字列比較に使える関数です。比較対象の長さが一致しない場合にも、先に形式を確認するなど、入力値を適切に扱います。
HMACの秘密値がデータベースと同時に漏えいすれば、保護は失われます。秘密値をソースコードやデータベースへ保存せず、漏えい時の交換手順も考えておきます。
パスワードハッシュを使う場合
PHPのpassword_hash()とpassword_verify()を使って、OTPを一方向ハッシュとして保存する方法もあります。
$code = generateOtpCode();
$storedHash = password_hash($code, PASSWORD_DEFAULT);
// データベースへ$storedHashを保存する。
if (!password_verify($inputCode, $storedHash)) {
throw new RuntimeException('OTPが正しくありません。');
}
パスワードハッシュは平文を保存しないという点で有効ですが、OTPは桁数が少ないため、データベースだけを入手した攻撃者が全候補を試すオフライン総当たりの対象になり得ます。HMAC方式と比較し、サーバー側の秘密値を使うか、検証処理のコストをどのように設定するかを決めます。
どの方式でも、検証回数の制限、有効期限、使用済み状態の管理は必要です。ハッシュ化したから無制限に試行できる、ということではありません。
メール送信処理を作成する
今回は確認事項に合わせて、PHPのmail()を使った最小の送信例を示します。
<?php
declare(strict_types=1);
function sendOtpMail(
string $email,
string $code,
DateTimeImmutable $expiresAt,
): void {
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false
|| strpbrk($email, "\r\n") !== false) {
throw new InvalidArgumentException('メールアドレスが不正です。');
}
$subject = 'Login verification code';
$message = implode(PHP_EOL, [
'ログイン確認コード: ' . $code,
'',
'有効期限: ' . $expiresAt->format('Y-m-d H:i:s T'),
'このコードに心当たりがない場合は、このメールを破棄してください。',
]);
$headers = implode("\r\n", [
'From: no-reply@example.test',
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: 8bit',
]);
if (!mail($email, $subject, $message, $headers)) {
throw new RuntimeException('OTPメールの送信処理に失敗しました。');
}
}
mail()の戻り値がtrueでも、受信者のメールボックスへ届いたことまでは保証されません。PHPから利用するSMTPやローカルのメール確認ツールが、正しく設定されている必要があります。
メールアドレスをヘッダーへ連結する場合は、改行文字を受け付けないようにします。送信元アドレスは利用者入力ではなく、固定値や設定値から指定します。
本番環境では、送信失敗の再試行、送信キュー、送信サービスの認証情報、バウンスメールなども考慮します。今回のコードは、OTP処理とメール配送処理を分ける境界を理解するための最小例です。
発行処理の責務を分ける
OTP発行処理から直接SQLやメール送信処理を呼び出すと、利用するデータベースやメールサービスを変更しにくくなります。そこで、保存処理と通知処理をOTP発行処理と分離します。
<?php
declare(strict_types=1);
final readonly class OtpRecord
{
public function __construct(
public int $id,
public string $deliveryStatus,
public ?DateTimeImmutable $consumedAt,
public ?DateTimeImmutable $invalidatedAt,
public DateTimeImmutable $expiresAt,
public string $codeDigest,
) {}
}
interface OtpStore
{
public function transaction(callable $operation): mixed;
public function invalidateActive(
int $userId,
string $purpose,
DateTimeImmutable $now,
): void;
public function createPending(
int $userId,
string $email,
string $purpose,
string $codeDigest,
DateTimeImmutable $createdAt,
DateTimeImmutable $expiresAt,
): int;
public function markSent(int $id, DateTimeImmutable $sentAt): void;
public function markFailed(int $id, DateTimeImmutable $failedAt): void;
public function findLatestForUpdate(
int $userId,
string $purpose,
): ?OtpRecord;
public function incrementFailedAttempts(int $id): int;
public function invalidate(int $id, DateTimeImmutable $at): void;
public function consume(int $id, DateTimeImmutable $consumedAt): void;
}
interface OtpNotifier
{
public function send(
string $email,
string $code,
DateTimeImmutable $expiresAt,
): void;
}
OtpStoreの実装には、PDOを使ったクラスや、各フレームワークのORMを使ったクラスを用意できます。OtpNotifierの実装には、今回のmail()を使うクラスや、SMTPライブラリを使うクラスを指定します。
発行時は、コードを保存してからメールを送信します。送信前の状態をpendingとして保存し、送信に成功したらsentへ変更します。送信に失敗した場合はfailedとして、検証対象から外します。
$code = generateOtpCode();
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$expiresAt = $now->modify('+' . $otpTtlSeconds . ' seconds');
$digest = createOtpDigest($code, $serverSecret);
$challengeId = $store->transaction(function () use (
$store,
$userId,
$email,
$purpose,
$digest,
$now,
$expiresAt,
): int {
$store->invalidateActive($userId, $purpose, $now);
return $store->createPending(
userId: $userId,
email: $email,
purpose: $purpose,
codeDigest: $digest,
createdAt: $now,
expiresAt: $expiresAt,
);
});
try {
$notifier->send($email, $code, $expiresAt);
$store->markSent($challengeId, new DateTimeImmutable('now', new DateTimeZone('UTC')));
} catch (Throwable $exception) {
$store->markFailed($challengeId, new DateTimeImmutable('now', new DateTimeZone('UTC')));
throw $exception;
}
// $codeは以降の処理で保存せず、ログにも出力しない。
コードを保存する処理とメール送信を同じデータベーストランザクションへ含めるかどうかは、配送方法によって判断します。メール送信に失敗しても、発行回数制限などは巻き戻したくない場合があるからです。
また、再送時は、同じユーザー・同じ用途の未使用コードを無効化してから、新しいコードを発行します。古いメールが遅れて届いた場合でも、古いコードを使えないようにするためです。
コードを検証する
検証時は、入力値の形式確認だけでなく、データベース上の状態も確認します。以下はDB非依存の擬似コードです。
$inputCode = trim((string) ($_POST['code'] ?? ''));
if (preg_match('/\A\d{6}\z/', $inputCode) !== 1) {
throw new InvalidArgumentException('確認コードを確認してください。');
}
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$verified = $store->transaction(function () use (
$store,
$userId,
$purpose,
$inputCode,
$serverSecret,
$now,
$maxVerificationAttempts,
): bool {
// このメソッドはトランザクション内で対象行をロックして取得する。
$challenge = $store->findLatestForUpdate($userId, $purpose);
if ($challenge === null
|| $challenge->deliveryStatus !== 'sent'
|| $challenge->consumedAt !== null
|| $challenge->invalidatedAt !== null
|| $challenge->expiresAt <= $now) {
throw new RuntimeException('確認コードを確認できません。');
}
$digest = createOtpDigest($inputCode, $serverSecret);
if (!hash_equals($challenge->codeDigest, $digest)) {
$failedAttempts = $store->incrementFailedAttempts($challenge->id);
if ($failedAttempts >= $maxVerificationAttempts) {
$store->invalidate($challenge->id, $now);
}
// 失敗回数の更新をコミットしてから、呼び出し元でエラーにする。
return false;
}
$store->consume($challenge->id, $now);
return true;
});
if ($verified !== true) {
throw new RuntimeException('確認コードを確認できません。');
}
// ここまで成功した場合に、既存のログイン処理で認証済み状態へ移行する。
誤入力の場合は、失敗回数の更新と必要な無効化をコミットしてから、呼び出し元でエラーとして扱っています。トランザクション内で先に例外を投げる実装にすると、DBのロールバック処理によって失敗回数まで戻る可能性があるためです。
findLatestForUpdate()は、対象コードを取得してから消費済みに変更するまで、他の検証処理と競合しないようにするためのメソッドです。データベースによって、行ロックや条件付きUPDATEなど実装方法が異なります。
同じコードを使ったリクエストが同時に届いた場合でも、片方だけが消費に成功する必要があります。コードの照合と消費を別々のリクエストで行ったり、ロックなしで更新したりしないようにします。
パスワード確認後の状態を分ける
パスワードが正しかった時点では、まだログイン完了にしません。ログイン途中のユーザーを表す状態を、通常の認証済みセッションとは別に管理します。
- パスワード確認前: 未認証
- パスワード確認後、OTP検証前: ログイン途中
- OTP検証成功後: 認証済み
このログイン途中の状態へ、ユーザーIDだけを保存するか、発行したOTPのIDを保存するかは、アプリケーションの構成に合わせて決めます。OTPの平文やパスワードをセッションへ保存する必要はありません。
再送と自動化攻撃を制限する
OTPは6桁程度の短い値になるため、試行回数を制限しないと総当たり攻撃を受けます。コードの検証だけでなく、コード発行の回数も制限します。
- メールアドレスやユーザー単位の発行回数
- IPアドレス単位の発行回数
- 再送までの待ち時間
- コード単位の検証失敗回数
- ログイン全体の試行回数
制限値はアプリケーションの利用状況に合わせた設定値として管理します。コード例へ具体的な数値を直接埋め込むと、環境や運用方針を変更しにくくなります。
再送処理の公開レスポンスには、次のような一般的な文言を使えます。
入力されたメールアドレスに、確認コードを送信しました。
メールが届かない場合は、迷惑メールフォルダーや入力内容を確認してください。
登録されていないメールアドレスの場合に「ユーザーが存在しません」と返すと、メールアドレスの登録状況を推測される可能性があります。内部では送信対象の有無を区別しても、外部への文言や処理時間はできるだけ差が出ないようにします。
動作を確認する
まずは、実在する宛先へ送信しないローカルのSMTPサーバーやメール確認ツールを使います。PHPからmail()を呼び出した後、次の内容を確認します。
- メールの宛先、件名、本文に意図した内容が入っている
- メール本文やログに、想定外の個人情報や秘密値が含まれていない
- データベースへ保存された値がOTPの平文ではない
- 送信成功後のコードだけが検証対象になっている
次に、コードの状態ごとに確認します。
| 確認する状態 | 期待する結果 |
|---|---|
| 正しいコード | 一度だけ検証に成功し、コードが消費済みになる |
| 間違ったコード | 認証に失敗し、失敗回数が増える |
| 先頭ゼロを含むコード | 文字列として正しく照合できる |
| 期限切れ | 正しいコードでも拒否される |
| 再送前のコード | 新しいコード発行後は拒否される |
| 使用済みコード | 2回目の検証は拒否される |
| 送信失敗したコード | 検証対象にならない |
| 失敗回数超過 | コードが無効化され、正しいコードも拒否される |
| 同時検証 | 同じコードが複数回消費されない |
メールの受信確認には、既存のMailpit導入記事で扱ったようなローカルツールを利用できます。Mailpitを使う場合も、既定設定のSMTPを外部ネットワークへ公開しないようにします。
本番環境での注意点
メールOTPを高強度のMFAと同一視しない
メールOTPは、メールアカウントへアクセスできることを利用します。メールアカウントがパスワードだけで保護されていたり、転送設定やメールサービス側の問題があったりすると、OTPも保護できません。
NISTのデジタルアイデンティティに関するガイドラインでは、メールをアウトオブバンド認証に使うことについて制限が示されています。一方で、メールアドレスを確認するための確認コードは、認証処理とは別のものとして扱われています。
メールOTPをログインへ利用する場合は、サービスのリスクと必要な保証レベルを確認します。管理者アカウント、重要な設定変更、アカウント復旧などでは、TOTPやパスキーなどを含めて別の方法を検討した方が良いでしょう。
OTPや秘密値をログへ残さない
リクエストログ、例外メッセージ、デバッグ出力、メール送信ログへ、OTPやサーバー秘密値を出力しないようにします。認証に失敗した場合も、入力されたコードをそのまま記録しません。
コードの用途と有効期限を分ける
ログイン用、メールアドレス確認用、パスワード再設定用など、処理の目的ごとにコードを分けます。コードの用途をデータベースへ保存し、別の処理で使えないようにします。
メール送信の失敗を考慮する
メール送信処理が失敗した場合、利用者にはコードが使えないことを知らせ、データベース上のコードも検証対象から外します。送信処理が成功したように見えても、最終的な配信まで完了したとは限らない点にも注意します。
セッション処理は既存の認証基盤に合わせる
OTPの検証に成功した後は、既存のログイン処理へ認証成功を渡します。セッションを使う場合は、ログイン成功時のセッションID再生成、Cookie属性、CSRF対策、ログアウト時の無効化などを、利用している認証基盤のルールに合わせて実装します。
まとめ
メールOTPを実装するときは、コードを生成してメールで送るだけでなく、発行から消費までの状態を管理します。
random_int()で予測しにくいコードを生成する- コードの平文を保存せず、HMACやハッシュ方式を検討する
- 有効期限、用途、配送状態、使用済み、失敗回数を管理する
- 再送時は古いコードを無効化する
- 発行回数と検証回数を制限する
- 照合と消費をトランザクションやロックで保護する
- メールOTPをTOTPやパスキーと同等の認証手段として扱わない
今回のコードは、フレームワーク固有の認証機能を使わず、OTPの責務を分ける考え方に絞っています。実際のサービスへ導入する場合は、メール送信サービス、データベース、レート制限、監視、アカウント復旧まで含めて設計してください。
参考資料
奈良市を拠点に、27年以上の経験を持つフリーランスWebエンジニア、阿部辰也です。
これまで、ECサイトのバックエンド開発や業務効率化システム、公共施設の予約システムなど、多彩なプロジェクトを手がけ、企業様や制作会社様のパートナーとして信頼を築いてまいりました。
【制作会社・企業様向けサポート】
Webシステムの開発やサイト改善でお困りの際は、どうぞお気軽にご相談ください。小さな疑問から大規模プロジェクトまで、最適なご提案を心を込めてさせていただきます。
ぜひ、プロフィールやWeb制作会社様向け業務案内、一般企業様向け業務案内もご覧くださいね。
Laravel 13で既存のテーブルを使ってログイン機能を実装する
2026.09.02
Laravel 13の既存連載で作成したmembersテーブルを認証用ユーザーとして流用し、Memberモデルと認証プロバイダーを設定します。SQLiteに保存したユーザー情報を使ったメールアドレス・パスワード認証、ログイン済み画面の表示、データベースセッション、ログアウト時のセッション破棄までを実装します。
PHP 8.3のcURLでISBNから書誌情報を取得する:openBD・Google Books API・NDLサーチ
2026.09.01
PHP 8.3のcURLを使い、ISBNをもとにopenBD、Google Books API、NDLサーチへアクセスする実装例を紹介します。JSON/XMLの解析、書誌情報の共通形式への変換、通信エラーや項目欠損の扱いに加え、openBD v1の終了予定やNDLサーチの利用条件も整理します。
Laravel 13+Vue.jsで画像・CSVを非同期アップロードする:FormData・ファイル検証・非公開保存
2026.08.31
Laravel 13のBladeに組み込んだVue.jsから、FormDataを使って画像またはCSVを非同期で送信する方法を解説します。JPEG・PNGの画像検証、CSVの文字コード変換と簡易的な内容確認、20MBのサイズ制限、非公開ディスクへの保存、CSRF・バリデーションエラー処理を実装し、fetchを基本にaxiosとの違いも整理します。
Laravel 13の登録フォームにPwned Passwords APIを組み込む:漏洩パスワードの確認と障害時の扱い
2026.08.30
Laravelで、説明用の登録フォームにPwned Passwords APIを組み込みます。SHA-1ハッシュの先頭5文字だけをAPIへ送り、返されたサフィックスをアプリケーション側で照合します。漏洩検出時はフォームエラー、API障害時は警告を表示して登録を続ける構成と、カスタムバリデーションルールとの使い分けを解説します。