PAYATHON 2026

如何安全进行 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,之后的授权操作仍全部由客户端完成,那么保护依然很弱。这样的设计只是把验证逻辑放到了网络上,并没有把需要保护的能力放到服务端。

推荐的验证流程

比较完整的流程如下:

  1. 用户通过 Google Play Billing Library 完成购买。
  2. App 从购买记录中取得 purchaseToken、商品标识等信息。
  3. App 通过 HTTPS 将 purchaseToken 和商品信息提交给自己的服务端,同时携带当前登录用户的身份凭证。
  4. 服务端使用自己的 Google API 凭据,通过 Google Play Developer API 查询该笔购买。
  5. 服务端核对包名、商品、购买状态、有效期、取消状态和购买凭证归属。
  6. 服务端将购买凭证绑定到当前业务账号,并保存计算后的权益状态。
  7. 后续业务 API 每次都在服务端检查权益,不信任客户端提交的 isSubscribed。
  8. 服务端通过 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。

备注:内容仅供参考。