SuperAPI AI Directory

security

Security & auditing

View repository

Updated 10-16 07:58

About this skill

Securing Google Play Billing

Anything running on the user device can be tampered with. If your trust model lives in the client, you have already lost. This skill shows you how to move trust to your backend and defend against the attack patterns you will actually see in production.

Phase 1: Assess

Before you write a line of defensive code, answer these questions about your current integration:

  • Where does purchase verification happen today? Client, backend, or both?
  • Does your database enforce a unique constraint on purchaseToken?
  • Do you set obfuscatedAccountId and obfuscatedProfileId on every purchase flow?
  • Do you poll the Voided Purchases API on a schedule?
  • Do any access decisions rely on System.currentTimeMillis() from the device?

Any "no" answer maps to a layer you are missing below.

Phase 2: Plan the Layered Defense

Fraud prevention is not a single check. It is a stack of controls where each layer catches a different attack class. Plan all of them, even if you ship them incrementally.

Layer Purpose Where it runs
1. Backend receipt verification Confirm the purchase exists in Google's records Your server, via purchases.products.get / purchases.subscriptionsv2.get
2. Response field validation Catch product swaps and cross app token reuse Your server (check packageName, productId, orderId)
3. Purchase state check Reject PENDING and expired purchases Your server
4. Replay protection Block reuse of valid tokens Unique constraint on purchase token column
5. Token to user binding Detect token sharing across accounts Your server (store and compare userId on duplicates)
6. Acknowledgement Prevent auto refund at 3 day mark Your server, after entitlement grant
7. Voided purchase sync Revoke access on refunds and chargebacks Daily batch job against purchases.voidedpurchases.list
8. Device and profile signals Give Google and yourself correlation data Client sets obfuscatedAccountId / obfuscatedProfileId before launching the flow
9. Integrity and pinning Raise the cost of modified APKs Play Integrity API plus TLS certificate pinning
10. Server time of record Defeat timezone manipulation All time decisions use server clock

Decide the order based on risk. For most apps: layers 1 through 6 are non negotiable for launch. Layer 7 follows within the first week. Layers 8 through 10 harden against the targeted attacks you will see later.

Phase 3: Execute

Step 1: Send the token from client to backend

Keep the client thin. Its only job is to hand the token to your server along with the current user identifier.

override fun onPurchasesUpdated(
    result: BillingResult,
    purchases: List<Purchase>?
) {
    if (result.responseCode != BillingResponseCode.OK) return
    purchases?.forEach { p ->
        if (p.purchaseState == PurchaseState.PURCHASED) {
            api.verify(p.purchaseToken, p.products, currentUserId)
        }
    }
}

Send over HTTPS. Pin the server certificate if you can. Include your own auth token so the server knows which user is claiming the purchase.

Step 2: Verify on the backend and bind the token to the user

The server is the only place that decides whether a purchase is real. Call the Google Play Developer API, validate the response, then atomically insert the entitlement under a unique constraint on purchase_token. That constraint is your replay defense.

def grant_entitlement(token, claimed_product, user_id):
    record = play_api.get_purchase(PACKAGE, claimed_product, token)
    assert record["packageName"] == PACKAGE
    assert record["productId"] == claimed_product
    assert record["purchaseState"] == 0  # PURCHASED, not PENDING
    existing = db.entitlements.find_by_token(token)
    if existing:
        if existing.user_id != user_id:
            security_log.flag("token_reuse", token, user_id)
        return existing
    db.entitlements.insert_unique(token, user_id, record["orderId"])
    play_api.acknowledge(PACKAGE, claimed_product, token)

Two things do the real work here. First, the unique insert on token means a replayed token from any account is a database conflict, not a second grant. Second, comparing the claiming user_id against the original owner on every lookup surfaces token sharing and account abuse patterns that a naive "already processed, return success" path would hide.

Step 3: Add device and profile signals before the purchase

Set both obfuscated identifiers on every purchase flow. They cost nothing and give Google, and you, a way to correlate across sessions and Google accounts.

val params = BillingFlowParams.newBuilder()
    .setProductDetailsParamsList(listOf(productParams))
    .setObfuscatedAccountId(sha256(currentUser.id))
    .setObfuscatedProfileId(sha256(currentUser.activeProfileId))
    .build()
billingClient.launchBillingFlow(activity, params)

Both values come back in the purchase record, so your backend can reconcile them with your user database even if the user switches Google accounts.

Fraud Pattern to Mitigation Reference

Fraud pattern Primary mitigation Backstop
Modified APK removes client checks Backend receipt verification Play Integrity API, R8 obfuscation
Fabricated purchase tokens purchases.products.get returns error, server rejects Package name and product ID checks
Stolen token from another app packageName validation on the API response Order ID uniqueness
Product swap (cheap token, expensive claim) productId validation against client claim Price recorded from API response, not client
Replayed token on same account Unique constraint on purchase_token Idempotent grant returns existing record
Replayed token across accounts Token to user binding with alert on mismatch Device fingerprint correlation
Pending payment treated as paid Reject purchaseState == 2, wait for RTDN Acknowledge only after state becomes PURCHASED
Missed acknowledgement triggers auto refund Server side acknowledge after grant with retry Monitor acknowledgement failures as high priority
Refund abuse and chargebacks Daily purchases.voidedpurchases.list poll Graduated enforcement (warn, restrict, ban)
Consumable refund after spend Deduct refunded amount from user balance Restrict access until balance is non negative
Account sharing across many devices Concurrent device limit, device fingerprinting Flag high distinct device count per token
Timezone and clock manipulation Server clock is the only clock for access decisions SystemClock.elapsedRealtime() for offline grace
Ban evasion via new account Store device and account identifiers of banned users Apply restrictions on first matching signal

Common Pitfalls

  • Acknowledging from the client. If the client acknowledges before your server grants entitlement, a failure leaves the user charged with no access. Acknowledge on the server, after the grant.
  • Returning 200 on duplicate tokens without checking the original owner. You miss token sharing entirely.
  • Bundling premium assets in assets/ or res/. Any APK extractor pulls them out. Host premium content on your server and gate downloads on verified entitlement.
  • Real time revocation when a void is detected. Batch revocations during your regular sync. Google occasionally reverses voids.
  • Using System.currentTimeMillis() for trial eligibility or offer windows. The device clock is user controlled.

References