PHP Tips | 文字列処理:ランダム・生成 – ワンタイムトークン生成

PHP PHP
スポンサーリンク

そもそも「ワンタイムトークン」とは何か

まず、言葉のイメージをはっきりさせます。

ワンタイムトークンは、ざっくり言うとこういうものです。

  • 一度きり、または短時間だけ有効な「秘密の合言葉」
  • URL やフォームに埋め込んで、「本当に本人か」「本当に正しい手順を踏んでいるか」を確認するために使う

具体的な場面でいうと、次のようなところで使われます。

  • パスワードリセット用の URL(メールで送られてくるリンク)
  • メールアドレス確認用の URL
  • CSRF 対策トークン(フォーム送信時の「なりすまし防止」)

ここで大事なのは、「推測されない」「一度使ったら無効になる」「期限がある」という3点です。


絶対に外せない前提:トークンは「推測されてはいけない」

rand() や mt_rand() を使ってはいけない理由

PHP には rand()mt_rand() がありますが、
これらは「ゲーム用」「簡易乱数」であって、セキュリティ用途には向きません。

内部状態がある程度予測できるので、
悪意のある人が「次に出る値」を推測できてしまう可能性があります。

ワンタイムトークンは、もし推測されたら「他人になりすましてパスワードを変えられる」など、致命的な被害につながります。

だからこそ、トークン生成には必ず「暗号論的に安全な乱数」を使います。
PHP では random_bytes()random_int() がその役割を担っています。


基本形:random_bytes() + bin2hex() でトークンを作る

なぜバイト列+16進文字列がよく使われるのか

ワンタイムトークンでよく見るのは、こんな感じの文字列です。

f3a9c2b4d6e7f80123ab45cd67ef8901

これは「ランダムなバイト列」を「16進数文字列」に変換したものです。

  • random_bytes(16) → 16バイト(128ビット)のランダムデータ
  • bin2hex() → それを 32 文字の 0–9a–f の文字列に変換

このパターンは、次の理由でよく使われます。

  • ランダム性が高い(128ビットあれば実務的には十分以上)
  • 文字種が 0–9a–f だけなので、URL に載せやすい
  • 長さが固定で扱いやすい

実装例:ワンタイムトークン生成関数

/**
 * ワンタイムトークンを生成する(16バイト=32桁の16進文字列)
 *
 * @return string
 */
function generate_one_time_token(int $bytes = 16): string
{
    if ($bytes <= 0) {
        throw new InvalidArgumentException('bytes must be positive');
    }

    $random = random_bytes($bytes);  // 暗号論的に安全なランダムバイト列
    return bin2hex($random);         // 0-9a-f の文字列に変換
}
PHP

使い方の例です。

$token = generate_one_time_token();      // デフォルト16バイト → 32桁
echo $token;                             // 例: "f3a9c2b4d6e7f80123ab45cd67ef8901"

$token64 = generate_one_time_token(32);  // 32バイト → 64桁
echo $token64;
PHP

ここでの重要ポイントは、「トークンの中身に意味を持たせない」ことです。
ユーザーIDやメールアドレスを埋め込むのではなく、
純粋なランダム値として扱います。


トークンは「生成して終わり」ではない:保存と検証の流れ

典型的なパスワードリセットの流れ

ワンタイムトークンは、「生成 → 保存 → 検証 → 無効化」という一連の流れで使います。
パスワードリセットを例にすると、こんな感じです。

  1. ユーザーが「パスワードを忘れた」画面でメールアドレスを入力する
  2. サーバー側でトークンを生成する
  3. トークンとユーザーID、期限をデータベースに保存する
  4. トークンを含んだ URL をメールで送る
  5. ユーザーがその URL を開く
  6. サーバー側で「トークンが存在するか」「期限内か」「未使用か」をチェックする
  7. OK ならパスワード変更画面を表示し、トークンを無効化する(削除 or 使用済みにする)

保存用のテーブルイメージ

例えば、こんなテーブルを用意します。

password_reset_tokens
  id           (PK)
  user_id      (どのユーザーのトークンか)
  token        (ワンタイムトークン文字列)
  expires_at   (有効期限)
  used_at      (使用された日時。未使用なら NULL)
  created_at

トークン生成時のコードイメージはこうです。

$userId = $user['id'];
$token  = generate_one_time_token(32); // 32バイト → 64桁など、少し長めでもOK

$expiresAt = (new DateTimeImmutable('+1 hour'))->format('Y-m-d H:i:s');

// DB に保存(PDO の例)
$stmt = $pdo->prepare('
    INSERT INTO password_reset_tokens (user_id, token, expires_at, created_at)
    VALUES (:user_id, :token, :expires_at, NOW())
');
$stmt->execute([
    ':user_id'    => $userId,
    ':token'      => $token,
    ':expires_at' => $expiresAt,
]);

// メールに載せる URL
$url = 'https://example.com/reset-password?token=' . $token;
PHP

検証時は、こんなイメージです。

$token = $_GET['token'] ?? '';

$stmt = $pdo->prepare('
    SELECT * FROM password_reset_tokens
    WHERE token = :token
    LIMIT 1
');
$stmt->execute([':token' => $token]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$row) {
    // トークンが存在しない → 不正
}

$now = new DateTimeImmutable();

if ($now > new DateTimeImmutable($row['expires_at'])) {
    // 期限切れ
}

if ($row['used_at'] !== null) {
    // すでに使われている
}

// ここまで来たら有効なトークン
// パスワード変更処理を行い、トークンを使用済みにする
$stmt = $pdo->prepare('
    UPDATE password_reset_tokens
    SET used_at = NOW()
    WHERE id = :id
');
$stmt->execute([':id' => $row['id']]);
PHP

ここでのポイントは、「トークンの検証は“存在・期限・未使用”の3つを見る」ということです。


もう一歩安全にする:トークンをハッシュ化して保存する

なぜトークンをそのまま保存しない方がよいのか

もしデータベースが漏洩した場合、
トークンが平文で保存されていると、そのまま悪用される可能性があります。

そこで、「トークンそのものではなく、ハッシュ値だけを保存する」というパターンがあります。
これはパスワードの保存と同じ発想です。

実装イメージ:保存はハッシュ、ユーザーには生トークン

生成時はこうします。

$token = generate_one_time_token(32); // ユーザーに渡す生トークン
$tokenHash = hash('sha256', $token);  // 保存用のハッシュ

$stmt = $pdo->prepare('
    INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, created_at)
    VALUES (:user_id, :token_hash, :expires_at, NOW())
');
$stmt->execute([
    ':user_id'    => $userId,
    ':token_hash' => $tokenHash,
    ':expires_at' => $expiresAt,
]);

$url = 'https://example.com/reset-password?token=' . $token;
PHP

検証時は、こうなります。

$token = $_GET['token'] ?? '';
$tokenHash = hash('sha256', $token);

$stmt = $pdo->prepare('
    SELECT * FROM password_reset_tokens
    WHERE token_hash = :token_hash
    LIMIT 1
');
$stmt->execute([':token_hash' => $tokenHash]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);

// あとは先ほどと同じように、期限・未使用をチェック
PHP

こうしておけば、仮に DB が漏れても「トークンそのもの」は分かりません。
(もちろん、これだけで全て安全になるわけではありませんが、リスクを下げる一手になります)


まとめ:今日からの「ワンタイムトークン生成」ユーティリティ

大事なポイントを整理すると、こうなります。

  • ワンタイムトークンは「推測されない」「一度きり」「期限付き」の“秘密の合言葉”。
  • 乱数は必ず random_bytes() / random_int() を使い、rand() / mt_rand() は使わない。
  • トークンは「生成 → 保存(ユーザー紐付け+期限)→ 検証(存在・期限・未使用)→ 無効化」という流れで扱う。
  • さらに安全にしたい場合は、「トークンのハッシュだけを保存する」設計も検討する。

核になる「生成」部分のコードは、これだけです。

function generate_one_time_token(int $bytes = 16): string
{
    if ($bytes <= 0) {
        throw new InvalidArgumentException('bytes must be positive');
    }

    $random = random_bytes($bytes);
    return bin2hex($random);
}
PHP

もし、あなたのプロジェクトで「パスワードリセット用のトークンを mt_rand() で作っている」ような箇所があれば、そこがこのユーティリティに差し替えるべき一番のポイントです。
その一箇所を置き換えるだけで、アプリの“入り口の安全性”がかなり変わります。

PHPPHP
スポンサーリンク
シェアする
@lifehackerをフォローする
スポンサーリンク
タイトルとURLをコピーしました