Guide · Chapter 1 of 5

Why client-side verification fails

Three attack vectors turn a client-reported "verified" into an unenforceable suggestion — and one identity mistake quietly repeats all three.

Client-side verification means your app asks the store SDK whether a purchase is good, believes the answer, and unlocks the feature locally. The appeal is obvious: no backend, no round trip, no verification service to run. The problem is equally simple — every step of that decision happens on hardware the attacker owns. The store's answer is honest; the code that reads it is not necessarily yours by the time it runs.

This chapter is the "why" for the rest of the guide. It expands the three attack vectors most teams have heard summarized in a bullet list, shows the concrete shape each one takes in a real app, and then covers a fourth mistake that is really the same bug wearing different clothes: trusting an identifier the client sent you.

The three attack vectors

1. Replay — one real purchase, unlimited unlocks

A replay attack does not forge anything. It reuses something genuine.

Concretely: an attacker buys your pro_monthly subscription once, for real money, on a device with a user-installed proxy CA. They capture the exact bytes your app receives — the store's purchase response, or (worse) the 200 OK body your own client-side check produced from it. Then they refund or cancel, wipe the app, reinstall, and replay the captured response into the app's verification path. The app has no way to tell a live response from a recorded one, because from the app's point of view they are byte-identical.

The same credential also travels. A purchase token posted into a forum unlocks the app for everyone who pastes it, because nothing in a client-only design is counting how many identities have redeemed it. The defense is not a smarter client check — it is a server that has seen the token before. Tierux hashes each raw purchase token and uses that hash for deduplication, so a token replayed against a second identity is recognizable as a repeat rather than treated as a fresh grant (purchase validation docs).

Timing matters too. A purchase verified this morning can be refunded, cancelled, or charged back this afternoon. A replayed "verified" snapshot is frozen at its most favourable moment and never expires — server-held entitlement state is what lets a lapse actually take effect.

2. Modified and rooted clients — your branch, their build

The second vector removes the pretense of an honest client entirely.

On a rooted Android device or a jailbroken iPhone, an attacker attaches an instrumentation toolkit to the running process and hooks the one method that matters. Your carefully written isPurchaseValid() now returns true unconditionally — no store call, no network traffic, nothing for a client-side heuristic to notice. No root is required either: a repackaged build works just as well. Decompile the APK, flip the conditional branch that guards the premium feature, re-sign, and distribute. Sideloaders install it happily.

Obfuscation raises the effort; it does not change the outcome. Any check whose result is computed on the attacker's device can be edited on the attacker's device. This is why the interesting question is never "how do I make the client harder to patch" but "what does my server refuse to do when the client lies". A gated API that answers with premium data because the request said {"premium": true} is broken regardless of how well the client was hardened.

Put the boundary where you control both sides: the client renders UI, the server decides access.

3. API key extraction — a shipped secret is a published secret

The third vector is the one that turns a per-user problem into an account-wide one.

Verifying a Google Play purchase properly means calling the Android Publisher API, which needs service-account credentials. Teams building client-only verification sometimes solve this by shipping those credentials in the binary. Extracting them takes minutes: unzip the APK, run strings over the assets and native libraries, or dump the constant pool from the decompiled classes. Anything embedded in a shipped artifact — a JSON key, a shared secret, a bearer token, a value assembled at runtime from three "clever" fragments — is readable by anyone who downloads your app. Certificate pinning does not help, because the attacker is not intercepting the secret in transit; they already have the file.

The blast radius is not one fraudulent unlock. A leaked Play service account is server-level access to your store data on behalf of your developer account.

This is why Tierux treats it as a hard invariant rather than a recommendation: a Tierux API key never appears in a client-side SDK snippet. The app-facing surface uses only a public appId, which selects a project and is explicitly not a secret or an authorization credential; the secret Tierux API key belongs on a trusted server, in the Authorization header of server-to-server calls (client authentication). Keys themselves are stored as SHA-256 hashes, so a database read does not yield a usable credential.

The fourth mistake

Trusting a client-supplied userId is the same class of bug

Suppose you fix all three vectors. Verification now happens on your server, the store API is called with credentials the app never sees, and tokens are deduplicated. Then the gating endpoint does this:

// The client tells you who it is. You believe it.
GET /internal/entitlements?userId="user_8842"

You have rebuilt the original bug one layer up. The purchase evidence is now trustworthy, but the answer to "whose purchase is it?" is still whatever the client typed. Incrementing the identifier in a proxy walks straight into another customer's entitlements.

The invariant is that identity must come from something the server can verify — an identity token it validates itself — rather than from a field in the request body. Tierux's own client entitlement read enforces exactly that: GET /api/client/apps/{appId}/entitlements/{entitlementId} requires a valid Firebase ID token and derives the userId from the verified UID, so there is no body field to tamper with — though today that route only recognizes Tierux's own Firebase project; customer-project federation is not yet available, so external callers use the server-side route below instead. When an app enables enforceAttestation, every client route requires both a valid Firebase ID token and a Firebase App Check token, and a verification body that carries a userId is rejected unless it equals that verified UID.

Be precise about the default, though, because the honest version is more useful than the slogan. The public verification routes accept a submitted opaque userId as an identifier, not as proof of identity — they use it when the body includes one, even if a valid ID token is also supplied, and fall back to the verified Firebase UID only when no userId is submitted. That design is safe only in the server-side pattern where your own trusted backend both submits verification and reads entitlements, and where the identifier is stable and non-enumerable. A sequential customer-1042 or an email address is neither.

Where the user identity comes from on each Tierux route
RouteWhere userId comes from
GET /api/v1/entitlements/{userId}/{entitlementId}Supplied by your trusted server, authenticated with your secret Tierux API key. Your backend is responsible for deriving it from its own verified session.
GET /api/client/apps/{appId}/entitlements/{entitlementId}Derived from the verified UID of a required Firebase ID token. Nothing is read from the request.
POST /api/client/apps/{appId}/purchases/{store}/verifyThe submitted opaque userId when the body includes one — even if a valid ID token is also supplied. Falls back to the verified Firebase UID only when no userId is submitted. Under enforceAttestation, a body userId must equal the verified UID or the request is rejected.

Whichever you pick, pick one. If a purchase is granted against a freshly generated UUID and later read back by Firebase UID, those are two different entitlement records and the user sees a paywall they already paid to remove.

What replaces it

The client never sees the verification result until the server returns it

Every fix above collapses into one structural rule. The client's job ends at handing over evidence; the decision is made somewhere it cannot reach.

1

The client obtains store evidence

A Google Play purchase token, or a StoreKit 2 JWS transaction. This is a credential to be checked, not a verdict to be trusted, and the app makes no access decision from it.

2

The server verifies it independently

Your backend — or Tierux on your behalf — calls the store's API directly with credentials that were never shipped in the app. Google Play verification runs against the Android Publisher API; App Store transactions are checked by validating Apple's JWS signature. Per-feature per-platform status is on the platform support matrix.

3

The result becomes server-held entitlement state

Store-specific responses are normalized into one entitlement record with a state and an expiry, so application logic stays platform-agnostic. Raw purchase tokens are never persisted: a hash is kept for deduplication and an AES-256-GCM encrypted copy only for refresh operations.

4

Gated requests check the record, not the request

Each protected call reads the entitlement server-side. A patched client can render a premium screen; it cannot make the server return premium data.

Notice what this rules out. There is no window in which the app "knows" it is entitled before the server does, so there is nothing to record and replay, no local branch worth patching, and no credential in the binary worth extracting. The three vectors are not mitigated one at a time — they lose the ground they stood on.

It also relocates the hard part. Verification is a single API call; keeping the entitlement honest afterwards is the real work, because a subscription verified today can be cancelled, refunded, put into grace period, or billing-retried tomorrow. That lifecycle problem is what the rest of this guide covers.

Related reading

Client authentication

The route-by-route trust model: which client routes are public, how identity is bound to a verified token, and what enforceAttestation changes.

Server-side receipt validation

The term itself, defined — what "validate on the server" means for Google Play tokens and App Store transactions.

Purchase verification

How Tierux implements the flow above: one call in, normalized entitlement state out, raw tokens never stored.

Server-side purchase verification: the definitive guide

The shorter overview article, including the common pitfalls teams hit when building verification in-house.

← Back to the server-side verification guide

Entitlements in one REST call

Free tier — unlimited apps, 1 paywall. No credit card, no revenue share.

Start free