如何在return_url中回传加密的登录信息?
结论
不要通过 return_url 直接传递用户名、密码、Cookie、Access Token 等登录凭证。即使使用 Base64、AES 或 RSA 处理,也不建议这样做。
建议采用以下方式:
- 用户发起支付时,由服务端生成一个随机、短期有效且只能使用一次的
state。 - 将
state与用户、订单、原登录会话关联,并保存在 Redis 或数据库中。 return_url只携带不含业务信息的state。- 支付完成并跳回商户页面后,服务端先验签、校验订单,再通过
state恢复登录上下文。 - 处理完成后,重定向到不带查询参数的业务页面。
如果支付前后的页面属于同一站点,通常不必传递 state,继续使用现有的 HttpOnly Session Cookie 即可。
为什么不应在 URL 中传递登录信息
浏览器和链路中的其他系统都能看到 return_url 的查询参数。这些参数可能出现在:
- 浏览器地址栏和历史记录
- Web 服务器、CDN、网关及监控日志
- 用户复制或分享的链接
- 页面跳转时的
Referer - 截图、录屏或第三方统计脚本中
加密可以降低内容被直接读取的风险,却无法阻止密文被复制和重放。Base64、URL 编码和 Hex 编码则根本不属于加密。
支付宝返回参数中的 sign 仅用于验证支付宝返回的数据是否遭到篡改,不会保护商户自行附加的登录信息。
推荐方案:一次性 state
1. 创建支付订单时生成随机值
下面以 Node.js 和 Redis 为例,其他语言也可以采用相同思路:
import crypto from "node:crypto";
const state = crypto.randomBytes(32).toString("base64url");
await redis.set(
`payment:return:${state}`,
JSON.stringify({
userId: currentUser.id,
outTradeNo,
redirectPath: "/payment/result"
}),
{
EX: 600,
NX: true
}
);
生成的 state 应满足以下条件:
- 至少包含 128 位随机性
- 不携带用户信息或登录凭证
- 有效期较短,例如 5~10 分钟
- 使用后立即删除,防止重放
2. 将 state 放入回跳地址
const returnUrl =
`https://www.example.com/alipay/return?state=${encodeURIComponent(state)}`;
能否在 return_url 中附加自定义查询参数,要以当前使用的支付宝产品接口和 SDK 行为为准。如果接入方式不能可靠保留自定义参数,可以在订单记录中保存用户与 out_trade_no 的关联,回跳后再通过商户订单号查询。
不要将敏感信息拼接到 URL 中:
# 不推荐
https://www.example.com/alipay/return?username=alice&token=...
# 同样不推荐
https://www.example.com/alipay/return?loginInfo=<encrypted-data>
3. 回跳后先验证支付宝签名
验签应使用支付宝 SDK,并传入支付宝返回的原始参数集合。商户自己的 state 不应交给支付宝验签逻辑处理。
app.get("/alipay/return", async (req, res) => {
const { state, ...alipayParams } = req.query;
const signatureValid = verifyAlipaySignature(alipayParams);
if (!signatureValid) {
return res.status(400).send("签名验证失败");
}
// 后续继续校验订单和 state
});
verifyAlipaySignature 只是示意名称。实际项目应调用对应语言的支付宝 SDK 验签方法,不要自行实现 RSA2 验签和参数排序。
4. 校验订单并原子消费 state
app.get("/alipay/return", async (req, res) => {
const { state, ...alipayParams } = req.query;
if (!verifyAlipaySignature(alipayParams)) {
return res.status(400).send("签名验证失败");
}
const key = `payment:return:${state}`;
// 生产环境应使用 GETDEL 或 Lua 脚本保证读取和删除的原子性
const contextText = await redis.getDel(key);
if (!contextText) {
return res.status(400).send("回跳凭证无效、已使用或已过期");
}
const context = JSON.parse(contextText);
if (context.outTradeNo !== alipayParams.out_trade_no) {
return res.status(400).send("订单号不匹配");
}
const order = await findOrder(context.outTradeNo);
if (
!order ||
order.userId !== context.userId ||
order.amount !== alipayParams.total_amount ||
alipayParams.app_id !== process.env.ALIPAY_APP_ID ||
alipayParams.seller_id !== process.env.ALIPAY_SELLER_ID
) {
return res.status(400).send("订单信息校验失败");
}
// 不要把 return_url 当作支付成功的唯一依据
await confirmPaymentFromTrustedResult(order);
await establishLoginSession(res, context.userId);
// 清除地址栏中的支付参数和 state
return res.redirect(303, context.redirectPath);
});
如果当前 Redis 版本不支持 GETDEL,可以使用 Lua 脚本原子地读取并删除数据,避免同一个 state 被并发使用多次。
支付结果不能只看 return_url
return_url 是浏览器的同步跳转地址。用户可能关闭页面、阻止跳转,也可能重复访问该地址,因此不能只凭浏览器回跳就将订单标记为支付成功。
服务端还需要:
- 验证支付宝返回签名。
- 校验
app_id、seller_id、out_trade_no和total_amount。 - 以
notify_url异步通知或支付宝服务端订单查询结果作为支付状态依据。 - 保证订单更新具有幂等性,避免重复入账或发货。
return_url 更适合用来展示支付结果,不应承担最终记账职责。
已有登录会话时的处理
如果支付前后的页面都在同一个 HTTPS 域名下,浏览器通常会自动携带站点的 Session Cookie。此时只需正确配置 Cookie:
Set-Cookie: session=<opaque-session-id>; Path=/; HttpOnly; Secure; SameSite=Lax
在跨站点顶级导航回到商户页面时,SameSite=Lax 通常允许浏览器携带 Cookie。不过,具体表现仍要结合支付跳转方式、浏览器策略和域名结构进行测试。
Cookie 中只保存随机的会话 ID,真实登录状态放在服务端 Session 中。不要将用户密码、Access Token 或完整用户资料写入 Cookie。
如果确实必须传递自包含密文
只有无法使用服务端状态存储时,才考虑采用经过认证的加密方案,例如 AES-256-GCM。密文至少应包含:
userId- 关联的
outTradeNo - 过期时间
exp - 唯一随机标识
jti
解密后仍要校验订单绑定关系和有效期,并记录 jti 的使用情况,防止重放。密钥只能保存在服务端,不能写入前端代码或 URL。
即使使用 AES-GCM,也不要封装密码、Session Cookie 或长期 Access Token。URL 中的密文仍可能泄漏并被重放,因此一次性随机 state 更稳妥。
注意事项
- 全程使用 HTTPS。
- 不要将完整回跳 URL、
sign、state或令牌写入日志。 redirectPath应使用服务端白名单,避免开放重定向漏洞。- 不要接受客户端传入的任意回跳域名。
state必须由密码学安全随机数生成器生成,不能使用时间戳或自增 ID。- 登录恢复和支付确认需要分别校验,不能因为支付参数验签通过就直接信任用户身份。
- 处理完成后,立即通过
303重定向到干净地址,清除地址栏中的敏感查询参数。
备注:内容仅供参考。
