そもそも「ワンタイムトークン」とは何か
まず、言葉のイメージをはっきりさせます。
ワンタイムトークンは、ざっくり言うとこういうものです。
- 一度きり、または短時間だけ有効な「秘密の合言葉」
- 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やメールアドレスを埋め込むのではなく、
純粋なランダム値として扱います。
トークンは「生成して終わり」ではない:保存と検証の流れ
典型的なパスワードリセットの流れ
ワンタイムトークンは、「生成 → 保存 → 検証 → 無効化」という一連の流れで使います。
パスワードリセットを例にすると、こんな感じです。
- ユーザーが「パスワードを忘れた」画面でメールアドレスを入力する
- サーバー側でトークンを生成する
- トークンとユーザーID、期限をデータベースに保存する
- トークンを含んだ URL をメールで送る
- ユーザーがその URL を開く
- サーバー側で「トークンが存在するか」「期限内か」「未使用か」をチェックする
- 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() で作っている」ような箇所があれば、そこがこのユーティリティに差し替えるべき一番のポイントです。
その一箇所を置き換えるだけで、アプリの“入り口の安全性”がかなり変わります。
