如何防止 JWT 被盗后通过 CURL 购买 subscription?
明确结论
是的,这是 Bearer JWT 的正常安全模型:只要持有有效 JWT,服务器就会将请求视为对应用户发起。无论使用 curl、浏览器、Postman 还是移动客户端,发送的本质上都是 HTTP 请求。服务器无法,也不应试图通过“禁止 CURL”来保护购买接口。
需要关注的是以下问题:
- JWT 是如何被窃取的;
- 被窃取的 JWT 是否权限过大或有效期过长;
- 仅凭普通登录令牌是否就能完成购买;
- 支付结果是否经过可信的支付服务端确认;
- 重放请求是否会造成重复扣款或重复订阅。
如果攻击者同时拿到了有效 JWT 和可用的支付凭据,或者购买接口允许直接使用账户中保存的付款方式,却没有额外验证,那么攻击者确实可能冒充用户操作。购买流程应以“登录令牌可能泄露”为前提进行设计,尽量控制泄露造成的损失。
不能靠阻止 CURL 解决
以下措施都不能形成有效的安全边界:
- 根据
User-Agent、Origin或Referer拒绝 CURL; - 添加一个只有前端代码“知道”的固定请求头;
- 在 JavaScript 中隐藏 endpoint;
- 混淆请求参数;
- 只允许通过页面按钮发起购买。
浏览器发出的请求可以被观察和复现。固定 Header、接口地址以及写在前端代码里的“密钥”同样可以被复制。
Origin、SameSite 等机制可以缓解 CSRF,但无法阻止已经获得 Bearer JWT 的攻击者自行构造请求。
先降低 JWT 被窃取的风险
JWT 存在 sessionStorage 中时,同源页面里运行的 JavaScript 可以读取它。一旦发生 XSS,攻击脚本就可能直接窃取令牌。
常见的改进方式是将会话凭据放进由服务器设置的 Cookie:
Set-Cookie: session=opaque-session-id; HttpOnly; Secure; SameSite=Lax; Path=/
各属性的作用如下:
HttpOnly:阻止普通 JavaScript 读取 Cookie;Secure:只通过 HTTPS 发送 Cookie;SameSite=Lax或Strict:减少跨站请求携带 Cookie 的机会;Path和必要的Domain:尽量限制 Cookie 的发送范围。
使用 Cookie 后仍需处理 CSRF。对于会修改状态的请求,应结合 SameSite、CSRF token 和 Origin 校验。如果业务确实需要跨站请求,则要按照实际部署方式调整 Cookie 和 CSRF 策略。
无论凭据存放在哪里,都要继续防范 XSS:
- 根据输出上下文进行转义;
- 不要将不可信内容传给
innerHTML; - 使用严格的 Content Security Policy;
- 谨慎引入第三方脚本;
- 定期检查依赖和前端注入点;
- 全站强制使用 HTTPS。
HttpOnly Cookie 不能彻底解决 XSS。攻击脚本即使无法直接读取 Cookie,仍可能在受害者页面中发起请求。因此,高风险操作还需要额外确认。
为购买操作增加独立授权
普通登录状态不应直接等同于“允许立即扣款”。购买或更换支付方式前,可以要求用户重新验证身份,例如:
- 重新输入密码;
- 使用 WebAuthn/passkey;
- 完成一次 MFA;
- 完成支付服务商要求的 3-D Secure 或其他强客户认证流程。
验证通过后,服务端可以签发一个有效期很短、只能使用一次的操作凭据,不再只依赖普通登录 JWT。这个凭据应绑定:
- 当前用户;
- 具体订单;
- 金额和币种;
- 操作类型;
- 很短的过期时间;
- 唯一随机值
jti; - 一次性使用状态。
例如:
{
"sub": "user_123",
"aud": "subscription-purchase",
"scope": "subscription:confirm",
"order_id": "order_456",
"amount": 9900,
"currency": "CNY",
"jti": "b9f04d7c-...",
"exp": 1787982300
}
服务端必须查询 jti 的状态,并以原子操作将其标记为已使用。只验证 JWT 签名,无法防止同一个请求被重复提交。
使用服务端控制的支付状态机
不能让客户端直接宣布“支付成功”。更可靠的处理流程是:
- 客户端请求创建订单。
- 服务端从数据库读取套餐价格,并创建状态为
pending的订单。 - 服务端调用支付服务商,创建 Payment Intent、Checkout Session 或等价对象。
- 客户端完成支付服务商要求的交互。
- 支付服务商通过签名 webhook 将支付结果通知服务端。
- 服务端验证 webhook 签名、金额、币种、商户账户和支付对象 ID。
- 服务端通过幂等事务将订单状态改为
paid,再开通 subscription。
客户端收到的“支付完成”回调只能用来刷新界面,不能作为开通订阅的最终依据。最终支付状态应来自支付服务商的服务端 API,或经过签名验证的 webhook。
价格也不能由客户端决定:
{
"plan": "pro",
"amount": 1
}
服务端只能接受 plan 这类选择标识,并自行查询实际价格。客户端传来的 amount、折扣、用户 ID 和支付状态都不能直接作为可信事实。
接口实现示例
下面是一个简化的 Express 风格示例。具体的支付 SDK 和数据库事务写法需要按照实际技术栈调整:
app.post(
"/subscribe/new",
requireAuthenticatedSession,
requireRecentStrongAuthentication,
requireCsrfProtection,
async (req, res) => {
const userId = req.auth.userId;
const { planId, idempotencyKey } = req.body;
if (!idempotencyKey) {
return res.status(400).json({ error: "missing_idempotency_key" });
}
const plan = await db.plans.findActiveById(planId);
if (!plan) {
return res.status(400).json({ error: "invalid_plan" });
}
// 必须由数据库唯一约束保证:
// UNIQUE(user_id, idempotency_key)
const existing = await db.orders.findByIdempotencyKey(
userId,
idempotencyKey
);
if (existing) {
return res.status(200).json(publicOrder(existing));
}
const order = await db.transaction(async (tx) => {
const created = await tx.orders.insert({
userId,
planId: plan.id,
amount: plan.price,
currency: plan.currency,
status: "pending",
idempotencyKey
});
const payment = await paymentProvider.createPayment({
amount: created.amount,
currency: created.currency,
metadata: {
orderId: created.id,
userId
},
idempotencyKey: created.id
});
return tx.orders.attachPayment(created.id, payment.id);
});
return res.status(201).json({
orderId: order.id,
paymentClientSecret: order.paymentClientSecret
});
}
);
Webhook 处理同样必须验证签名并保证幂等:
app.post(
"/payments/webhook",
rawBodyParser,
async (req, res) => {
let event;
try {
event = paymentProvider.verifyWebhook(
req.body,
req.headers["payment-signature"]
);
} catch {
return res.status(400).send("invalid signature");
}
if (event.type !== "payment.succeeded") {
return res.sendStatus(200);
}
const payment = event.data;
await db.transaction(async (tx) => {
const order = await tx.orders.lockByPaymentId(payment.id);
if (!order || order.status === "paid") {
return;
}
if (
payment.amount !== order.amount ||
payment.currency !== order.currency
) {
throw new Error("payment details do not match order");
}
await tx.orders.markPaid(order.id);
await tx.subscriptions.activate(order.userId, order.planId);
});
return res.sendStatus(200);
}
);
数据库唯一约束、行锁或功能相同的并发控制不可缺少。如果只在应用代码中先查询再写入,并发请求仍可能产生重复订单。
限制令牌泄露后的损失
认证体系还应包含以下控制:
- 缩短 Access Token 的有效期;
- 轮换 Refresh Token,并检测旧令牌是否被重复使用;
- 在用户注销、修改密码或发现异常时撤销会话;
- 明确校验 JWT 的
iss、aud、exp、签名算法和权限; - 不允许算法降级,也不信任未经验证的 claims;
- 为普通登录令牌和购买确认令牌设置不同的
aud或scope; - 对支付接口设置合理的频率限制;
- 在出现新设备、异常 IP 或短时间内重复购买等情况时触发二次验证;
- 向用户发送购买通知,并提供会话和设备管理功能;
- 在日志中记录订单 ID、用户 ID、会话 ID 和处理结果,但不要记录完整 JWT、银行卡数据或支付密钥。
仅将 IP 地址写入 JWT 通常并不可靠,因为移动网络、代理和 NAT 都可能导致地址变化。设备绑定、DPoP 或 mTLS 可以增加令牌重放的难度,但实现和部署成本较高,也无法替代 XSS 防护、二次验证和正确的支付状态机。
支付数据的处理边界
尽量不要让自己的服务直接接收或保存原始银行卡信息。优先使用合规支付服务商提供的托管结账页、客户端组件或 tokenization,将卡号转换为只能由指定商户使用的支付方式标识。
如果允许使用账户中已经保存的付款方式,风险会更高,因为攻击者不再需要另行获得有效的支付信息。这种情况下尤其需要近期强认证,并根据金额、设备和行为风险判断是否再次要求支付验证。
合法客户端通过 HTTP 调用购买接口本身没有问题。安全性来自服务端授权、短时强认证、可信的支付确认、幂等控制和完整的会话防护,而不是区分浏览器与 CURL。
备注:内容仅供参考。
