如何安全进行 In-app billing validation?
结论
服务端验证无法阻止攻击者修改客户端,也不能保证 App 一定按验证结果执行。它解决的是另一个问题:由可信服务端判断购买凭证是否真实、订阅权益是否有效,避免客户端自行伪造访问服务端资源的权限。
合理的安全边界是:
- App 负责发起购买并上传
purchaseToken。 - 服务端直接向 Google Play 查询购买状态。
- 服务端保存和维护用户的订阅权益。
- 需要保护的服务端功能,必须在服务端检查权益后才能访问。
- App 收到的订阅状态主要用于界面展示,不能作为服务端授权依据。
如果付费功能完全位于本地,例如隐藏广告、解锁本地页面或启用离线算法,那么修改过的 App 仍可能绕过检查。服务端验证可以提高破解成本,但无法让不可信的客户端成为可信环境。
为什么服务端验证更安全
客户端由用户控制。攻击者可以反编译、调试或修改 APK,所以客户端中的判断不能作为最终的安全依据:
if (serverResponse.isSubscribed) {
unlockPremiumFeatures()
}
攻击者可以直接删掉条件判断:
unlockPremiumFeatures()
这并不意味着服务端验证没有价值。区别在于攻击者能控制哪些资源。
假设高级功能需要调用服务端 API:
App ──请求高级数据──> 业务服务端
│
└──检查该用户是否拥有有效权益
即使攻击者修改 App,让界面显示”已订阅”,服务端仍可拒绝请求:
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"error": "SUBSCRIPTION_REQUIRED"
}
攻击者可以伪造本地界面,却不能只靠修改 App,把服务端数据库中的订阅改成有效状态。
如果服务端只返回一个 true 或 false,之后的授权操作仍全部由客户端完成,那么保护依然很弱。这样的设计只是把验证逻辑放到了网络上,并没有把需要保护的能力放到服务端。
推荐的验证流程
比较完整的流程如下:
- 用户通过 Google Play Billing Library 完成购买。
- App 从购买记录中取得
purchaseToken、商品标识等信息。 - App 通过 HTTPS 将
purchaseToken和商品信息提交给自己的服务端,同时携带当前登录用户的身份凭证。 - 服务端使用自己的 Google API 凭据,通过 Google Play Developer API 查询该笔购买。
- 服务端核对包名、商品、购买状态、有效期、取消状态和购买凭证归属。
- 服务端将购买凭证绑定到当前业务账号,并保存计算后的权益状态。
- 后续业务 API 每次都在服务端检查权益,不信任客户端提交的
isSubscribed。 - 服务端通过 Google Play 的实时开发者通知接收续订、取消、暂停或退款等变化。收到通知后,再向 Google 查询最新状态。
具体字段和状态应以项目当前使用的 Google Play Developer API 版本为准,不能只根据某个布尔字段判断订阅是否可用。
App 只上传购买凭证
下面是简化后的 Kotlin 示例。Billing Library 不同版本的字段结构可能不同。这里的重点是上传原始 purchaseToken,不要由客户端声明”订阅有效”。
data class VerifyPurchaseRequest(
val productId: String,
val purchaseToken: String
)
suspend fun submitPurchase(purchase: Purchase, productId: String) {
val request = VerifyPurchaseRequest(
productId = productId,
purchaseToken = purchase.purchaseToken
)
subscriptionApi.verifyPurchase(request)
}
请求还应携带用户登录后的访问令牌,例如:
POST /api/subscriptions/google/verify HTTP/1.1
Authorization: Bearer <user-access-token>
Content-Type: application/json
{
"productId": "premium_monthly",
"purchaseToken": "<google-play-purchase-token>"
}
不要接受下面这种请求:
{
"isSubscribed": true,
"expiresAt": "2099-12-31T23:59:59Z"
}
这些值来自客户端,攻击者可以随意修改。
服务端验证示例
下面的 TypeScript 代码用于说明验证边界,不能直接套用到所有 API 版本。处理 Google 返回的数据结构和状态枚举时,应以当前官方接口定义为准。
type VerifyRequest = {
productId: string;
purchaseToken: string;
};
async function verifyGoogleSubscription(
userId: string,
request: VerifyRequest
) {
if (!allowedSubscriptionProducts.has(request.productId)) {
throw new Error("Unknown subscription product");
}
const subscription = await googlePlayClient.getSubscription({
packageName: ANDROID_PACKAGE_NAME,
purchaseToken: request.purchaseToken
});
const entitlement = evaluateSubscriptionEntitlement(
subscription,
request.productId
);
if (entitlement.active) {
await bindPurchaseTokenToUser({
userId,
productId: request.productId,
purchaseToken: request.purchaseToken,
expiresAt: entitlement.expiresAt
});
} else {
await revokeEntitlement(userId, request.productId);
}
return {
productId: request.productId,
active: entitlement.active,
expiresAt: entitlement.expiresAt
};
}
服务端必须使用身份认证系统提供的 userId,不能信任请求体中的用户标识:
app.post("/api/subscriptions/google/verify", authenticate, async (req, res) => {
const userId = req.authenticatedUser.id;
const result = await verifyGoogleSubscription(userId, {
productId: req.body.productId,
purchaseToken: req.body.purchaseToken
});
res.json(result);
});
真正提供高级能力的接口还要再次检查权益:
app.get("/api/premium/report", authenticate, async (req, res) => {
const entitlement = await entitlementStore.get(
req.authenticatedUser.id,
"premium"
);
if (!entitlement?.active || entitlement.expiresAt <= new Date()) {
return res.status(403).json({
error: "SUBSCRIPTION_REQUIRED"
});
}
const report = await buildPremiumReport(req.authenticatedUser.id);
res.json(report);
});
服务端验证的安全价值就在这一步。如果业务接口不检查权益,前面的验证也无法保护服务端资源。
服务端响应能否被伪造
正常情况下,App 与服务端之间应使用 HTTPS。只要没有关闭证书校验,HTTPS 就能防止普通网络攻击者读取或篡改响应。
设备已经被攻击者控制时,情况会有所不同。攻击者可能:
- 修改 APK,绕过验证结果。
- Hook 网络方法或业务方法。
- 在受控设备中安装证书,尝试拦截流量。
- 修改证书校验或移除 certificate pinning。
- 直接替换客户端收到的数据。
Certificate pinning 可以增加拦截难度,却无法抵御有能力修改 App 的攻击者。代码混淆、反调试和 Play Integrity API 同样只能提高攻击成本,不能代替服务端授权。
因此,服务端返回的状态可以用于更新客户端界面:
premiumBadge.isVisible = entitlement.active
但服务端不能相信客户端之后发回的状态:
{
"premium": true
}
是否允许访问,应始终由服务端根据自己的权益记录决定。
防止 purchaseToken 被盗用
确认 Google 返回”购买有效”还不够。服务端还要防止同一购买凭证被多个业务账号重复绑定。
通常需要保证:
一个 purchaseToken → 最多绑定一个合法业务账号
可以在数据库中为 purchaseToken 建立唯一约束:
CREATE UNIQUE INDEX subscriptions_purchase_token_uq
ON subscriptions (purchase_token);
收到已经绑定的凭证时,不能直接把它转移给另一个用户:
const existing = await findByPurchaseToken(purchaseToken);
if (existing && existing.userId !== authenticatedUserId) {
throw new PurchaseTokenAlreadyBoundError();
}
账号迁移、恢复购买和家庭共享等场景需要结合产品规则分别设计,不能根据客户端提交的新 userId 直接改绑。
如果当前集成方式支持在发起购买时传递经过混淆的业务账号标识,也可以用它辅助关联和风控,但它不能代替服务端登录身份及购买状态验证。
不要只在购买时验证一次
订阅状态会随着时间变化,包括:
- 自动续订成功或失败;
- 用户取消续订;
- 进入宽限期或账号保留期;
- 订阅暂停或恢复;
- 退款、撤销或拒付;
- 套餐升级、降级或替换;
- 订阅到期。
服务端应维护订阅状态,并通过实时开发者通知及时更新。通知本身通常只表示”状态发生了变化”。收到通知后,服务端还要调用 Google Play Developer API,获取权威的最新状态。
对于高价值操作,如果本地记录已经过期、状态异常或长时间没有同步,也可以重新查询 Google,不要无限期信任缓存。
购买确认也要正确处理
购买验证和购买确认相关,但不是同一个步骤。成功购买通常需要在规定时间内进行 acknowledgment,否则可能被自动退款。
确认操作可以放在客户端,也可以放在服务端,但必须满足两个条件:
- 只有确认购买符合业务规则后,才能授予权益;
- 确认操作必须具备幂等性,重复调用不能重复发放权益。
“已经 acknowledged”不等于”当前订阅仍然有效”,也不能代替后续的订阅状态检查。
如果高级功能必须完全离线
完全离线的本地功能没有绝对可靠的防破解方案,因为代码和最终执行环境都由用户控制。可以采取一些措施来提高破解成本:
- 使用 R8 等工具压缩和混淆代码;
- 避免用一个明显的布尔变量集中保存全部授权状态;
- 对短期权益使用由服务端签名、客户端验证的授权凭证;
- 为离线凭证设置较短的有效期,并在联网后刷新;
- 使用 Android Keystore 保护本地密钥和敏感数据;
- 在高风险场景中结合 Play Integrity API;
- 定期在线同步订阅状态。
例如,服务端可以签发短期权益凭证:
{
"subject": "user-123",
"entitlement": "premium",
"expiresAt": 1789401600
}
服务端使用私钥签名,App 内只保存公钥并进行验签。这能防止普通用户自行生成长期有效的凭证,但攻击者仍可能修改 App,跳过验签或直接解锁功能。因此,这种方式适合支持离线使用,不能视为无法破解的 DRM。
实施时的安全清单
- 客户端与服务端之间的通信全部使用 HTTPS,绝不关闭证书校验。
- Google API 服务账号凭据只能保存在服务端,不能打包进 APK。
- 服务端验证
packageName、产品标识、购买状态、有效期和购买凭证归属。 - 不信任客户端提交的用户 ID、过期时间或
isSubscribed。 - 为
purchaseToken建立唯一约束,验证接口和权益发放流程保持幂等。 - 服务端业务接口独立检查权益,不能只依赖 App 启动时的一次验证。
- 使用实时开发者通知处理续订、退款、撤销和到期等状态变化。
- 对日志中的
purchaseToken、访问令牌和服务账号凭据进行脱敏。 - 为验证接口配置身份认证、速率限制、审计日志和异常监控。
- 代码混淆、certificate pinning 和设备完整性检查属于纵深防御,不能作为信任根。
最终原则很简单:客户端可以负责显示”已订阅”,但只有服务端有权决定”允许访问什么”。 对完全位于客户端的功能,只能增加绕过难度,无法从根本上阻止修改过的 App。
备注:内容仅供参考。
