PAYATHON 2026

如何为在线购物网站安全设置 SameSite cookie?

支付小周

结论

SameSite=None; Secure 不会让 Cookie 被发送给”所有网站”。浏览器只会将 Cookie 发送到符合其 Domain、Path 等属性的目标地址。

SameSite=None 表示,当其他网站触发对你的网站的请求时,浏览器可以附带该 Cookie。它放宽的是跨站请求场景,并不会扩大 Cookie 的目标域名范围。

假设商城域名是 shop.example.com,支付网关域名是 pay.example-gateway.com:

  • 请求 https://shop.example.com/payment/return 时,可能携带商城 Cookie。
  • 请求 https://pay.example-gateway.com/checkout 时,不会携带属于 shop.example.com 的 Cookie。
  • 商城无法设置一个仍归商城所有、却只在访问支付网关域名时发送的 Cookie。

这里真正需要防范的是 CSRF。恶意网站可能诱导用户的浏览器向商城发起跨站请求,而 SameSite=None 允许浏览器在该请求中附带匹配的 Cookie。

是否必须使用 SameSite=None

这取决于支付流程以什么方式返回商城。

如果用户通过顶层页面的普通 GET 跳转回商城,例如:

https://shop.example.com/payment/return

通常可以先尝试:

Set-Cookie: session=...; Path=/; Secure; HttpOnly; SameSite=Lax

SameSite=Lax 通常允许顶层跨站导航使用安全方法发起请求,同时会阻止许多第三方子资源请求和跨站 POST 请求携带 Cookie。

如果支付网关通过浏览器向商城提交跨站 POST 表单,或者商城页面被嵌入第三方站点的 iframe,相关 Cookie 才可能需要设置为:

SameSite=None; Secure

浏览器的隐私策略也会影响具体行为。部分浏览器可能进一步限制第三方 Cookie,因此支付流程不能只依赖第三方 Cookie 是否可用。

推荐设置

1. 不设置 Domain

省略 Domain 会创建 host-only Cookie,它只会发送给设置该 Cookie 的主机:

Set-Cookie: payment_state=...; Path=/payment/return; Max-Age=600; Secure; HttpOnly; SameSite=None

如果响应来自 shop.example.com,该 Cookie 就不会发送给 api.example.com、example.com 或支付网关。

不要仅仅为了方便就扩大范围:

Domain=example.com

否则,Cookie 可能被发送给 example.com 下多个匹配的子域名。

2. 使用尽可能窄的 Path

如果 Cookie 只供支付返回接口使用,可以限制其路径:

Set-Cookie: payment_state=...; Path=/payment/return; Max-Age=600; Secure; HttpOnly; SameSite=None

这样,浏览器访问 /account 或 /products 时通常不会附带它。

不过,Path 主要控制 Cookie 的发送范围,不能作为严格的安全隔离机制。敏感权限仍须由服务端验证。

3. 设置较短的有效期

支付状态 Cookie 通常只需要保留几分钟:

Max-Age=600

支付成功、失败或状态验证完成后,应立即删除:

Set-Cookie: payment_state=; Path=/payment/return; Max-Age=0; Secure; HttpOnly; SameSite=None

删除 Cookie 时,Path 和 Domain 必须与原 Cookie 一致。

4. 同时启用 Secure 和 HttpOnly

Secure; HttpOnly
  • Secure:Cookie 只通过 HTTPS 发送。现代浏览器通常要求 SameSite=None 与 Secure 配合使用。
  • HttpOnly:禁止前端 JavaScript 直接读取 Cookie,降低 Cookie 被注入脚本窃取的风险。

Secure 无法防止 CSRF,也不能弥补 XSS 漏洞或服务端校验缺失。

Express 示例

如果跨站支付返回确实需要携带 Cookie:

app.post('/start-payment', (req, res) => {
  const paymentState = crypto.randomUUID();

  res.cookie('payment_state', paymentState, {
    httpOnly: true,
    secure: true,
    sameSite: 'none',
    path: '/payment/return',
    maxAge: 10 * 60 * 1000
  });

  // paymentState 还应与订单、用户和支付会话在服务端关联
  res.redirect(createPaymentGatewayUrl(paymentState));
});

处理返回请求时,不能只确认 Cookie 是否存在:

app.post('/payment/return', async (req, res) => {
  const cookieState = req.cookies.payment_state;
  const returnedState = req.body.state;

  if (!cookieState || cookieState !== returnedState) {
    return res.status(400).send('Invalid payment state');
  }

  const payment = await verifyPaymentWithGateway(req.body);

  if (!payment.valid) {
    return res.status(400).send('Payment verification failed');
  }

  res.clearCookie('payment_state', {
    httpOnly: true,
    secure: true,
    sameSite: 'none',
    path: '/payment/return'
  });

  res.redirect('/order/success');
});

如果支付网关采用顶层 GET 跳转,而且测试确认业务流程正常,应优先考虑:

res.cookie('payment_state', paymentState, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
  path: '/payment/return',
  maxAge: 10 * 60 * 1000
});

支付网关返回的”支付成功”参数和浏览器 Cookie 都可能遭到伪造或重放。服务端至少需要验证:

  • state 或一次性随机值是否匹配;
  • 状态是否绑定当前订单和用户;
  • 状态是否已过期或已被使用;
  • 支付网关签名是否有效;
  • 订单号、金额、币种和商户号是否一致;
  • 必要时通过支付网关的服务端 API 查询实际交易状态。

支付网关的 webhook 是服务器到服务器的请求,不会携带用户浏览器中的商城 Cookie。Webhook 应使用签名、密钥或网关规定的认证机制进行验证。

合理的做法不是把商城 Cookie “发送给支付网关”,而是让支付网关将一个短期、不可预测的一次性状态值带回商城,再由商城在服务端完成关联和验证。

备注:内容仅供参考。