PAYATHON 2026

在 PHP 中创建自制 paylink 是否安全?

支付阿杰

结论

不安全。meta robots 不具备访问控制能力,而 md5(time()) 生成的 key 很容易预测,也可能重复。支付链接应通过密码学安全的随机数生成器创建,同时使用 HTTPS,并设置有效期、撤销机制和访问限流。账单信息还必须由服务端校验。

为什么 md5(time()) 不安全

time() 返回当前 Unix 时间戳,通常只精确到秒。攻击者只要知道链接大致的创建时间,就能枚举前后几分钟内的时间戳:

md5($possibleTimestamp);

MD5 不会让时间戳变得更随机,只是将这个可预测的值转换成固定格式,输入范围依然很小。

这种写法还有两个问题:

  • 同一秒内创建的两笔付款会得到相同的 key。
  • MD5 已不适合安全用途。不过,即使换成 SHA-256,hash('sha256', time()) 仍然可以预测,因为问题出在输入缺少足够的随机性。

uniqid()、当前时间、订单号,或者这些值的哈希组合,同样不适合用来生成支付链接令牌。

robots 不是安全措施

下面的标签只是告诉愿意遵守规则的搜索引擎不要收录页面:

<meta name="robots" content="noindex, nofollow">

它既不能阻止别人直接访问链接,也约束不了恶意爬虫。链接还可能从以下位置泄露:

  • 浏览器历史记录和同步服务
  • Web 服务器、代理或监控日志
  • 邮件转发、聊天记录和截图
  • 第三方资源收到的 Referer
  • 分析统计、错误追踪和客服系统

所以,noindex 可以作为辅助性的隐私设置,但不能代替身份验证或安全令牌。

使用安全随机令牌

可以先生成 32 字节的密码学安全随机数,再编码成十六进制字符串:

$token = bin2hex(random_bytes(32));

生成的令牌有 64 个字符,包含 256 位随机数据。不要将它截短到容易被穷举的长度。

如果运行环境不支持 random_bytes(),应使用经过审查的 CSPRNG 兼容库,不能退回到 rand()、mt_rand()、uniqid() 或时间戳。

推荐:数据库只保存令牌哈希

原始令牌只需发送给客户,数据库中保存它的 SHA-256 哈希。这样即使数据库泄露,攻击者也不能直接使用其中的数据访问支付链接。

创建支付链接:

use Carbon\Carbon;

$token = bin2hex(random_bytes(32));

$registration->key_hash = hash('sha256', $token);
$registration->expires_at = Carbon::now()->addHours(24);
$registration->used_at = null;
$registration->save();

$url = url('/pay/' . $token);

路由可以这样定义:

Route::get('/pay/{token}', 'PaymentController@show');
Route::post('/pay/{token}', 'PaymentController@pay');

收到访问请求后,对令牌执行相同的哈希运算,再用结果查询记录:

public function show($token)
{
    if (!preg_match('/\A[a-f0-9]{64}\z/', $token)) {
        abort(404);
    }

    $keyHash = hash('sha256', $token);

    $registration = Registration::where('key_hash', $keyHash)->first();

    if (!$registration) {
        abort(404);
    }

    if ($registration->expires_at->isPast()) {
        abort(410);
    }

    if ($registration->used_at !== null) {
        abort(410);
    }

    return response()
        ->view('payments.show', compact('registration'))
        ->header('Cache-Control', 'no-store, private')
        ->header('Referrer-Policy', 'no-referrer')
        ->header('X-Robots-Tag', 'noindex, nofollow, noarchive');
}

数据库中的 key_hash 应设置唯一索引。256 位随机令牌发生碰撞的概率虽然极低,但唯一约束仍有助于保证数据一致性。

支付提交必须在服务端校验

安全链接解决的只是访问权限问题,也就是持有链接的人可以打开页面。付款金额、币种、账单归属和支付状态必须从数据库读取,不能相信浏览器提交的隐藏字段:

public function pay(Request $request, $token)
{
    if (!preg_match('/\A[a-f0-9]{64}\z/', $token)) {
        abort(404);
    }

    $registration = Registration::where(
        'key_hash',
        hash('sha256', $token)
    )->firstOrFail();

    if ($registration->expires_at->isPast() ||
        $registration->used_at !== null) {
        abort(410);
    }

    // 金额和币种必须来自服务端保存的账单。
    $amount = $registration->amount;
    $currency = $registration->currency;

    // 调用支付服务,并为支付请求设置稳定的幂等键。
    // 不要直接处理或保存银行卡敏感数据。

    // 只有在支付服务端回调验证成功后,才最终标记账单已支付。
}

用户从支付页面跳转回来,并不能证明付款已经成功。最终状态应根据支付服务商经过签名验证的 Webhook 或服务端查询结果更新。

还需要注意什么

  • 全程强制使用 HTTPS,防止令牌在传输过程中被窃取。
  • 为链接设置较短的有效期,并允许主动撤销和重新签发。
  • 根据业务需要决定链接是否只能使用一次。付款成功后,链接应立即失效。
  • 对无效令牌和支付尝试进行限流,同时避免在响应中暴露账单是否存在。
  • 不要将完整令牌写入应用日志、分析平台或错误追踪系统。
  • 支付页面应尽量少加载不必要的第三方脚本、图片和统计资源。
  • 使用 Cache-Control: no-store 和 Referrer-Policy: no-referrer 减少泄露。
  • 为支付请求设置幂等键,防止重复提交导致重复扣款。
  • 如果账单信息敏感,或者只允许指定客户付款,应在随机链接之外增加登录验证、一次性验证码等身份校验。
  • 优先使用支付服务商提供的托管支付页面,避免自行接触银行卡数据,也避免扩大 PCI DSS 合规范围。

安全随机链接本质上是一种 bearer token。任何拿到完整 URL 的人,都可能获得相应权限。它可以作为低风险账单的访问凭证,但不能因为链接难以猜中,就忽视链接泄露、生命周期管理和服务端支付校验。

备注:内容仅供参考。