公開日 2026-09-25

PHPでワンタイム認証キー(OTP)を自作する(HMAC + pepper + 原子的UPDATE)

PHPで6桁のワンタイム認証キーを自作する最小構成。random_intでの生成、DBにはHMAC-SHA256+pepperのハッシュだけを保存、試行回数の上限を原子的UPDATEで安全に数える設計をテスト付きで示す。

目次

  1. 動作を確認した構成
  2. 設計方針
  3. 発行と検証のサービス
  4. コードのポイント
  5. HMAC + pepper にする理由
  6. リポジトリと原子的UPDATE
  7. コードのポイント
  8. テストで振る舞いを固定する
  9. コードのポイント
  10. 検証キーをどこに結び付けるか
  11. まとめ

対象読者: PHP でメールに送る数字のワンタイムキー(OTP)を自前で実装したい方。フレームワークの認証機能ではなく、依存を増やさずに最小構成で組む場面を想定します。

共有リンクを開いた相手にメールで6桁の数字を送り、その数字が合っていればダウンロードを許可する。この種のワンタイム認証を PHP と PostgreSQL だけで組むと、設計判断が3か所に集まります。生成した数字をどう保存するか、検証をどう安全に比較するか、試行回数の制限をどう競合なく数えるか。この記事では、AuthKeyService という1クラスと、それが使うリポジトリを題材に、この3点をコードで示します。

TOTP(認証アプリに時間ベースで表示されるコード)は扱いません。ここで作るのは、サーバーが乱数で発行してメールで配る使い切りのキーです。永続化の細部(マイグレーションツールの選定など)も範囲外で、テーブルは CREATE TABLE を1つ載せるにとどめます。

動作を確認した構成

コードを動かして確認したのは、次の構成です。

  • PHP 8.4(pdo_pgsql 拡張)
  • PostgreSQL 17
  • 依存パッケージはテスト用の PHPUnit のみ(本番コードはフレームワーク非依存)

キーの保存先として、次のテーブルを前提にします。

CREATE TABLE auth_keys (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    key_hash text NOT NULL,
    expires_at timestamptz NOT NULL,
    attempt_count integer NOT NULL DEFAULT 0,
    verified_at timestamptz,
    created_at timestamptz NOT NULL DEFAULT now()
);

平文の数字は列に持ちません。保存するのは key_hash(HMAC-SHA256 のハッシュ)だけです。attempt_count は成否を問わない検証試行の回数(正しいコードでも1回消費する)、verified_at は検証成功で1度だけ立てる印になります。

設計方針

先に決めておく判断は3つ。

  • 生成: random_int() で6桁を作る。rand() や mt_rand() は使わない
  • 保存: 平文ではなく HMAC-SHA256 + pepper のハッシュだけを DB に置く。pepper(サーバー側の秘密鍵)が未設定なら発行を止める
  • 検証: hash_equals() で時間一定比較し、試行回数の上限判定は1本の UPDATE に閉じ込める

pepper の必要性と、試行回数を PHP 側で数えない理由は、コードを見たあとの4章・5章で実測とともに扱います。

発行と検証のサービス

まず、発行結果を表す小さな値オブジェクトです。src/Security/IssuedAuthKey.php に置きます。呼び出し側へ返すのは、DB 上の id と、メールに載せる平文の code の2つだけです。

<?php
declare(strict_types=1);

namespace App\Security;

final readonly class IssuedAuthKey
{
    public function __construct(
        public int $id,
        public string $code,
    ) {
    }
}

本体の AuthKeyService を src/Security/AuthKeyService.php に置きます。発行(issue)と検証(verify)の2つのメソッドを持ち、DB アクセスはリポジトリへ委ねる形です。

<?php
declare(strict_types=1);

namespace App\Security;

use RuntimeException;
use SensitiveParameter;

final class AuthKeyService
{
    private const CODE_LENGTH = 6;

    public function __construct(
        private readonly AuthKeyRepository $repository,
        #[SensitiveParameter] private readonly string $pepper,
        private readonly int $ttlMinutes = 15,
        private readonly int $maxAttempts = 5,
    ) {
        if ($this->pepper === '') {
            throw new RuntimeException(
                'Auth key pepper is not configured. Set AUTH_KEY_PEPPER before issuing keys.'
            );
        }
    }

    public function issue(): IssuedAuthKey
    {
        $code = str_pad(
            (string) random_int(0, 10 ** self::CODE_LENGTH - 1),
            self::CODE_LENGTH,
            '0',
            STR_PAD_LEFT,
        );

        $id = $this->repository->insert($this->hash($code), $this->ttlMinutes);

        return new IssuedAuthKey(id: $id, code: $code);
    }

    public function verify(int $id, #[SensitiveParameter] string $inputCode): bool
    {
        $storedHash = $this->repository->claimAttempt($id, $this->maxAttempts);
        if ($storedHash === null) {
            return false;
        }

        if (!hash_equals($storedHash, $this->hash($inputCode))) {
            return false;
        }

        return $this->repository->markVerified($id);
    }

    private function hash(string $code): string
    {
        return hash_hmac('sha256', $code, $this->pepper);
    }
}

コードのポイント

① pepper 未設定は例外で止める(fail-closed)

    public function __construct(
        private readonly AuthKeyRepository $repository,
        #[SensitiveParameter] private readonly string $pepper,

pepper が空文字のままサービスを組み立てると、コンストラクタで RuntimeException を投げます。設定漏れのときに「pepper 無しのハッシュ」で発行を続けてしまうと、DB が漏れた場合の防御(4章)が丸ごと抜ける。空でも動く作りにはせず、起動時点で気づける形にしました。#[SensitiveParameter] は、例外のスタックトレースに pepper の値が載るのを防ぐ属性です。

② 6桁の生成は random_int() を使う

        $code = str_pad(
            (string) random_int(0, 10 ** self::CODE_LENGTH - 1),
            self::CODE_LENGTH,
            '0',
            STR_PAD_LEFT,
        );

random_int() は暗号論的に安全な乱数を返します。rand() / mt_rand() は速度優先の擬似乱数で、出力を多数観測すれば内部状態を復元して以降の値を予測できるため、認証キーの生成には向きません。str_pad() で左を 0 埋めするのは、7 や 512 のような桁数の少ない数でも常に6桁の文字列にするためです。生成範囲は 0〜999999 なので、コードは常に 000000〜999999 の100万通りです。

③ 保存前に必ずハッシュ化し、平文は返り値だけに残す

        $id = $this->repository->insert($this->hash($code), $this->ttlMinutes);

        return new IssuedAuthKey(id: $id, code: $code);

リポジトリへ渡すのは hash() を通した値で、平文の $code は DB へ渡しません。これで平文は DB に置かれない。ただし平文はもう1か所、検証時の入力として現れます。verify(int $id, #[SensitiveParameter] string $inputCode) の $inputCode は利用者が入れた本物のコード候補なので、ここにも #[SensitiveParameter] を付けています。付けないと、verify() の中で PDO が例外を投げたとき(zend.exception_ignore_args が無効な環境では)スタックトレースの引数に平文コードが載り、ログへ流れ込む経路が残ります。

検証時は、入力された数字を同じ hash() に通してから DB のハッシュと比べます(verify)。突き合わせに使う hash_equals() は、秘密値から導いたダイジェスト同士を定数時間で比べ、一致した位置(先頭から何バイト一致したか)を実行時間の差として漏らさない関数です。ここで比べているのは HMAC のハッシュ同士なので、時間差が漏れても得られるのはハッシュのバイト情報だけで、pepper 無しでは平文コードに戻せません。MAC や認証情報を比較するときの定石として使い、=== の早期 return を避ける安価な保険と考えてください。

HMAC + pepper にする理由

第一の防御は、コードそのものの寿命と試行回数です。コードは15分で失効し(ttlMinutes)、使い切りで(verified_at)、誤入力は5回で打ち止め(attempt_count)。オンラインで verify を叩いて総当たりする攻撃は、この短い有効期限と5回の上限に阻まれます。ハッシュの話はこの前提の上に乗ります。

保存側で考えるのは、DB が漏れたときに何が起きるかです。100万通りは総当たりできる規模なので、平文で保存すれば漏洩=即読み取り。では password_hash()(bcrypt)でハッシュ化して「遅く」すればよいかというと、この用途では割に合いません。手元の PHP 8.4 で bcrypt(デフォルトの cost 12)の検証時間を測りました。

default bcrypt cost: {"cost":12} / verify: 186.8 ms/回 / 10^6通り: 51.9 時間(単純計算)

1回あたり約187ミリ秒。100万通りの総当たりは単純計算で約52時間、当たりまでの期待値はその半分の約26時間、複数コア・複数マシンに分ければさらに縮む。これは「bcrypt が弱い」という話ではありません。15分で失効するコードなら、この遅さは総当たりを有効期限の外へ十分に押し出せる。問題は別のところにあります。正規の検証でも1回ごとに約187ミリ秒を払うので、短命なコードを守るために毎回この重さを背負うのは釣り合いません。

pepper を使うと、検証を遅くせずに同じ守りが得られます。pepper は DB に保存せず、アプリの環境変数(AUTH_KEY_PEPPER)に置くサーバー側の秘密鍵です。ハッシュは HMAC-SHA256(key=pepper, message=code) で作るので、pepper を知らない攻撃者は、たとえ DB 全体を手に入れても総当たりの計算を始められません。手元のハッシュと突き合わせる「code のハッシュ」を、pepper 無しでは1つも作れないからです。無 pepper の素の SHA256 なら100万通りをミリ秒で全列挙されますが、pepper を挟むと高速ハッシュのまま DB 漏洩に耐えます。

pepper の効用は、DB のスナップショットだけが漏れたときに、まだ有効な窓内のコードすらオフラインで復元させないことに絞られます。DB とは別の境界で pepper が秘匿されている限り、DB 単独の漏洩から平文コードは戻せない。アプリサーバーごと侵害されて pepper も一緒に漏れれば、この守りは効きません(DB と同じホストに .env を置く構成なら、両者は同時に漏れる)。オンライン推測に対して pepper は無力で、そこを守るのは前述の短い有効期限と試行上限になります。

pepper を使ううえでの前提が2つあります。ひとつは pepper 自体の強度で、random_bytes(32) 相当の高エントロピーな値を秘密管理から注入する。短い単語や推測できる文字列だと、DB を得た攻撃者が pepper 候補ごと総当たりできてしまい、秘匿の前提が崩れます。もうひとつは、この構成が行ごとの salt を持たない点。同じコードは同じハッシュに写るので、pepper が割れると100万通りの事前計算表で全行が一斉に破られます(bcrypt の行ごと salt と違い、被害が全行に及ぶ)。pepper を入れ替えると既存の key_hash は検証できなくなりますが、コードは15分で失効するので、窓が明けるのを待てば実害は限られます。

リポジトリと原子的UPDATE

インターフェースを先に定義します。src/Security/AuthKeyRepository.php です。サービスは、この3メソッドしか知りません。

<?php
declare(strict_types=1);

namespace App\Security;

interface AuthKeyRepository
{
    public function insert(string $keyHash, int $ttlMinutes): int;

    public function claimAttempt(int $id, int $maxAttempts): ?string;

    public function markVerified(int $id): bool;
}

PostgreSQL 実装を src/Security/DbAuthKeyRepository.php に置きます。試行回数の判定は、claimAttempt() の1本の UPDATE に閉じ込めた形です。

<?php
declare(strict_types=1);

namespace App\Security;

use PDO;

final class DbAuthKeyRepository implements AuthKeyRepository
{
    public function __construct(
        private readonly PDO $pdo,
    ) {
    }

    public function insert(string $keyHash, int $ttlMinutes): int
    {
        $statement = $this->pdo->prepare(<<<'SQL'
            INSERT INTO auth_keys (key_hash, expires_at)
            VALUES (:key_hash, now() + make_interval(mins => :ttl_minutes))
            RETURNING id
            SQL);
        $statement->bindValue(':key_hash', $keyHash);
        $statement->bindValue(':ttl_minutes', $ttlMinutes, PDO::PARAM_INT);
        $statement->execute();

        return (int) $statement->fetchColumn();
    }

    public function claimAttempt(int $id, int $maxAttempts): ?string
    {
        $statement = $this->pdo->prepare(<<<'SQL'
            UPDATE auth_keys
               SET attempt_count = attempt_count + 1
             WHERE id = :id
               AND verified_at IS NULL
               AND expires_at > now()
               AND attempt_count < :max_attempts
            RETURNING key_hash
            SQL);
        $statement->bindValue(':id', $id, PDO::PARAM_INT);
        $statement->bindValue(':max_attempts', $maxAttempts, PDO::PARAM_INT);
        $statement->execute();

        $keyHash = $statement->fetchColumn();

        return $keyHash === false ? null : (string) $keyHash;
    }

    public function markVerified(int $id): bool
    {
        $statement = $this->pdo->prepare(<<<'SQL'
            UPDATE auth_keys
               SET verified_at = now()
             WHERE id = :id
               AND verified_at IS NULL
            SQL);
        $statement->bindValue(':id', $id, PDO::PARAM_INT);
        $statement->execute();

        return $statement->rowCount() === 1;
    }
}

コードのポイント

① 判定と加算を1本の UPDATE にまとめる

            UPDATE auth_keys
               SET attempt_count = attempt_count + 1
             WHERE id = :id
               AND verified_at IS NULL
               AND expires_at > now()
               AND attempt_count < :max_attempts
            RETURNING key_hash

「まだ検証されていない」「期限内」「試行回数が上限未満」という3条件を WHERE に置き、その場で attempt_count を1つ増やします。条件に合わなければ更新される行は0件で、RETURNING は何も返さない。サービス側はこの戻り値の有無だけを見て、null なら検証を拒否します。有効期限も試行回数も、SQL 1文の中で判定と更新を同時に済ませているのが要点です。

② SELECT してから UPDATE に分けない

この判定を「SELECT attempt_count で読み、PHP 側で上限と比べ、別の UPDATE で加算する」と3ステップに分けると、read-modify-write の隙間ができます。2つのリクエストがほぼ同時に来ると、両方が同じ attempt_count = 4 を読み、両方が「まだ上限未満」と判断して加算へ進めます。上限が5回のはずが6回試せる、という穴です。

1本の UPDATE に閉じ込めると、行ロックによって2つの更新が直列化されます。挙動を見やすくするため、attempt_count = 4 の行へ2つのセッションから同時に同じ UPDATE を投げ、片方を明示トランザクションで開いたまま止めて確かめました。片方が更新中(attempt_count を4→5にした未コミットの状態)で、もう片方は行ロックの解放を待つ状態になります。

--- session B (starts while A holds the row lock) ---
 attempt_count
---------------
(0 rows)

UPDATE 0

real    0m3.312s

セッションBは約3.3秒待たされたあと(セッションAのコミット待ち)、更新0件を返しました。Aのコミットで attempt_count が5になった結果を見て、WHERE attempt_count < 5 に合致しなくなったからです。PostgreSQL は待機の解放後に WHERE を最新の行で評価し直すため、上限を超えた6回目は成立しません。この3.3秒はデモのためにトランザクションを開いたまま止めた最悪ケースで、実際の claimAttempt() は単文(autocommit)なので、待たされてもロックを持つのは1文の実行時間だけです。

この評価し直しは、PostgreSQL の既定である READ COMMITTED 分離レベルの挙動です。この分離レベルでは、アプリ側でロックを組む必要はありません。ただし接続やフレームワークの設定で REPEATABLE READ / SERIALIZABLE に上げている場合、同じ競合は0件更新ではなく直列化失敗(ERROR: could not serialize access due to concurrent update、SQLSTATE 40001)で例外になります。その構成では例外を捕まえてトランザクションごと再試行してください。上限自体は破れませんが、null だけを失敗として扱う実装だと未処理の例外で 500 になります。

テストで振る舞いを固定する

DB につなぐテストで、発行と検証の性質を固定します。tests/Security/AuthKeyServiceTest.php です。各テストの冒頭で auth_keys を空にし、AuthKeyService に実際の DbAuthKeyRepository を渡しています。

<?php
declare(strict_types=1);

namespace Tests\Security;

use App\Security\AuthKeyService;
use App\Security\DbAuthKeyRepository;
use PDO;
use PHPUnit\Framework\TestCase;
use RuntimeException;

final class AuthKeyServiceTest extends TestCase
{
    private PDO $pdo;

    private AuthKeyService $service;

    protected function setUp(): void
    {
        $this->pdo = new PDO(
            (string) getenv('DB_DSN'),
            (string) getenv('DB_USER'),
            (string) getenv('DB_PASSWORD'),
            [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION],
        );
        $this->pdo->exec('TRUNCATE auth_keys RESTART IDENTITY');

        $this->service = new AuthKeyService(
            repository: new DbAuthKeyRepository($this->pdo),
            pepper: 'test-pepper',
        );
    }

    public function testStoredValueIsHashNotPlainCode(): void
    {
        $issued = $this->service->issue();

        $keyHash = $this->fetchColumn($issued->id, 'key_hash');

        self::assertNotSame($issued->code, $keyHash);
        self::assertMatchesRegularExpression('/\A[0-9a-f]{64}\z/', (string) $keyHash);
    }

    public function testKeyIsSingleUse(): void
    {
        $issued = $this->service->issue();

        self::assertTrue($this->service->verify($issued->id, $issued->code));
        self::assertFalse($this->service->verify($issued->id, $issued->code));
    }

    public function testCorrectCodeIsRejectedAfterMaxAttempts(): void
    {
        $issued = $this->service->issue();
        $wrong = $this->wrongCode($issued->code);

        for ($i = 0; $i < 5; $i++) {
            self::assertFalse($this->service->verify($issued->id, $wrong));
        }

        self::assertFalse($this->service->verify($issued->id, $issued->code));
    }

    public function testExpiredKeyIsRejected(): void
    {
        $issued = $this->service->issue();

        $statement = $this->pdo->prepare(
            "UPDATE auth_keys SET expires_at = now() - interval '1 second' WHERE id = :id"
        );
        $statement->execute(['id' => $issued->id]);

        self::assertFalse($this->service->verify($issued->id, $issued->code));
    }

    public function testEmptyPepperFailsClosed(): void
    {
        $this->expectException(RuntimeException::class);

        new AuthKeyService(
            repository: new DbAuthKeyRepository($this->pdo),
            pepper: '',
        );
    }

    private function wrongCode(string $code): string
    {
        return $code === '000000' ? '000001' : '000000';
    }

    private function fetchColumn(int $id, string $column): mixed
    {
        $statement = $this->pdo->prepare(
            "SELECT {$column} FROM auth_keys WHERE id = :id"
        );
        $statement->execute(['id' => $id]);

        return $statement->fetchColumn();
    }
}

コードのポイント

① 保存値が平文でないことをテストで押さえる

        $keyHash = $this->fetchColumn($issued->id, 'key_hash');

        self::assertNotSame($issued->code, $keyHash);
        self::assertMatchesRegularExpression('/\A[0-9a-f]{64}\z/', (string) $keyHash);

発行した平文 code と DB の key_hash が一致しないこと、そして key_hash が SHA256 相当の64桁の16進文字列であることを確かめます。「うっかり平文を保存する実装に戻す」変更を、このテストが止めます。上限超過(testCorrectCodeIsRejectedAfterMaxAttempts)と期限切れ(testExpiredKeyIsRejected)は、正しい code を渡しても拒否されることを確認しています。

実行結果は次のとおりです。

PHPUnit 12.5.31 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23
Configuration: /workspace/phpunit.xml

.........                                                           9 / 9 (100%)

OK (9 tests, 16 assertions)

検証キーをどこに結び付けるか

verify(int $id, ...) の $id は auth_keys の連番の主キーです。この最小実装は id の出所を扱っていませんが、実運用ではこの id をクライアントからそのまま受け取ってはいけません。素の連番を URL やフォームで受けると、攻撃者が id を 1, 2, 3… と指定して誤コードで verify を叩くだけで、他人の生きているキーの attempt_count を上限まで消費できます。claimAttempt() は成否に関わらず試行を数えるので、正規の利用者は正しいコードを入れても attempt_count < 5 に阻まれて認証できなくなります(妨害=DoS)。有効な id の列挙にもつながります。

id は、OTP を発行したときにサーバー側のセッションへ保持するか、推測できないランダムなトークンから引く形にします。行を「誰の・どの共有リンクの認証か」に結び付け、UPDATE の WHERE にその条件も含めれば、他人の行には触れません。試行回数の制限も、id 単位だけでなく送信元やセッション単位でも掛けます。

まとめ

6桁のワンタイムキーを自前で組むときの3つの判断を、コードで示しました。

  • 生成は random_int()。保存は平文ではなく HMAC-SHA256 + pepper のハッシュだけ
  • 第一の防御は短い有効期限・使い切り・試行上限。pepper の効用はその上で、DB 単独漏洩時にまだ有効な窓内のコードすらオフライン復元させないことに絞られる。検証を遅くせずにそれを得るために、bcrypt ではなく高速ハッシュ+pepper を選ぶ
  • 試行回数の上限は SELECT と UPDATE に分けず、条件付きの1本の UPDATE(既定の READ COMMITTED 前提)に閉じ込めて競合を避ける

条件付き UPDATE で同時実行を安全に捌く考え方は、在庫引当やジョブキューでも同じ形で使えます。行ロックで更新を直列化する例は PHP + PostgreSQLで在庫引当を安全に実装する(SELECT … FOR UPDATE 最小構成) を参照してください。