A subscription purchase can look successful in the app and still fail at the point where it matters.
I ran into this wiring RYZA’s iOS purchases to a small verification service. StoreKit could complete the purchase. The app could receive the transaction. The premium screen could even look ready to unlock. Then the server request failed.
At first I treated it like a normal network problem. Maybe the endpoint was wrong. Maybe the receipt payload was malformed. Maybe App Store sandbox was being unpredictable, which is not exactly rare.
The useful clue was that the server was not seeing a valid request to verify. The failure happened before the purchase verifier could decide whether the App Store transaction was even real.
The missing piece was App Check.
A purchase is not proof by itself
The app sends purchase information to the verification service. The service checks it with the relevant store, then returns whether premium access should be granted. That path is roughly:
User buys subscription
↓
StoreKit returns transaction data
↓
RYZA sends it to the verification service
↓
Server checks the App Store transaction
↓
Server returns premium status
There is an obvious security problem here. A public purchase-verification endpoint is an attractive target. Someone can send invented product IDs, replay old transaction data, or simply use the endpoint in ways the app never would.
I added Firebase App CheckFirebase’s way of confirming a request came from a genuine, unmodified instance of an app, checked before the request’s contents are trusted to make the server ask one more question before it even looked at the purchase: did this request come from an app instance I am willing to trust? App Check does not prove that a purchase is valid. Store verification still does that. It is a separate gate at the entrance.
Request arrives
↓
Valid App Check token?
↓
No → reject before purchase verification
Yes → verify the store transaction
That separation mattered. I did not want the app to grant premium access just because it had an App Check token: a real app can still send an invalid or expired purchase. And a real purchase payload should not be accepted from an untrusted request source either.
The failure was too quiet
The first version of the flow gave me very little to work with. The app tried to fetch an App Check token. If something went wrong, purchase verification failed, and from the user’s point of view that could look like “I paid, but premium did not unlock.”
That is a rough message to receive. It is also a terrible debugging signal, because I had error handling that was treating several distinct failures as one vague thing:
- App Check did not return a token.
- The token was rejected by the server.
- The server endpoint returned an unexpected HTTP response.
- The request itself timed out or failed.
- The purchase verifier returned “not valid.”
Those do not have the same owner. Some point at the iOS attestation setup, some at the server configuration, some at networking, and some at the purchase itself. I had collapsed all of them into a purchase problem. That was my first mistake.
I changed the iOS App Check provider
On iOS, App Check needs a provider that can produce a token Firebase will accept for the configured app. My earlier release configuration used an App Attest-based provider with a fallback path. During this work I changed the release provider to DeviceCheck.
That was not a claim that one is universally better than the other. It was a practical correction for this app’s release setup and the token path I actually needed to observe.
The bigger lesson was that “Firebase App Check is initialized” is not enough to call the integration complete. The entire chain has to work:
iOS app
→ App Check provider
→ token returned to Flutter
→ X-Firebase-AppCheck request header
→ Cloud Run service
→ Firebase Admin token verification
→ purchase verification
A green initialization line at the top of the app says almost nothing about the middle of that chain.
I made the boundary visible
I added narrow logs on both sides of the request. On the app side, I recorded:
- the platform and the product ID
- whether an App Check token was loaded
- the token length, not the token itself
- HTTP status when the server rejected the request
- whether the server returned a valid purchase result
- request and token-loading failures, through crash diagnostics
I was careful not to log the raw App Check token or purchase receipt. Those are not debugging souvenirs.
On the server side I added logs for verification results and cache hits: platform, product ID, outcome, premium expiry when present, an error category, and a shortened fingerprint rather than a raw purchase token. That gave me a useful trail without turning the logs into a second database of credentials.
After the change, “premium did not unlock” stopped being one opaque report. I could tell whether the request never left the app, arrived without an App Check token, was rejected at the gate, or reached store verification and failed there.
I chose to fail closed
There was a tempting shortcut: if App Check could not get a token, send the purchase request anyway. It would have made testing feel smoother. It also would have made the security boundary optional at exactly the moment it was inconvenient.
I did not take that route in release builds. When App Check is required, RYZA does not
make the purchase-verification request without a token. It returns a specific
app_check_token_unavailable result instead.
That can still be frustrating for a user. But it is an honest failure. The app does not quietly weaken purchase verification, and I have a reason code to investigate rather than a support ticket that starts and ends with “it didn’t work.”
This is also why debug builds need their own plan. A development environment needs a safe way to obtain tokens, or a clearly scoped debug provider. It should not train the production app to treat missing attestation as normal.
The server had its own configuration trap
The server can require App Check, optionally consume tokens for replay protection, and restrict accepted app IDs. Those are useful switches. They also make it easy to build a configuration where every request gets rejected for a valid-looking reason.
For example, the verifier can run in a different Google Cloud project from the Firebase app that issued the token. In that case the service needs to know which Firebase project owns the token, and it can also restrict acceptance to the expected iOS and Android app IDs. A token may be valid in the abstract and still be invalid for this verifier’s policy. That is not a bug in App Check. It is a configuration decision that needs to be visible.
I keep that distinction in mind because “token verification failed” is not one thing. It can mean a bad device attestation, a missing header, the wrong Firebase project, an unexpected app ID, or a server identity that lacks the needed permission.
What I got wrong
I had assumed the hard part of purchase verification was talking to the App Store. That was only one of the hard parts.
The purchase itself sits behind several boundaries: StoreKit, the app’s App Check provider, the request header, Cloud Run, Firebase Admin, and finally the store verification API. Each boundary can fail in a different way, and the user only sees one button: Restore Purchases, or Subscribe.
I did not need a more complicated billing screen. I needed clearer evidence at the boundaries.
The finished flow is still intentionally strict. A purchase is not trusted because the app says it happened. A request is not trusted because it contains a receipt-shaped string. Premium access is granted only after the request comes from a verified app instance and the store transaction checks out.
That is less magical than the original version. It is also much easier to debug when something goes wrong.